diff --git a/crates/auths-cli/src/commands/log.rs b/crates/auths-cli/src/commands/log.rs index 11dcbb2a..e483704a 100644 --- a/crates/auths-cli/src/commands/log.rs +++ b/crates/auths-cli/src/commands/log.rs @@ -1,13 +1,10 @@ -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::time::Duration; use anyhow::{Context, Result, bail}; use auths_infra_http::HttpRegistryClient; use auths_sdk::ports::RegistryClient; -use auths_sdk::workflows::compliance::ArtifactDigest; -use auths_transparency::{ - FsTileStore, LogOrigin, LogSigningKey, LogWriter, SignedCheckpoint, hash_leaf, -}; +use auths_transparency::SignedCheckpoint; use clap::{Args, Subcommand}; use serde::Serialize; @@ -15,9 +12,6 @@ use super::executable::ExecutableCommand; use crate::config::CliConfig; use crate::ux::format::{JsonResponse, is_json_mode}; -/// PKCS#8 signing-key file kept inside the local log directory. -const LOG_KEY_FILE: &str = "log.key"; - #[derive(Args, Debug, Clone)] #[command(about = "Inspect, verify, and operate the transparency log")] pub struct LogCommand { @@ -280,65 +274,21 @@ async fn handle_verify(args: &VerifyArgs) -> Result<()> { Ok(()) } -/// Load the log signing key from `/log.key`, creating it on first -/// use when `create` is set (the append path); proving against a log that -/// does not exist yet is an error, not a key-generation event. -fn load_log_key(log_dir: &Path, create: bool) -> Result { - let path = log_dir.join(LOG_KEY_FILE); - match std::fs::read(&path) { - Ok(der) => LogSigningKey::from_pkcs8_der(&der) - .with_context(|| format!("Failed to parse log signing key at {}", path.display())), - Err(e) if e.kind() == std::io::ErrorKind::NotFound && create => { - let key = LogSigningKey::generate().context("Failed to generate log signing key")?; - let der = key - .to_pkcs8_der() - .context("Failed to encode log signing key")?; - std::fs::write(&path, der) - .with_context(|| format!("Failed to write {}", path.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) - .with_context(|| format!("Failed to restrict {}", path.display()))?; - } - Ok(key) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - bail!("No log at {} — append an artifact first", log_dir.display()) - } - Err(e) => { - Err(e).with_context(|| format!("Failed to read log signing key at {}", path.display())) - } - } -} - -fn local_log_writer(log_dir: &Path, origin: &str, create: bool) -> Result> { - let origin = LogOrigin::new(origin).map_err(|e| anyhow::anyhow!("invalid --origin: {e}"))?; - if create { - std::fs::create_dir_all(log_dir) - .with_context(|| format!("Failed to create {}", log_dir.display()))?; - } - let key = load_log_key(log_dir, create)?; - Ok(LogWriter::new( - FsTileStore::new(log_dir.to_path_buf()), - key, - origin, - )) -} - #[allow(clippy::disallowed_methods)] // CLI is the presentation boundary (checkpoint timestamp) async fn handle_append(args: &AppendArgs) -> Result<()> { - let digest = ArtifactDigest::parse(&args.artifact) - .map_err(|e| anyhow::anyhow!("invalid --artifact value: {e}"))?; - let writer = local_log_writer(&args.log_dir, &args.origin, true)?; + let appended = auths_sdk::workflows::transparency::append_artifact_digest( + &args.log_dir, + &args.origin, + &args.artifact, + chrono::Utc::now(), + ) + .await + .context("Failed to append artifact to transparency log")?; - let leaf_hash = hash_leaf(digest.as_str().as_bytes()); - let appended = writer.append(leaf_hash, chrono::Utc::now()).await?; let checkpoint = &appended.signed_checkpoint.checkpoint; - let result = AppendResult { - artifact_digest: digest.as_str().to_string(), - leaf_hash: hex::encode(leaf_hash.as_bytes()), + artifact_digest: appended.artifact_digest, + leaf_hash: hex::encode(appended.leaf_hash.as_bytes()), index: appended.index, size: checkpoint.size, root: hex::encode(checkpoint.root.as_bytes()), @@ -360,12 +310,13 @@ async fn handle_append(args: &AppendArgs) -> Result<()> { } async fn handle_prove(args: &ProveArgs) -> Result<()> { - let digest = ArtifactDigest::parse(&args.artifact) - .map_err(|e| anyhow::anyhow!("invalid --artifact value: {e}"))?; - let writer = local_log_writer(&args.log_dir, &args.origin, false)?; - - let leaf_hash = hash_leaf(digest.as_str().as_bytes()); - let inclusion = writer.prove(&leaf_hash).await?; + let inclusion = auths_sdk::workflows::transparency::prove_artifact_digest( + &args.log_dir, + &args.origin, + &args.artifact, + ) + .await + .context("Failed to prove artifact inclusion")?; if let Some(out) = &args.out { let json = serde_json::to_string_pretty(&inclusion) @@ -375,7 +326,7 @@ async fn handle_prove(args: &ProveArgs) -> Result<()> { JsonResponse::success("log prove", &inclusion).print()?; } else { println!("Inclusion evidence written"); - println!(" Artifact: {}", digest.as_str()); + println!(" Artifact: {}", args.artifact); println!(" Index: {}", inclusion.inclusion_proof.index); println!(" Size: {}", inclusion.inclusion_proof.size); println!(" Out: {}", out.display()); diff --git a/crates/auths-sdk/src/domains/signing/service.rs b/crates/auths-sdk/src/domains/signing/service.rs index ea11d005..d2d24ef7 100644 --- a/crates/auths-sdk/src/domains/signing/service.rs +++ b/crates/auths-sdk/src/domains/signing/service.rs @@ -634,6 +634,11 @@ pub fn validate_commit_sha(sha: &str) -> Result { /// }; /// let result = sign_artifact(params, &ctx)?; /// ``` +// A straight-line dual-signature pipeline: resolve the issuer (root) + device keys, +// build and sign the attestation, then anchor it on the root KEL. The steps are +// sequential with shared locals; splitting the flow would scatter it across helpers +// without making any one part clearer. +#[allow(clippy::too_many_lines)] pub fn sign_artifact( params: ArtifactSigningParams, ctx: &AuthsContext, diff --git a/crates/auths-sdk/src/workflows/dsse.rs b/crates/auths-sdk/src/workflows/dsse.rs new file mode 100644 index 00000000..a0e4c6f4 --- /dev/null +++ b/crates/auths-sdk/src/workflows/dsse.rs @@ -0,0 +1,323 @@ +//! Generic DSSE signing/verification for arbitrary in-toto Statements. +//! +//! The auths DSSE crypto already exists, but it is reachable only welded to the +//! compliance evidence-pack predicate. This module exposes the same envelope + +//! PAE as a **predicate-agnostic** surface: the caller supplies a complete +//! in-toto Statement (its own `predicateType`), and this signs its DSSE +//! pre-authentication encoding with an agent identity's keychain key. The +//! envelope is byte-compatible with the compliance one, so a verdict statement +//! and a compliance statement verify through the same DSSE path — only the +//! predicate differs. +//! +//! Reuses [`crate::domains::signing::service::dsse_pae`] and the +//! [`DsseEnvelope`] wire type so there is exactly one DSSE envelope shape in the +//! SDK. + +use std::sync::Arc; + +use auths_core::signing::{PassphraseProvider, SecureSigner, StorageSigner}; +use auths_core::storage::keychain::{KeyAlias, KeyStorage}; +use auths_crypto::CurveType; +use auths_keri::KeriPublicKey; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use thiserror::Error; + +use crate::domains::compliance::dsse::{DSSE_INTOTO_PAYLOAD_TYPE, DsseEnvelope, DsseSignature}; +use crate::domains::signing::service::dsse_pae; + +/// Errors from generic DSSE statement signing/verification. +#[derive(Debug, Error)] +pub enum DsseError { + /// The caller-supplied statement is not a well-formed in-toto Statement. + #[error("invalid statement: {0}")] + InvalidStatement(String), + + /// Signing the DSSE PAE failed (keychain/passphrase). + #[error("signing failed: {0}")] + Signing(String), + + /// The envelope or its payload could not be decoded. + #[error("decode error: {0}")] + Decode(String), + + /// No signature verified against the pinned key. + #[error("verification failed: {0}")] + Verification(String), +} + +/// In-band curve tag for a signature (never inferred from byte length). +fn curve_tag(curve: CurveType) -> &'static str { + match curve { + CurveType::Ed25519 => "ed25519", + CurveType::P256 => "p256", + } +} + +/// Parse a curve tag; unknown/missing defaults to P-256 (the workspace default). +fn curve_from_tag(tag: &str) -> CurveType { + match tag { + "ed25519" => CurveType::Ed25519, + _ => CurveType::P256, + } +} + +/// Confirm a JSON value is an in-toto Statement: a `_type` and a `predicateType` +/// string are the defining fields (version-agnostic — accepts Statement v0.1/v1). +fn validate_intoto_statement(value: &serde_json::Value) -> Result<(), DsseError> { + let has = |k: &str| value.get(k).and_then(|v| v.as_str()).is_some(); + if !has("_type") || !has("predicateType") { + return Err(DsseError::InvalidStatement( + "an in-toto Statement must carry string `_type` and `predicateType` fields".into(), + )); + } + Ok(()) +} + +/// DSSE-sign an in-toto Statement with a keychain identity (e.g. an agent). +/// +/// The payload is the caller's complete in-toto Statement JSON; its DSSE PAE is +/// signed under `alias`, and the signature's curve travels in-band. The +/// predicate is entirely the caller's — this is predicate-agnostic. +/// +/// Args: +/// * `key_storage` — Keychain holding the signing key. +/// * `passphrase_provider` — Unlocks the key (file-backend keychains). +/// * `keyid` — The signer's `did:keri:`, recorded as the signature `keyid`. +/// * `alias` — Keychain alias of the signing key. +/// * `curve` — The signing key's curve (carried in-band; resolve it, don't guess). +/// * `statement_json` — The complete in-toto Statement to wrap and sign. +/// +/// Usage: +/// ```ignore +/// let env = sign_intoto_statement(keychain, &provider, agent_did, &alias, curve, &statement)?; +/// ``` +pub fn sign_intoto_statement( + key_storage: Arc, + passphrase_provider: &dyn PassphraseProvider, + keyid: &str, + alias: &KeyAlias, + curve: CurveType, + statement_json: &str, +) -> Result { + let value: serde_json::Value = serde_json::from_str(statement_json) + .map_err(|e| DsseError::InvalidStatement(format!("statement is not JSON: {e}")))?; + validate_intoto_statement(&value)?; + + let payload = statement_json.as_bytes(); + let to_sign = dsse_pae(DSSE_INTOTO_PAYLOAD_TYPE, payload); + let signer = StorageSigner::new(key_storage); + let sig = signer + .sign_with_alias(alias, passphrase_provider, &to_sign) + .map_err(|e| DsseError::Signing(e.to_string()))?; + + Ok(DsseEnvelope { + payload_type: DSSE_INTOTO_PAYLOAD_TYPE.to_string(), + payload: BASE64.encode(payload), + signatures: vec![DsseSignature { + keyid: keyid.to_string(), + curve: curve_tag(curve).to_string(), + sig: BASE64.encode(&sig), + }], + }) +} + +/// DSSE-sign an in-toto Statement with a raw 32-byte seed (no keychain). +/// +/// The same as [`sign_intoto_statement`] but for an ephemeral in-memory identity +/// whose seed is held directly rather than in a keychain — it signs the DSSE PAE +/// with `auths_crypto::typed_sign`. The resulting envelope is byte-compatible and +/// verifies through the same [`verify_intoto_statement`] path. +/// +/// Args: +/// * `seed` — The signer's 32-byte private seed. +/// * `curve` — The seed's curve (carried in-band on the signature). +/// * `keyid` — The signer's `did:keri:`, recorded as the signature `keyid`. +/// * `statement_json` — The complete in-toto Statement to wrap and sign. +/// +/// Usage: +/// ```ignore +/// let env = sign_intoto_statement_with_seed(&seed, curve, agent_did, &statement)?; +/// ``` +pub fn sign_intoto_statement_with_seed( + seed: &[u8; 32], + curve: CurveType, + keyid: &str, + statement_json: &str, +) -> Result { + let value: serde_json::Value = serde_json::from_str(statement_json) + .map_err(|e| DsseError::InvalidStatement(format!("statement is not JSON: {e}")))?; + validate_intoto_statement(&value)?; + + let payload = statement_json.as_bytes(); + let to_sign = dsse_pae(DSSE_INTOTO_PAYLOAD_TYPE, payload); + let typed_seed = auths_crypto::TypedSeed::from_curve(curve, *seed); + let sig = auths_crypto::typed_sign(&typed_seed, &to_sign) + .map_err(|e| DsseError::Signing(e.to_string()))?; + + Ok(DsseEnvelope { + payload_type: DSSE_INTOTO_PAYLOAD_TYPE.to_string(), + payload: BASE64.encode(payload), + signatures: vec![DsseSignature { + keyid: keyid.to_string(), + curve: curve_tag(curve).to_string(), + sig: BASE64.encode(&sig), + }], + }) +} + +/// Verify a DSSE-wrapped in-toto Statement against a pinned public key, offline. +/// +/// Recomputes the PAE over the decoded payload and checks that `pinned_public_key` +/// signed it — the curve travels in-band on each signature, so no curve argument +/// is needed. Returns the parsed in-toto Statement on success; a forged, absent, +/// or wrong-key signature is an `Err`. No network, no keychain. +/// +/// Args: +/// * `envelope_json` — The DSSE envelope as produced by [`sign_intoto_statement`]. +/// * `pinned_public_key` — The signer's verkey bytes, pinned out of band. +/// +/// Usage: +/// ```ignore +/// let statement = verify_intoto_statement(&envelope_json, &agent_pubkey)?; +/// assert_eq!(statement["predicateType"], "https://recurve.dev/verdict/v1"); +/// ``` +pub fn verify_intoto_statement( + envelope_json: &str, + pinned_public_key: &[u8], +) -> Result { + let envelope: DsseEnvelope = serde_json::from_str(envelope_json) + .map_err(|e| DsseError::Decode(format!("envelope is not JSON: {e}")))?; + let payload = BASE64 + .decode(envelope.payload.as_bytes()) + .map_err(|e| DsseError::Decode(format!("payload base64: {e}")))?; + let to_verify = dsse_pae(&envelope.payload_type, &payload); + + let verified = envelope.signatures.iter().any(|s| { + let curve = curve_from_tag(&s.curve); + let Ok(key) = KeriPublicKey::from_verkey_bytes(pinned_public_key, curve) else { + return false; + }; + let Ok(sig) = BASE64.decode(s.sig.as_bytes()) else { + return false; + }; + key.verify_signature(&to_verify, &sig).is_ok() + }); + + if !verified { + return Err(DsseError::Verification( + "no DSSE signature verified against the pinned key".into(), + )); + } + + let statement: serde_json::Value = serde_json::from_slice(&payload) + .map_err(|e| DsseError::Decode(format!("payload is not JSON: {e}")))?; + validate_intoto_statement(&statement)?; + Ok(statement) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use auths_crypto::testing::generate_typed_signer; + + fn intoto_statement(gate: &str) -> String { + serde_json::json!({ + "_type": "https://in-toto.io/Statement/v1", + "subject": [{"name": "recurve.gate", "digest": {"sha256": "ab".repeat(32)}}], + "predicateType": "https://recurve.dev/verdict/v1", + "predicate": {"gate": gate} + }) + .to_string() + } + + fn signed_envelope(curve: CurveType, statement: &str) -> (String, Vec) { + let signer = generate_typed_signer(curve); + let payload = statement.as_bytes(); + let pae = dsse_pae(DSSE_INTOTO_PAYLOAD_TYPE, payload); + let sig = signer.sign(&pae).unwrap(); + let env = DsseEnvelope { + payload_type: DSSE_INTOTO_PAYLOAD_TYPE.to_string(), + payload: BASE64.encode(payload), + signatures: vec![DsseSignature { + keyid: "did:keri:EAgent".into(), + curve: curve_tag(curve).to_string(), + sig: BASE64.encode(&sig), + }], + }; + ( + serde_json::to_string(&env).unwrap(), + signer.public_key().to_vec(), + ) + } + + #[test] + fn verify_round_trips_ed25519() { + let (env, pk) = signed_envelope(CurveType::Ed25519, &intoto_statement("GREEN")); + let stmt = verify_intoto_statement(&env, &pk).unwrap(); + assert_eq!(stmt["predicateType"], "https://recurve.dev/verdict/v1"); + assert_eq!(stmt["predicate"]["gate"], "GREEN"); + } + + #[test] + fn verify_round_trips_p256() { + let (env, pk) = signed_envelope(CurveType::P256, &intoto_statement("GREEN")); + verify_intoto_statement(&env, &pk).unwrap(); + } + + #[test] + fn verify_rejects_tampered_payload() { + let (env, pk) = signed_envelope(CurveType::Ed25519, &intoto_statement("GREEN")); + let mut envelope: DsseEnvelope = serde_json::from_str(&env).unwrap(); + // Swap in a different, well-formed statement — the signature no longer binds it. + envelope.payload = BASE64.encode(intoto_statement("RED").as_bytes()); + let err = + verify_intoto_statement(&serde_json::to_string(&envelope).unwrap(), &pk).unwrap_err(); + assert!(matches!(err, DsseError::Verification(_))); + } + + #[test] + fn sign_with_seed_round_trips_and_binds() { + let signer = generate_typed_signer(CurveType::P256); + let seed = *signer.seed().as_bytes(); + let env = sign_intoto_statement_with_seed( + &seed, + signer.curve(), + "did:keri:EAgent", + &intoto_statement("GREEN"), + ) + .unwrap(); + let stmt = + verify_intoto_statement(&serde_json::to_string(&env).unwrap(), signer.public_key()) + .unwrap(); + assert_eq!(stmt["predicate"]["gate"], "GREEN"); + + let mut envelope: DsseEnvelope = + serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap(); + envelope.payload = BASE64.encode(intoto_statement("RED").as_bytes()); + let err = verify_intoto_statement( + &serde_json::to_string(&envelope).unwrap(), + signer.public_key(), + ) + .unwrap_err(); + assert!(matches!(err, DsseError::Verification(_))); + } + + #[test] + fn verify_rejects_wrong_key() { + let (env, _pk) = signed_envelope(CurveType::Ed25519, &intoto_statement("GREEN")); + let stranger = generate_typed_signer(CurveType::Ed25519) + .public_key() + .to_vec(); + let err = verify_intoto_statement(&env, &stranger).unwrap_err(); + assert!(matches!(err, DsseError::Verification(_))); + } + + #[test] + fn validate_rejects_non_intoto_statement() { + // Missing predicateType — not an in-toto Statement. + let bad = serde_json::json!({"_type": "https://in-toto.io/Statement/v1"}); + assert!(validate_intoto_statement(&bad).is_err()); + } +} diff --git a/crates/auths-sdk/src/workflows/mod.rs b/crates/auths-sdk/src/workflows/mod.rs index d28cfbc3..ef5bec73 100644 --- a/crates/auths-sdk/src/workflows/mod.rs +++ b/crates/auths-sdk/src/workflows/mod.rs @@ -12,6 +12,8 @@ pub mod commit_trust; /// Compliance-as-a-query: evidence packs, DSSE org-signing, offline verification. pub mod compliance; pub mod diagnostics; +/// Predicate-agnostic DSSE signing/verification for arbitrary in-toto Statements. +pub mod dsse; pub mod federation; pub mod git_integration; pub mod log_submit; diff --git a/crates/auths-sdk/src/workflows/transparency.rs b/crates/auths-sdk/src/workflows/transparency.rs index 9d757c24..1c74516f 100644 --- a/crates/auths-sdk/src/workflows/transparency.rs +++ b/crates/auths-sdk/src/workflows/transparency.rs @@ -1,6 +1,6 @@ //! SDK transparency verification workflows. -use std::path::Path; +use std::path::{Path, PathBuf}; use auths_core::ports::config_store::{ConfigStore, ConfigStoreError}; use auths_core::ports::network::{NetworkError, RegistryClient}; @@ -8,13 +8,20 @@ use auths_keri::witness::independence::{ IndependencePolicy, Infrastructure, Jurisdiction, OperatorId, Organization, WitnessOperatorInfo, }; use auths_transparency::{ - BundleVerificationReport, ConsistencyProof, LogOrigin, OfflineBundle, SignedCheckpoint, - TrustRoot, TrustRootWitness, + BundleVerificationReport, ConsistencyProof, FsTileStore, LogOrigin, LogSigningKey, LogWriter, + MerkleHash, OfflineBundle, SignedCheckpoint, TransparencyError, TrustRoot, TrustRootWitness, + hash_leaf, }; use auths_verifier::Ed25519PublicKey; +use auths_verifier::evidence_pack::TransparencyInclusion; use chrono::{DateTime, Utc}; use thiserror::Error; +use crate::domains::compliance::releases::ArtifactDigest; + +/// PKCS#8 signing-key file kept inside a local log directory. +const LOG_KEY_FILE: &str = "log.key"; + /// Errors from transparency verification workflows. #[derive(Debug, Error)] pub enum TransparencyWorkflowError { @@ -37,6 +44,28 @@ pub enum TransparencyWorkflowError { /// Network error fetching trust root or other remote data. #[error("network error: {0}")] NetworkError(#[source] NetworkError), + + /// Invalid caller input — a malformed artifact digest or log origin. + #[error("invalid input: {0}")] + InvalidInput(String), + + /// No local transparency log exists at the given directory yet. + #[error("no transparency log at {0} — append an artifact first")] + LogNotFound(PathBuf), + + /// Local transparency-log file I/O failed. + #[error("transparency log I/O error at {path}: {source}")] + LogIo { + /// Path whose access failed. + path: PathBuf, + /// Underlying I/O error. + #[source] + source: std::io::Error, + }, + + /// An underlying transparency-log operation (append, prove, key) failed. + #[error("transparency log error: {0}")] + Transparency(#[from] TransparencyError), } /// Wire-format response from the registry trust-root endpoint. @@ -373,6 +402,151 @@ pub fn try_cache_checkpoint( }) } +/// Outcome of appending an artifact digest to a local transparency log. +/// +/// Carries the sequenced position and the fresh signed checkpoint so a +/// presentation layer can render or persist them; the raw leaf hash is +/// retained for callers that re-derive it (`hash_leaf(artifact_digest)`). +#[derive(Debug, Clone)] +pub struct AppendedArtifact { + /// Canonical `sha256:` digest that was logged. + pub artifact_digest: String, + /// Merkle leaf hash the digest was stored under. + pub leaf_hash: MerkleHash, + /// Zero-based index the leaf was sequenced at. + pub index: u64, + /// The checkpoint signed over the tree that now includes the leaf. + pub signed_checkpoint: SignedCheckpoint, +} + +/// Load the log signing key from `/log.key`, creating it on first +/// use when `create` is set (the append path); proving against a log that +/// does not exist yet is an error, not a key-generation event. +fn load_log_key(log_dir: &Path, create: bool) -> Result { + let path = log_dir.join(LOG_KEY_FILE); + match std::fs::read(&path) { + Ok(der) => LogSigningKey::from_pkcs8_der(&der).map_err(TransparencyWorkflowError::from), + Err(e) if e.kind() == std::io::ErrorKind::NotFound && create => { + let key = LogSigningKey::generate()?; + let der = key.to_pkcs8_der()?; + std::fs::write(&path, der).map_err(|source| TransparencyWorkflowError::LogIo { + path: path.clone(), + source, + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).map_err( + |source| TransparencyWorkflowError::LogIo { + path: path.clone(), + source, + }, + )?; + } + Ok(key) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err( + TransparencyWorkflowError::LogNotFound(log_dir.to_path_buf()), + ), + Err(source) => Err(TransparencyWorkflowError::LogIo { path, source }), + } +} + +/// Open a writer over the local tile-backed log, validating the origin and +/// (when `create`) ensuring the log directory and signing key exist. +fn open_log_writer( + log_dir: &Path, + origin: &str, + create: bool, +) -> Result, TransparencyWorkflowError> { + let origin = LogOrigin::new(origin) + .map_err(|e| TransparencyWorkflowError::InvalidInput(format!("invalid log origin: {e}")))?; + if create { + std::fs::create_dir_all(log_dir).map_err(|source| TransparencyWorkflowError::LogIo { + path: log_dir.to_path_buf(), + source, + })?; + } + let key = load_log_key(log_dir, create)?; + Ok(LogWriter::new( + FsTileStore::new(log_dir.to_path_buf()), + key, + origin, + )) +} + +/// Append an artifact digest to a local tile-backed transparency log. +/// +/// Validates the digest, creates the log directory and signing key on first +/// use, hashes the canonical digest string into a leaf, appends it, and +/// returns the sequenced position plus the newly signed checkpoint. The log +/// is append-only: repeated calls grow the tree. +/// +/// Args: +/// * `log_dir` — Directory holding the tile store and `log.key`. +/// * `origin` — Log origin string (non-empty ASCII) written into checkpoints. +/// * `artifact_digest` — Artifact digest to log (`sha256:<64 hex>`). +/// * `now` — Injected wall-clock time stamped into the checkpoint. +/// +/// Usage: +/// ```ignore +/// let appended = +/// append_artifact_digest(&log_dir, "acme.dev/releases", "sha256:ab..cd", now).await?; +/// ``` +pub async fn append_artifact_digest( + log_dir: &Path, + origin: &str, + artifact_digest: &str, + now: DateTime, +) -> Result { + let digest = ArtifactDigest::parse(artifact_digest).map_err(|e| { + TransparencyWorkflowError::InvalidInput(format!("invalid artifact digest: {e}")) + })?; + let writer = open_log_writer(log_dir, origin, true)?; + + let leaf_hash = hash_leaf(digest.as_str().as_bytes()); + let appended = writer.append(leaf_hash, now).await?; + + Ok(AppendedArtifact { + artifact_digest: digest.into_inner(), + leaf_hash, + index: appended.index, + signed_checkpoint: appended.signed_checkpoint, + }) +} + +/// Emit offline inclusion evidence for an artifact digest already appended to +/// a local transparency log. +/// +/// Validates the digest, re-derives its leaf, and proves the leaf against the +/// current signed checkpoint. Errors when no log exists yet ([`TransparencyWorkflowError::LogNotFound`]) +/// or the leaf was never appended (an [`TransparencyWorkflowError::Transparency`] +/// invalid-proof error). The returned evidence is re-verifiable offline. +/// +/// Args: +/// * `log_dir` — Directory holding the tile store and `log.key`. +/// * `origin` — Log origin string (must match the one used to append). +/// * `artifact_digest` — Artifact digest to prove (`sha256:<64 hex>`). +/// +/// Usage: +/// ```ignore +/// let evidence = prove_artifact_digest(&log_dir, "acme.dev/releases", "sha256:ab..cd").await?; +/// ``` +pub async fn prove_artifact_digest( + log_dir: &Path, + origin: &str, + artifact_digest: &str, +) -> Result { + let digest = ArtifactDigest::parse(artifact_digest).map_err(|e| { + TransparencyWorkflowError::InvalidInput(format!("invalid artifact digest: {e}")) + })?; + let writer = open_log_writer(log_dir, origin, false)?; + + let leaf_hash = hash_leaf(digest.as_str().as_bytes()); + let inclusion = writer.prove(&leaf_hash).await?; + Ok(inclusion) +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used, clippy::disallowed_methods)] mod tests { @@ -694,4 +868,123 @@ mod tests { TransparencyWorkflowError::CheckpointInconsistent(_) )); } + + // ---- local-log append / prove workflow ---- + + const TEST_DIGEST: &str = + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const TEST_DIGEST_2: &str = + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + fn fixed_now() -> DateTime { + chrono::DateTime::parse_from_rfc3339("2026-07-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc) + } + + #[tokio::test] + async fn append_creates_log_and_returns_sequenced_leaf() { + let dir = tempfile::tempdir().unwrap(); + + let appended = append_artifact_digest(dir.path(), "test.dev/log", TEST_DIGEST, fixed_now()) + .await + .unwrap(); + + assert_eq!(appended.index, 0); + assert_eq!(appended.artifact_digest, TEST_DIGEST); + assert_eq!(appended.signed_checkpoint.checkpoint.size, 1); + assert_eq!(appended.leaf_hash, hash_leaf(TEST_DIGEST.as_bytes())); + assert!(dir.path().join("log.key").exists()); + } + + #[tokio::test] + async fn append_normalizes_uppercase_hex() { + let dir = tempfile::tempdir().unwrap(); + let upper = TEST_DIGEST + .to_ascii_uppercase() + .replace("SHA256:", "sha256:"); + + let appended = append_artifact_digest(dir.path(), "test.dev/log", &upper, fixed_now()) + .await + .unwrap(); + + assert_eq!(appended.artifact_digest, TEST_DIGEST); + assert_eq!(appended.leaf_hash, hash_leaf(TEST_DIGEST.as_bytes())); + } + + #[tokio::test] + async fn append_is_append_only_and_grows_the_tree() { + let dir = tempfile::tempdir().unwrap(); + + let a = append_artifact_digest(dir.path(), "test.dev/log", TEST_DIGEST, fixed_now()) + .await + .unwrap(); + let b = append_artifact_digest(dir.path(), "test.dev/log", TEST_DIGEST_2, fixed_now()) + .await + .unwrap(); + + assert_eq!(a.index, 0); + assert_eq!(b.index, 1); + assert_eq!(b.signed_checkpoint.checkpoint.size, 2); + } + + #[tokio::test] + async fn append_then_prove_round_trips_and_proof_verifies() { + let dir = tempfile::tempdir().unwrap(); + append_artifact_digest(dir.path(), "test.dev/log", TEST_DIGEST, fixed_now()) + .await + .unwrap(); + + let inclusion = prove_artifact_digest(dir.path(), "test.dev/log", TEST_DIGEST) + .await + .unwrap(); + + assert_eq!(inclusion.inclusion_proof.index, 0); + assert_eq!(inclusion.inclusion_proof.size, 1); + assert_eq!(inclusion.leaf_hash, hash_leaf(TEST_DIGEST.as_bytes())); + inclusion + .inclusion_proof + .verify(&inclusion.leaf_hash) + .unwrap(); + } + + #[tokio::test] + async fn prove_without_any_log_errors() { + let dir = tempfile::tempdir().unwrap(); + let err = prove_artifact_digest(dir.path(), "test.dev/log", TEST_DIGEST) + .await + .unwrap_err(); + assert!(matches!(err, TransparencyWorkflowError::LogNotFound(_))); + } + + #[tokio::test] + async fn prove_absent_leaf_errors() { + let dir = tempfile::tempdir().unwrap(); + append_artifact_digest(dir.path(), "test.dev/log", TEST_DIGEST, fixed_now()) + .await + .unwrap(); + + let err = prove_artifact_digest(dir.path(), "test.dev/log", TEST_DIGEST_2) + .await + .unwrap_err(); + assert!(matches!(err, TransparencyWorkflowError::Transparency(_))); + } + + #[tokio::test] + async fn append_rejects_malformed_digest() { + let dir = tempfile::tempdir().unwrap(); + let err = append_artifact_digest(dir.path(), "test.dev/log", "not-a-digest", fixed_now()) + .await + .unwrap_err(); + assert!(matches!(err, TransparencyWorkflowError::InvalidInput(_))); + } + + #[tokio::test] + async fn append_rejects_empty_origin() { + let dir = tempfile::tempdir().unwrap(); + let err = append_artifact_digest(dir.path(), "", TEST_DIGEST, fixed_now()) + .await + .unwrap_err(); + assert!(matches!(err, TransparencyWorkflowError::InvalidInput(_))); + } } diff --git a/packages/auths-python/Cargo.lock b/packages/auths-python/Cargo.lock index 0898aaf7..bbc707ad 100644 --- a/packages/auths-python/Cargo.lock +++ b/packages/auths-python/Cargo.lock @@ -386,6 +386,7 @@ dependencies = [ "auths-verifier", "axum", "chrono", + "git2", "hex", "json-canon", "pyo3", @@ -395,6 +396,7 @@ dependencies = [ "serde_json", "sha2", "shellexpand", + "tempfile", "tokio", "url", "uuid", diff --git a/packages/auths-python/Cargo.toml b/packages/auths-python/Cargo.toml index 74fddb87..0229aa33 100644 --- a/packages/auths-python/Cargo.toml +++ b/packages/auths-python/Cargo.toml @@ -34,6 +34,9 @@ auths-storage = { path = "../../crates/auths-storage", features = ["backend-git" auths-policy = { path = "../../crates/auths-policy" } auths-pairing-daemon = { path = "../../crates/auths-pairing-daemon" } auths-infra-git = { path = "../../crates/auths-infra-git" } +git2 = { version = "0.21.0", default-features = false, features = ["vendored-libgit2"] } +tempfile = "3" + shellexpand = "3" url = "2" sha2 = "0.10" diff --git a/packages/auths-python/python/auths/__init__.py b/packages/auths-python/python/auths/__init__.py index 54d0bac0..89614c76 100644 --- a/packages/auths-python/python/auths/__init__.py +++ b/packages/auths-python/python/auths/__init__.py @@ -13,6 +13,7 @@ VerificationError, ) from auths._native import ( + PASSPHRASE_MIN_LEN, ChainLink, CredentialReport, CredentialStatus, @@ -26,7 +27,9 @@ sign_action, sign_artifact_bytes_raw, sign_bytes, + validate_passphrase, verify_action_envelope, + verify_bytes, verify_at_time, verify_attestation, verify_chain, @@ -50,6 +53,8 @@ from auths.trust import TrustEntry, TrustLevel, TrustService from auths.witness import Witness, WitnessService from auths.artifact import ArtifactPublishResult, ArtifactSigningResult +from auths.tlog import LogAppendResult, log_append, log_prove, log_verify_inclusion +from auths import dsse from auths.attestation_query import Attestation, AttestationService from auths.commit import CommitSigningResult from auths.jwt import AuthsClaims @@ -92,4 +97,8 @@ "AuthsError", "ChainLink", "VerificationStatus", + + # Passphrase policy (pre-flight validation) + "validate_passphrase", + "PASSPHRASE_MIN_LEN", ] diff --git a/packages/auths-python/python/auths/__init__.pyi b/packages/auths-python/python/auths/__init__.pyi index 8f208916..96c715f1 100644 --- a/packages/auths-python/python/auths/__init__.pyi +++ b/packages/auths-python/python/auths/__init__.pyi @@ -164,6 +164,12 @@ def generate_inmemory_keypair() -> tuple[str, str, str]: def sign_bytes(private_key_hex: str, message: bytes) -> str: ... def sign_action(private_key_hex: str, action_type: str, payload_json: str, identity_did: str) -> str: ... def verify_action_envelope(envelope_json: str, public_key_hex: str) -> VerificationResult: ... +def verify_bytes(message: bytes, signature_hex: str, public_key_hex: str) -> bool: ... + +# -- Passphrase policy -- + +PASSPHRASE_MIN_LEN: int +def validate_passphrase(passphrase: str) -> None: ... # -- Token -- diff --git a/packages/auths-python/python/auths/dsse.py b/packages/auths-python/python/auths/dsse.py new file mode 100644 index 00000000..53c1f866 --- /dev/null +++ b/packages/auths-python/python/auths/dsse.py @@ -0,0 +1,80 @@ +"""Generic DSSE — DSSE-wrap an in-toto Statement with an agent identity, verify offline. + +The signing/verification logic lives in the Rust SDK (`auths_sdk::workflows::dsse`); +this is a thin, Pythonic wrapper. The in-toto *predicate* is entirely the caller's — +e.g. a ``recurve.dev/verdict/v1`` code-correctness verdict. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Union + +from auths._native import dsse_sign_statement as _dsse_sign_statement +from auths._native import dsse_verify_statement as _dsse_verify_statement + +PathLike = Union[str, "Path"] + +# The DSSE payload type for an in-toto Statement (RFC / in-toto attestation spec). +INTOTO_PAYLOAD_TYPE = "application/vnd.in-toto+json" +# The in-toto Statement schema type. +INTOTO_STATEMENT_TYPE = "https://in-toto.io/Statement/v1" + + +def intoto_statement(subject: list[dict[str, Any]], predicate_type: str, predicate: dict[str, Any]) -> str: + """Build a canonical in-toto Statement JSON string. + + Args: + subject: The statement subjects (``[{"name": ..., "digest": {"sha256": ...}}]``). + predicate_type: The predicate type URI (e.g. ``recurve.dev/verdict/v1``). + predicate: The predicate payload (the claim being attested). + + Returns: + The in-toto Statement as a compact, sorted-key JSON string. + """ + statement = { + "_type": INTOTO_STATEMENT_TYPE, + "subject": subject, + "predicateType": predicate_type, + "predicate": predicate, + } + return json.dumps(statement, sort_keys=True, separators=(",", ":")) + + +def sign_statement( + statement_json: str, + key_alias: str, + keyid_did: str, + repo_path: PathLike, + passphrase: str | None = None, +) -> str: + """DSSE-sign an in-toto Statement with an agent identity's key. + + Args: + statement_json: The complete in-toto Statement to wrap and sign. + key_alias: Keychain alias of the agent's signing key. + keyid_did: The agent's ``did:keri:`` (recorded as the signature keyid). + repo_path: Path to the agent's auths keychain/repo. + passphrase: Optional passphrase (else ``AUTHS_PASSPHRASE``). + + Returns: + The DSSE envelope as a JSON string. + """ + return _dsse_sign_statement(statement_json, key_alias, keyid_did, str(repo_path), passphrase) + + +def verify_statement(envelope_json: str, public_key_hex: str) -> dict[str, Any]: + """Verify a DSSE-wrapped in-toto Statement offline against a pinned key. + + Args: + envelope_json: The DSSE envelope from :func:`sign_statement`. + public_key_hex: The agent's verkey, hex-encoded. + + Returns: + The verified in-toto Statement (parsed dict). + + Raises: + ValueError: if no signature verifies against the key (forged/absent/wrong key). + """ + return json.loads(_dsse_verify_statement(envelope_json, public_key_hex)) diff --git a/packages/auths-python/python/auths/tlog.py b/packages/auths-python/python/auths/tlog.py new file mode 100644 index 00000000..3a4d139e --- /dev/null +++ b/packages/auths-python/python/auths/tlog.py @@ -0,0 +1,119 @@ +"""Transparency log — append artifact digests, prove inclusion, and verify it offline. + +The append/prove/verify logic lives in the Rust SDK (`auths_sdk::workflows::transparency`) +and the offline verifier (`auths_verifier::evidence_pack`); this module is a thin, +Pythonic wrapper over the native bindings. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Union + +from auths._native import log_append as _log_append +from auths._native import log_prove as _log_prove +from auths._native import log_verify_inclusion as _log_verify_inclusion + +PathLike = Union[str, "Path"] + + +@dataclass +class LogAppendResult: + """Outcome of appending an artifact digest to a local transparency log. + + Pin `.log_public_key` to later verify the evidence `log_prove` mints; the + `.checkpoint_json` is the full signed checkpoint the leaf is anchored to. + """ + + artifact_digest: str + """Canonical ``sha256:`` digest that was logged.""" + leaf_hash: str + """Hex-encoded Merkle leaf hash the digest was stored under.""" + index: int + """Zero-based index the leaf was sequenced at.""" + size: int + """Tree size of the checkpoint that now includes the leaf.""" + root: str + """Hex-encoded Merkle root of that checkpoint.""" + origin: str + """The log's origin line.""" + log_public_key: str + """Hex-encoded Ed25519 key the checkpoint is signed with.""" + checkpoint_json: str + """The full signed checkpoint, JSON-serialized.""" + + def __repr__(self) -> str: + return f"LogAppendResult(index={self.index}, size={self.size}, origin={self.origin!r})" + + +def log_append( + artifact_digest: str, + log_dir: PathLike, + origin: str = "auths.local/log", +) -> LogAppendResult: + """Append an artifact digest to a local tile-backed transparency log. + + Creates the log directory and signing key on first use. The log is + append-only: repeated calls grow the tree and return increasing indices. + + Args: + artifact_digest: The digest to log (``sha256:<64 hex>``). + log_dir: Directory holding the tile store and ``log.key``. + origin: The log's origin line, written into every checkpoint. + + Returns: + A :class:`LogAppendResult` with the sequenced position and checkpoint. + """ + r = _log_append(artifact_digest, str(log_dir), origin) + return LogAppendResult( + artifact_digest=r.artifact_digest, + leaf_hash=r.leaf_hash, + index=r.index, + size=r.size, + root=r.root, + origin=r.origin, + log_public_key=r.log_public_key, + checkpoint_json=r.checkpoint_json, + ) + + +def log_prove( + artifact_digest: str, + log_dir: PathLike, + origin: str = "auths.local/log", +) -> str: + """Emit offline inclusion evidence (JSON) for an already-appended digest. + + Args: + artifact_digest: The digest to prove (``sha256:<64 hex>``). + log_dir: Directory holding the tile store and ``log.key``. + origin: The log's origin line (must match the appended log). + + Returns: + A serialized ``TransparencyInclusion`` verifiable with zero network. + """ + return _log_prove(artifact_digest, str(log_dir), origin) + + +def log_verify_inclusion( + evidence_json: str, + artifact_digest: str, + log_public_key: str, +) -> bool: + """Verify inclusion evidence against a pinned log key, fully offline. + + Fail-closed: the evidence must bind to this artifact (leaf re-derives from + the digest), the Merkle proof must verify against the embedded signed + checkpoint, and that checkpoint must be signed by ``log_public_key``. A + forged, absent, or mismatched proof raises :class:`ValueError`. + + Args: + evidence_json: The serialized inclusion evidence from :func:`log_prove`. + artifact_digest: The canonical ``sha256:`` digest to bind to. + log_public_key: Hex-encoded Ed25519 key the checkpoint must be signed by. + + Returns: + ``True`` on success; raises ``ValueError`` on any failure. + """ + return _log_verify_inclusion(evidence_json, artifact_digest, log_public_key) diff --git a/packages/auths-python/src/dsse.rs b/packages/auths-python/src/dsse.rs new file mode 100644 index 00000000..0725b59d --- /dev/null +++ b/packages/auths-python/src/dsse.rs @@ -0,0 +1,155 @@ +//! Generic DSSE bindings: DSSE-sign an arbitrary in-toto Statement with an agent +//! identity, and verify a DSSE envelope offline against a pinned key. Thin +//! wrappers over `auths_sdk::workflows::dsse` — the predicate is entirely the +//! caller's (e.g. a `recurve.dev/verdict/v1` verdict). + +use std::sync::Arc; + +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; + +use auths_core::signing::PrefilledPassphraseProvider; +use auths_core::storage::keychain::{ + KeyAlias, KeyStorage, extract_public_key_bytes, get_platform_keychain_with_config, +}; +use auths_sdk::workflows::dsse::{ + DsseError, sign_intoto_statement, sign_intoto_statement_with_seed, verify_intoto_statement, +}; + +use crate::identity::{make_keychain_config, resolve_passphrase}; + +/// Map a DSSE workflow error onto a tagged Python exception. +fn map_dsse_err(e: DsseError) -> PyErr { + match e { + DsseError::InvalidStatement(m) => { + PyValueError::new_err(format!("[AUTHS_INVALID_INPUT] {m}")) + } + DsseError::Decode(m) => PyValueError::new_err(format!("[AUTHS_INVALID_INPUT] {m}")), + DsseError::Verification(m) => { + PyValueError::new_err(format!("[AUTHS_VERIFICATION_FAILED] {m}")) + } + DsseError::Signing(m) => PyRuntimeError::new_err(format!("[AUTHS_SIGNING_FAILED] {m}")), + } +} + +/// DSSE-sign an in-toto Statement with an agent identity's key. +/// +/// The statement's `predicateType` is entirely the caller's; the key's curve is +/// resolved from the keychain (never guessed) and travels in-band on the +/// signature. Returns the DSSE envelope as a JSON string. +/// +/// Args: +/// * `statement_json`: The complete in-toto Statement to wrap and sign. +/// * `key_alias`: Keychain alias of the agent's signing key. +/// * `keyid_did`: The agent's `did:keri:`, recorded as the signature keyid. +/// * `repo_path`: Path to the agent's auths keychain/repo. +/// * `passphrase`: Optional passphrase (else `AUTHS_PASSPHRASE`). +/// +/// Usage: +/// ```ignore +/// let env = dsse_sign_statement(py, statement, "recurve-ci-agent", did, keychain, None)?; +/// ``` +#[pyfunction] +#[pyo3(signature = (statement_json, key_alias, keyid_did, repo_path, passphrase=None))] +pub fn dsse_sign_statement( + _py: Python<'_>, + statement_json: String, + key_alias: String, + keyid_did: String, + repo_path: String, + passphrase: Option, +) -> PyResult { + let passphrase_str = resolve_passphrase(passphrase); + let env_config = make_keychain_config(&passphrase_str, &repo_path); + let keychain: Arc = Arc::from( + get_platform_keychain_with_config(&env_config) + .map_err(|e| PyRuntimeError::new_err(format!("[AUTHS_KEYCHAIN_ERROR] {e}")))?, + ); + let provider = PrefilledPassphraseProvider::new(&passphrase_str); + let alias = KeyAlias::new(&key_alias).map_err(|e| { + PyValueError::new_err(format!("[AUTHS_KEY_NOT_FOUND] invalid key alias: {e}")) + })?; + + let (_pk, curve) = extract_public_key_bytes(keychain.as_ref(), &alias, &provider) + .map_err(|e| PyRuntimeError::new_err(format!("[AUTHS_KEY_NOT_FOUND] {e}")))?; + + let envelope = sign_intoto_statement( + keychain, + &provider, + &keyid_did, + &alias, + curve, + &statement_json, + ) + .map_err(map_dsse_err)?; + + serde_json::to_string(&envelope) + .map_err(|e| PyRuntimeError::new_err(format!("[AUTHS_SERIALIZATION_ERROR] {e}"))) +} + +/// DSSE-sign an in-toto Statement with a raw private seed (an ephemeral agent). +/// +/// The same as `dsse_sign_statement` but for an in-memory identity whose 32-byte +/// seed is held directly rather than in a keychain. The envelope verifies through +/// the same `dsse_verify_statement` path. +/// +/// Args: +/// * `statement_json`: The complete in-toto Statement to wrap and sign. +/// * `private_key_hex`: The agent's 32-byte private seed, hex-encoded. +/// * `keyid_did`: The agent's `did:keri:`, recorded as the signature keyid. +/// * `curve`: Optional curve (`"p256"` default, `"ed25519"`). +#[pyfunction] +#[pyo3(signature = (statement_json, private_key_hex, keyid_did, curve=None))] +pub fn dsse_sign_statement_with_key( + _py: Python<'_>, + statement_json: String, + private_key_hex: String, + keyid_did: String, + curve: Option<&str>, +) -> PyResult { + let seed_vec = hex::decode(&private_key_hex).map_err(|e| { + PyValueError::new_err(format!( + "[AUTHS_INVALID_INPUT] invalid private key hex: {e}" + )) + })?; + let seed: [u8; 32] = seed_vec + .as_slice() + .try_into() + .map_err(|_| PyValueError::new_err("[AUTHS_INVALID_INPUT] private key must be 32 bytes"))?; + let curve_type = match curve { + Some("ed25519") | Some("Ed25519") => auths_crypto::CurveType::Ed25519, + _ => auths_crypto::CurveType::default(), + }; + let envelope = sign_intoto_statement_with_seed(&seed, curve_type, &keyid_did, &statement_json) + .map_err(map_dsse_err)?; + serde_json::to_string(&envelope) + .map_err(|e| PyRuntimeError::new_err(format!("[AUTHS_SERIALIZATION_ERROR] {e}"))) +} + +/// Verify a DSSE-wrapped in-toto Statement offline against a pinned public key. +/// +/// Returns the in-toto Statement (JSON string) on success; raises ValueError if +/// no signature verifies against the key (forged, absent, or wrong key). The +/// signature's curve is read in-band, so only the raw verkey hex is needed. +/// +/// Args: +/// * `envelope_json`: The DSSE envelope from `dsse_sign_statement`. +/// * `public_key_hex`: The agent's verkey, hex-encoded. +/// +/// Usage: +/// ```ignore +/// let statement = dsse_verify_statement(py, envelope, agent_pubkey_hex)?; +/// ``` +#[pyfunction] +pub fn dsse_verify_statement( + _py: Python<'_>, + envelope_json: String, + public_key_hex: String, +) -> PyResult { + let pk = hex::decode(&public_key_hex).map_err(|e| { + PyValueError::new_err(format!("[AUTHS_INVALID_INPUT] invalid public key hex: {e}")) + })?; + let statement = verify_intoto_statement(&envelope_json, &pk).map_err(map_dsse_err)?; + serde_json::to_string(&statement) + .map_err(|e| PyRuntimeError::new_err(format!("[AUTHS_SERIALIZATION_ERROR] {e}"))) +} diff --git a/packages/auths-python/src/ephemeral.rs b/packages/auths-python/src/ephemeral.rs new file mode 100644 index 00000000..5c4e4f15 --- /dev/null +++ b/packages/auths-python/src/ephemeral.rs @@ -0,0 +1,97 @@ +//! Ephemeral in-memory agent identity: a did:keri agent minted with no +//! passphrase KDF and no persistent keychain. Its private seed is returned so a +//! caller signs directly (raw-key `sign_action` / `dsse_sign_statement_with_key`) +//! instead of through a keychain. The identity lives only in the returned values. + +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; + +use auths_verifier::types::CanonicalDid; + +use crate::identity::validate_capabilities; + +/// An ephemeral agent identity (in-memory; not persisted). +#[pyclass(frozen, skip_from_py_object)] +#[derive(Clone)] +pub struct PyEphemeralAgent { + /// The agent's `did:keri:` identifier. + #[pyo3(get)] + pub did: String, + /// The agent's public key, hex-encoded. + #[pyo3(get)] + pub public_key: String, + /// The agent's raw 32-byte private seed, hex-encoded (for direct signing). + #[pyo3(get)] + pub private_key: String, + /// A self-attestation binding the agent's capabilities (the mark of an agent). + #[pyo3(get)] + pub attestation_json: String, +} + +#[pymethods] +impl PyEphemeralAgent { + fn __repr__(&self) -> String { + format!("EphemeralAgent(did='{}')", self.did) + } +} + +/// Mint an ephemeral `did:keri` agent identity in-memory, with no passphrase KDF. +/// +/// Runs a KERI inception in a throwaway repository and returns the agent's +/// `did:keri`, its public key, its raw private seed (hex, for direct signing), +/// and a self-attestation binding the requested capabilities. Nothing is +/// persisted — the throwaway repository is discarded. +/// +/// Args: +/// * `agent_name`: Human-readable agent name. +/// * `capabilities`: Capabilities to bind into the attestation. +/// +/// Usage: +/// ```ignore +/// let agent = create_ephemeral_agent(py, "recurve-ci-agent", vec!["sign".into()])?; +/// ``` +#[pyfunction] +#[pyo3(signature = (agent_name, capabilities))] +pub fn create_ephemeral_agent( + _py: Python<'_>, + agent_name: &str, + capabilities: Vec, +) -> PyResult { + validate_capabilities(&capabilities)?; + + let tmp = tempfile::tempdir() + .map_err(|e| PyRuntimeError::new_err(format!("[AUTHS_IO_ERROR] tempdir: {e}")))?; + let repo = git2::Repository::init(tmp.path()) + .map_err(|e| PyRuntimeError::new_err(format!("[AUTHS_REGISTRY_ERROR] repo init: {e}")))?; + + #[allow(clippy::disallowed_methods)] // presentation boundary: ephemeral inception timestamp + let now = chrono::Utc::now(); + let inception = auths_id::keri::create_keri_identity(&repo, None, now) + .map_err(|e| PyRuntimeError::new_err(format!("[AUTHS_IDENTITY_ERROR] inception: {e}")))?; + + let did = inception.did(); + let signer = auths_crypto::TypedSignerKey::from_pkcs8(inception.current_keypair_pkcs8.as_ref()) + .map_err(|e| PyValueError::new_err(format!("[AUTHS_CRYPTO_ERROR] key parse: {e}")))?; + let seed_hex = hex::encode(signer.seed().as_bytes()); + let pub_hex = hex::encode(&inception.current_public_key); + let curve = signer.curve(); + + let device_did = CanonicalDid::from_public_key_did_key(&inception.current_public_key, curve); + let attestation = serde_json::json!({ + "version": 1, + "rid": "ephemeral", + "issuer": did, + "subject": device_did.to_string(), + "device_public_key": pub_hex, + "timestamp": now.to_rfc3339(), + "capabilities": capabilities, + "note": format!("Ephemeral agent: {agent_name}"), + }); + + Ok(PyEphemeralAgent { + did, + public_key: pub_hex, + private_key: seed_hex, + attestation_json: attestation.to_string(), + }) +} diff --git a/packages/auths-python/src/lib.rs b/packages/auths-python/src/lib.rs index c57708e9..9caa4f98 100644 --- a/packages/auths-python/src/lib.rs +++ b/packages/auths-python/src/lib.rs @@ -14,15 +14,19 @@ pub mod commit_sign; pub mod commit_verify; pub mod device_ext; pub mod diagnostics; +pub mod dsse; +pub mod ephemeral; pub mod identity; pub mod identity_sign; pub mod org; pub mod pairing; +pub mod passphrase; pub mod policy; pub mod presentation; pub mod rotation; pub mod runtime; pub mod sign; +pub mod tlog; pub mod token; pub mod trust; pub mod types; @@ -54,6 +58,10 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(sign::sign_bytes, m)?)?; m.add_function(wrap_pyfunction!(sign::sign_action, m)?)?; m.add_function(wrap_pyfunction!(sign::verify_action_envelope, m)?)?; + m.add_function(wrap_pyfunction!(sign::verify_bytes, m)?)?; + + m.add("PASSPHRASE_MIN_LEN", passphrase::PASSPHRASE_MIN_LEN)?; + m.add_function(wrap_pyfunction!(passphrase::validate_passphrase, m)?)?; m.add_function(wrap_pyfunction!(token::get_token, m)?)?; @@ -94,6 +102,18 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(artifact_sign::sign_artifact_bytes, m)?)?; m.add_function(wrap_pyfunction!(artifact_sign::sign_artifact_bytes_raw, m)?)?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(tlog::log_append, m)?)?; + m.add_function(wrap_pyfunction!(tlog::log_prove, m)?)?; + m.add_function(wrap_pyfunction!(tlog::log_verify_inclusion, m)?)?; + + m.add_function(wrap_pyfunction!(dsse::dsse_sign_statement, m)?)?; + m.add_function(wrap_pyfunction!(dsse::dsse_sign_statement_with_key, m)?)?; + m.add_function(wrap_pyfunction!(dsse::dsse_verify_statement, m)?)?; + + m.add_class::()?; + m.add_function(wrap_pyfunction!(ephemeral::create_ephemeral_agent, m)?)?; + m.add_class::()?; m.add_function(wrap_pyfunction!(commit_sign::sign_commit, m)?)?; diff --git a/packages/auths-python/src/passphrase.rs b/packages/auths-python/src/passphrase.rs new file mode 100644 index 00000000..621189b4 --- /dev/null +++ b/packages/auths-python/src/passphrase.rs @@ -0,0 +1,26 @@ +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +/// The minimum passphrase length the keychain enforces. +pub const PASSPHRASE_MIN_LEN: usize = 12; + +/// Validate a keychain passphrase against the strength policy, up front. +/// +/// The keychain requires at least 12 characters and at least 3 of 4 character +/// classes (lowercase, uppercase, digit, symbol). `create_agent`/`create` apply +/// the same rule at call time; this lets a caller fail fast instead of +/// discovering it by trial. Raises `ValueError` describing the shortfall when the +/// passphrase is too weak. +/// +/// Args: +/// * `passphrase`: The passphrase to check. +/// +/// Usage: +/// ```ignore +/// auths.validate_passphrase("Correct-Horse-Battery-9") +/// ``` +#[pyfunction] +pub fn validate_passphrase(passphrase: &str) -> PyResult<()> { + auths_core::crypto::encryption::validate_passphrase(passphrase) + .map_err(|e| PyValueError::new_err(e.to_string())) +} diff --git a/packages/auths-python/src/sign.rs b/packages/auths-python/src/sign.rs index 678a5fd8..399fb6cd 100644 --- a/packages/auths-python/src/sign.rs +++ b/packages/auths-python/src/sign.rs @@ -210,3 +210,34 @@ pub fn verify_action_envelope( }), } } + +/// Verify a raw signature over a message: `(message, signature_hex, public_key_hex)`. +/// +/// The dual of `sign_bytes` / `sign_as_agent` — it checks the exact bytes a raw +/// signer produced, with no envelope. The curve is read from the public key. +/// Returns True iff the signature is valid. +/// +/// Args: +/// * `message`: The signed bytes. +/// * `signature_hex`: The signature, hex-encoded. +/// * `public_key_hex`: The signer's public key, hex-encoded. +/// +/// Usage: +/// ```ignore +/// assert!(verify_bytes(b"hash", sig_hex, pubkey_hex)?); +/// ``` +#[pyfunction] +pub fn verify_bytes(message: &[u8], signature_hex: &str, public_key_hex: &str) -> PyResult { + let (pk_bytes, curve) = validate_pk_hex(public_key_hex)?; + let sig_bytes = hex::decode(signature_hex) + .map_err(|e| PyValueError::new_err(format!("Invalid signature hex: {e}")))?; + let result = match curve { + CurveType::Ed25519 => { + auths_crypto::RingCryptoProvider::ed25519_verify(&pk_bytes, message, &sig_bytes) + } + CurveType::P256 => { + auths_crypto::RingCryptoProvider::p256_verify(&pk_bytes, message, &sig_bytes) + } + }; + Ok(result.is_ok()) +} diff --git a/packages/auths-python/src/tlog.rs b/packages/auths-python/src/tlog.rs new file mode 100644 index 00000000..09fdbe11 --- /dev/null +++ b/packages/auths-python/src/tlog.rs @@ -0,0 +1,189 @@ +//! Transparency-log bindings: append an artifact digest to a local tile log, +//! emit offline inclusion evidence, and re-verify that evidence against a +//! pinned log key — all thin wrappers over `auths_sdk::workflows::transparency` +//! and the `auths_verifier` offline inclusion check. + +use std::path::PathBuf; + +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; + +use auths_sdk::workflows::transparency::{ + TransparencyWorkflowError, append_artifact_digest, prove_artifact_digest, +}; +use auths_verifier::Ed25519PublicKey; +use auths_verifier::evidence_pack::{TransparencyInclusion, verify_artifact_log_inclusion}; + +use crate::runtime::runtime; + +/// Result of appending an artifact digest to a local transparency log. +#[pyclass(frozen, skip_from_py_object)] +#[derive(Clone)] +pub struct PyLogAppendResult { + /// Canonical `sha256:` digest that was logged. + #[pyo3(get)] + pub artifact_digest: String, + /// Hex-encoded Merkle leaf hash the digest was stored under. + #[pyo3(get)] + pub leaf_hash: String, + /// Zero-based index the leaf was sequenced at. + #[pyo3(get)] + pub index: u64, + /// Tree size of the checkpoint that now includes the leaf. + #[pyo3(get)] + pub size: u64, + /// Hex-encoded Merkle root of that checkpoint. + #[pyo3(get)] + pub root: String, + /// The log's origin line. + #[pyo3(get)] + pub origin: String, + /// Hex-encoded Ed25519 public key the checkpoint is signed with — pin this + /// to verify the evidence `log_prove` mints. + #[pyo3(get)] + pub log_public_key: String, + /// The full signed checkpoint, JSON-serialized. + #[pyo3(get)] + pub checkpoint_json: String, +} + +#[pymethods] +impl PyLogAppendResult { + fn __repr__(&self) -> String { + format!( + "LogAppendResult(index={}, size={}, origin={:?})", + self.index, self.size, self.origin + ) + } +} + +/// Map a transparency workflow error onto a tagged Python exception. +fn map_tlog_err(e: TransparencyWorkflowError) -> PyErr { + match e { + TransparencyWorkflowError::InvalidInput(m) => { + PyValueError::new_err(format!("[AUTHS_INVALID_INPUT] {m}")) + } + TransparencyWorkflowError::LogNotFound(p) => PyRuntimeError::new_err(format!( + "[AUTHS_LOG_NOT_FOUND] no transparency log at {}", + p.display() + )), + other => PyRuntimeError::new_err(format!("[AUTHS_TRANSPARENCY_ERROR] {other}")), + } +} + +/// Append an artifact digest to a local tile-backed transparency log. +/// +/// Creates the log directory and its signing key on first use. The log is +/// append-only, so repeated calls grow the tree and return increasing indices. +/// +/// Args: +/// * `artifact_digest`: The digest to log (`sha256:<64 hex>`). +/// * `log_dir`: Directory holding the tile store and `log.key`. +/// * `origin`: The log's origin line, written into every checkpoint. +/// +/// Usage: +/// ```ignore +/// let r = log_append(py, "sha256:ab…".into(), "/tmp/log".into(), "acme.dev/releases")?; +/// println!("appended at index {}", r.index); +/// ``` +#[pyfunction] +#[pyo3(signature = (artifact_digest, log_dir, origin="auths.local/log"))] +pub fn log_append( + _py: Python<'_>, + artifact_digest: String, + log_dir: PathBuf, + origin: &str, +) -> PyResult { + #[allow(clippy::disallowed_methods)] // FFI is the presentation boundary (checkpoint timestamp) + let now = chrono::Utc::now(); + let appended = runtime() + .block_on(append_artifact_digest( + &log_dir, + origin, + &artifact_digest, + now, + )) + .map_err(map_tlog_err)?; + + let checkpoint = &appended.signed_checkpoint.checkpoint; + let checkpoint_json = serde_json::to_string(&appended.signed_checkpoint) + .map_err(|e| PyRuntimeError::new_err(format!("failed to serialize checkpoint: {e}")))?; + + Ok(PyLogAppendResult { + artifact_digest: appended.artifact_digest, + leaf_hash: hex::encode(appended.leaf_hash.as_bytes()), + index: appended.index, + size: checkpoint.size, + root: hex::encode(checkpoint.root.as_bytes()), + origin: checkpoint.origin.to_string(), + log_public_key: hex::encode(appended.signed_checkpoint.log_public_key.as_bytes()), + checkpoint_json, + }) +} + +/// Emit offline inclusion evidence (JSON) for an already-appended artifact +/// digest. The returned string is a serialized `TransparencyInclusion` that +/// `log_verify_inclusion` (or any auths verifier) can check with zero network. +/// +/// Args: +/// * `artifact_digest`: The digest to prove (`sha256:<64 hex>`). +/// * `log_dir`: Directory holding the tile store and `log.key`. +/// * `origin`: The log's origin line (must match the appended log). +/// +/// Usage: +/// ```ignore +/// let evidence = log_prove(py, "sha256:ab…".into(), "/tmp/log".into(), "acme.dev/releases")?; +/// ``` +#[pyfunction] +#[pyo3(signature = (artifact_digest, log_dir, origin="auths.local/log"))] +pub fn log_prove( + _py: Python<'_>, + artifact_digest: String, + log_dir: PathBuf, + origin: &str, +) -> PyResult { + let inclusion = runtime() + .block_on(prove_artifact_digest(&log_dir, origin, &artifact_digest)) + .map_err(map_tlog_err)?; + serde_json::to_string(&inclusion).map_err(|e| { + PyRuntimeError::new_err(format!("failed to serialize inclusion evidence: {e}")) + }) +} + +/// Verify, fully offline, that inclusion evidence anchors an artifact digest in +/// a log operated by the **pinned** key. +/// +/// Three fail-closed checks: the evidence binds to this artifact (its leaf +/// re-derives from the digest), the Merkle proof verifies against the embedded +/// signed checkpoint, and that checkpoint is signed by the pinned log key. A +/// forged, absent, or mismatched proof raises `ValueError`. +/// +/// Args: +/// * `evidence_json`: The serialized inclusion evidence from `log_prove`. +/// * `artifact_digest`: The canonical `sha256:` digest the leaf must match. +/// * `log_public_key`: Hex-encoded Ed25519 key the checkpoint must be signed by. +/// +/// Usage: +/// ```ignore +/// log_verify_inclusion(py, evidence, "sha256:ab…".into(), key_hex)?; // True or raises +/// ``` +#[pyfunction] +pub fn log_verify_inclusion( + _py: Python<'_>, + evidence_json: String, + artifact_digest: String, + log_public_key: String, +) -> PyResult { + let inclusion: TransparencyInclusion = serde_json::from_str(&evidence_json) + .map_err(|e| PyValueError::new_err(format!("invalid inclusion evidence JSON: {e}")))?; + + let key_bytes: [u8; 32] = hex::decode(&log_public_key) + .map_err(|e| PyValueError::new_err(format!("invalid log public key hex: {e}")))? + .try_into() + .map_err(|_| PyValueError::new_err("log public key must be 32 bytes"))?; + let pinned = Ed25519PublicKey::from_bytes(key_bytes); + + verify_artifact_log_inclusion(&artifact_digest, &inclusion, &pinned) + .map_err(|e| PyValueError::new_err(format!("[AUTHS_INCLUSION_UNVERIFIED] {e}")))?; + Ok(true) +}