Skip to main content

auths_sdk/domains/signing/
service.rs

1//! Signing pipeline orchestration.
2//!
3//! Composed pipeline: validate freeze → sign data → format SSHSIG.
4//! Agent communication and passphrase prompting remain in the CLI.
5//!
6//! DSSE PAE (Pre-Authentication Encoding) is computed for transparency log
7//! submissions where the signing key is available (ephemeral signing).
8
9use crate::context::AuthsContext;
10use crate::ports::artifact::{ArtifactDigest, ArtifactMetadata, ArtifactSource};
11use auths_core::crypto::signer as core_signer;
12use auths_core::crypto::ssh::{self, SecureSeed};
13use auths_core::signing::{PassphraseProvider, SecureSigner};
14use auths_core::storage::keychain::{IdentityDID, KeyAlias, KeyStorage};
15use auths_id::attestation::core::resign_attestation;
16use auths_id::attestation::create::create_signed_attestation;
17use auths_id::storage::git_refs::AttestationMetadata;
18use auths_verifier::core::{OidcBinding, ResourceId, SignerType};
19use auths_verifier::types::CanonicalDid;
20use chrono::{DateTime, Utc};
21use sha2::{Digest, Sha256};
22use std::collections::HashMap;
23use std::path::Path;
24use std::sync::Arc;
25use zeroize::Zeroizing;
26
27/// Errors from the signing pipeline.
28#[derive(Debug, thiserror::Error)]
29#[non_exhaustive]
30pub enum SigningError {
31    /// The identity is in a freeze state and signing is not permitted.
32    #[error("identity is frozen: {0}")]
33    IdentityFrozen(String),
34    /// The requested key alias could not be resolved from the keychain.
35    #[error("key resolution failed: {0}")]
36    KeyResolution(String),
37    /// The cryptographic signing operation failed.
38    #[error("signing operation failed: {0}")]
39    SigningFailed(String),
40    /// The supplied passphrase was incorrect.
41    #[error("invalid passphrase")]
42    InvalidPassphrase,
43    /// SSHSIG PEM encoding failed after signing.
44    #[error("PEM encoding failed: {0}")]
45    PemEncoding(String),
46    /// The agent is not available (platform unsupported, not installed, or not reachable).
47    #[error("agent unavailable: {0}")]
48    AgentUnavailable(String),
49    /// The agent accepted the signing request but it failed.
50    #[error("agent signing failed")]
51    AgentSigningFailed(#[source] crate::ports::agent::AgentSigningError),
52    /// All passphrase attempts were exhausted without a successful decryption.
53    #[error("passphrase exhausted after {attempts} attempt(s)")]
54    PassphraseExhausted {
55        /// Number of failed attempts before giving up.
56        attempts: usize,
57    },
58    /// The platform keychain could not be accessed.
59    #[error("keychain unavailable: {0}")]
60    KeychainUnavailable(String),
61    /// The encrypted key material could not be decrypted.
62    #[error("key decryption failed: {0}")]
63    KeyDecryptionFailed(String),
64}
65
66impl auths_core::error::AuthsErrorInfo for SigningError {
67    fn error_code(&self) -> &'static str {
68        match self {
69            Self::IdentityFrozen(_) => "AUTHS-E5901",
70            Self::KeyResolution(_) => "AUTHS-E5902",
71            Self::SigningFailed(_) => "AUTHS-E5903",
72            Self::InvalidPassphrase => "AUTHS-E5904",
73            Self::PemEncoding(_) => "AUTHS-E5905",
74            Self::AgentUnavailable(_) => "AUTHS-E5906",
75            Self::AgentSigningFailed(_) => "AUTHS-E5907",
76            Self::PassphraseExhausted { .. } => "AUTHS-E5908",
77            Self::KeychainUnavailable(_) => "AUTHS-E5909",
78            Self::KeyDecryptionFailed(_) => "AUTHS-E5910",
79        }
80    }
81
82    fn suggestion(&self) -> Option<&'static str> {
83        match self {
84            Self::IdentityFrozen(_) => Some("To unfreeze: auths emergency unfreeze"),
85            Self::KeyResolution(_) => Some("Run `auths key list` to check available keys"),
86            Self::SigningFailed(_) => Some(
87                "The signing operation failed; verify your key is accessible with `auths key list`",
88            ),
89            Self::InvalidPassphrase => Some("Check your passphrase and try again"),
90            Self::PemEncoding(_) => {
91                Some("Failed to encode the key in PEM format; the key material may be corrupted")
92            }
93            Self::AgentUnavailable(_) => Some("Start the agent with `auths agent start`"),
94            Self::AgentSigningFailed(_) => Some("Check agent logs with `auths agent status`"),
95            Self::PassphraseExhausted { .. } => Some(
96                "The passphrase you entered is incorrect (tried 3 times). Verify it matches what you set during init, or try: auths key export --key-alias <alias> --format pub",
97            ),
98            Self::KeychainUnavailable(_) => Some("Run `auths doctor` to diagnose keychain issues"),
99            Self::KeyDecryptionFailed(_) => Some("Check your passphrase and try again"),
100        }
101    }
102}
103
104/// Configuration for a signing operation.
105///
106/// Args:
107/// * `namespace`: The SSHSIG namespace (typically "git").
108///
109/// Usage:
110/// ```ignore
111/// let config = SigningConfig {
112///     namespace: "git".to_string(),
113/// };
114/// ```
115pub struct SigningConfig {
116    /// SSHSIG namespace string (e.g. `"git"` for commit signing).
117    pub namespace: String,
118}
119
120/// Validate that the identity is not frozen.
121///
122/// Args:
123/// * `repo_path`: Path to the auths repository (typically `~/.auths`).
124/// * `now`: The reference time used to check if the freeze is active.
125///
126/// Usage:
127/// ```ignore
128/// validate_freeze_state(&repo_path, clock.now())?;
129/// ```
130pub fn validate_freeze_state(
131    repo_path: &Path,
132    now: chrono::DateTime<chrono::Utc>,
133) -> Result<(), SigningError> {
134    use auths_id::freeze::load_active_freeze;
135
136    if let Some(state) = load_active_freeze(repo_path, now)
137        .map_err(|e| SigningError::IdentityFrozen(e.to_string()))?
138    {
139        return Err(SigningError::IdentityFrozen(format!(
140            "frozen until {}. Remaining: {}. To unfreeze: auths emergency unfreeze",
141            state.frozen_until.format("%Y-%m-%d %H:%M UTC"),
142            state.expires_description(now),
143        )));
144    }
145
146    Ok(())
147}
148
149/// Construct the SSHSIG signed-data payload for the given data and namespace.
150///
151/// Args:
152/// * `data`: The raw bytes to sign.
153/// * `namespace`: The SSHSIG namespace (e.g. "git").
154///
155/// Usage:
156/// ```ignore
157/// let payload = construct_signature_payload(b"data", "git")?;
158/// ```
159pub fn construct_signature_payload(data: &[u8], namespace: &str) -> Result<Vec<u8>, SigningError> {
160    ssh::construct_sshsig_signed_data(data, namespace)
161        .map_err(|e| SigningError::SigningFailed(e.to_string()))
162}
163
164/// Create a complete SSHSIG PEM signature from a seed and data.
165///
166/// Args:
167/// * `seed`: The Ed25519 signing seed.
168/// * `data`: The raw bytes to sign.
169/// * `namespace`: The SSHSIG namespace.
170///
171/// Usage:
172/// ```ignore
173/// let pem = sign_with_seed(&seed, b"data to sign", "git")?;
174/// ```
175pub fn sign_with_seed(
176    seed: &SecureSeed,
177    data: &[u8],
178    namespace: &str,
179    curve: auths_crypto::CurveType,
180) -> Result<String, SigningError> {
181    ssh::create_sshsig(seed, data, namespace, curve)
182        .map_err(|e| SigningError::PemEncoding(e.to_string()))
183}
184
185// ---------------------------------------------------------------------------
186// Artifact attestation signing
187// ---------------------------------------------------------------------------
188
189/// Selects how a signing key is supplied to `sign_artifact`.
190///
191/// `Alias` resolves the key from the platform keychain at call time.
192/// `Direct` injects a raw seed, bypassing the keychain — intended for headless
193/// CI/CD runners that have no platform keychain available.
194pub enum SigningKeyMaterial {
195    /// Resolve by alias from the platform keychain.
196    Alias(KeyAlias),
197    /// Inject a raw Ed25519 seed directly. The passphrase provider is not called.
198    Direct(SecureSeed),
199}
200
201/// Parameters for the artifact attestation signing workflow.
202///
203/// Usage:
204/// ```ignore
205/// let params = ArtifactSigningParams {
206///     artifact: Arc::new(my_artifact),
207///     identity_key: Some(SigningKeyMaterial::Alias("my-identity".into())),
208///     device_key: SigningKeyMaterial::Direct(my_seed),
209///     expires_in: Some(31_536_000),
210///     note: None,
211///     commit_sha: None,
212/// };
213/// ```
214pub struct ArtifactSigningParams {
215    /// The artifact to attest. Provides the canonical digest and metadata.
216    pub artifact: Arc<dyn ArtifactSource>,
217    /// Identity key source. `None` skips the identity signature.
218    pub identity_key: Option<SigningKeyMaterial>,
219    /// Device key source. Required to produce a dual-signed attestation.
220    pub device_key: SigningKeyMaterial,
221    /// Duration in seconds until expiration (per RFC 6749).
222    pub expires_in: Option<u64>,
223    /// Optional human-readable annotation embedded in the attestation.
224    pub note: Option<String>,
225    /// Git commit SHA for provenance binding (included in signed canonical data).
226    pub commit_sha: Option<String>,
227}
228
229/// Result of a successful artifact attestation signing operation.
230///
231/// Usage:
232/// ```ignore
233/// let result = sign_artifact(params, &ctx)?;
234/// std::fs::write(&output_path, &result.attestation_json)?;
235/// println!("Signed {} (sha256:{})", result.rid, result.digest);
236/// ```
237#[derive(Debug)]
238pub struct ArtifactSigningResult {
239    /// Canonical JSON of the signed attestation.
240    pub attestation_json: String,
241    /// Resource identifier assigned to the attestation in the identity store.
242    pub rid: ResourceId,
243    /// Hex-encoded SHA-256 digest of the attested artifact.
244    pub digest: String,
245    /// DSSE signature over the PAE of the attestation JSON (for transparency log submission).
246    /// Present when the signing function has access to the key (e.g., ephemeral signing).
247    pub dsse_signature: Option<Vec<u8>>,
248}
249
250/// Errors from the artifact attestation signing workflow.
251#[derive(Debug, thiserror::Error)]
252#[non_exhaustive]
253pub enum ArtifactSigningError {
254    /// No auths identity was found in the configured identity storage.
255    #[error("identity not found in configured identity storage")]
256    IdentityNotFound,
257
258    /// The key alias could not be resolved to usable key material.
259    #[error("key resolution failed: {0}")]
260    KeyResolutionFailed(String),
261
262    /// The encrypted key material could not be decrypted (e.g. wrong passphrase).
263    #[error("key decryption failed: {0}")]
264    KeyDecryptionFailed(String),
265
266    /// Computing the artifact digest failed.
267    #[error("digest computation failed: {0}")]
268    DigestFailed(String),
269
270    /// Building or serializing the attestation failed.
271    #[error("attestation creation failed: {0}")]
272    AttestationFailed(String),
273
274    /// Adding the device signature to a partially-signed attestation failed.
275    #[error("attestation re-signing failed: {0}")]
276    ResignFailed(String),
277
278    /// The provided commit SHA has an invalid format.
279    #[error("invalid commit SHA: {0} (expected 40 or 64 hex characters)")]
280    InvalidCommitSha(String),
281
282    /// The signing device's delegated identity has been revoked by the root.
283    #[error("device revoked: {0}")]
284    DeviceRevoked(String),
285
286    /// The signing key is no longer the current key in the identity's KEL (rotated out).
287    #[error("signing key rotated out of the KEL: {0}")]
288    KeyRotatedOut(String),
289}
290
291impl auths_core::error::AuthsErrorInfo for ArtifactSigningError {
292    fn error_code(&self) -> &'static str {
293        match self {
294            Self::IdentityNotFound => "AUTHS-E5850",
295            Self::KeyResolutionFailed(_) => "AUTHS-E5851",
296            Self::KeyDecryptionFailed(_) => "AUTHS-E5852",
297            Self::DigestFailed(_) => "AUTHS-E5853",
298            Self::AttestationFailed(_) => "AUTHS-E5854",
299            Self::ResignFailed(_) => "AUTHS-E5855",
300            Self::InvalidCommitSha(_) => "AUTHS-E5856",
301            Self::DeviceRevoked(_) => "AUTHS-E5857",
302            Self::KeyRotatedOut(_) => "AUTHS-E5858",
303        }
304    }
305
306    fn suggestion(&self) -> Option<&'static str> {
307        match self {
308            Self::IdentityNotFound => {
309                Some("Run `auths init` to create an identity, or `auths key import` to restore one")
310            }
311            Self::KeyResolutionFailed(_) => {
312                Some("Run `auths status` to see available device aliases")
313            }
314            Self::KeyDecryptionFailed(_) => Some("Check your passphrase and try again"),
315            Self::DigestFailed(_) => Some("Verify the file exists and is readable"),
316            Self::AttestationFailed(_) => Some("Check identity storage with `auths status`"),
317            Self::ResignFailed(_) => {
318                Some("Verify your device key is accessible with `auths status`")
319            }
320            Self::InvalidCommitSha(_) => {
321                Some("Provide a full SHA-1 (40 hex chars) or SHA-256 (64 hex chars) commit hash")
322            }
323            Self::DeviceRevoked(_) => {
324                Some("This device has been removed; pair a new device or sign from an active one")
325            }
326            Self::KeyRotatedOut(_) => {
327                Some("This key was rotated out; sign with the identity's current key")
328            }
329        }
330    }
331}
332
333/// A `SecureSigner` backed by pre-resolved in-memory seeds.
334///
335/// Seeds are keyed by alias and carry their curve so sign dispatches correctly
336/// for Ed25519 + P-256 identities. The passphrase provider is never called
337/// because all key material was resolved before construction.
338struct SeedMapSigner {
339    seeds: HashMap<String, (SecureSeed, auths_crypto::CurveType)>,
340}
341
342impl SecureSigner for SeedMapSigner {
343    fn sign_with_alias(
344        &self,
345        alias: &auths_core::storage::keychain::KeyAlias,
346        _passphrase_provider: &dyn PassphraseProvider,
347        message: &[u8],
348    ) -> Result<Vec<u8>, auths_core::AgentError> {
349        let (seed, curve) = self
350            .seeds
351            .get(alias.as_str())
352            .ok_or(auths_core::AgentError::KeyNotFound)?;
353        let typed = match curve {
354            auths_crypto::CurveType::Ed25519 => auths_crypto::TypedSeed::Ed25519(*seed.as_bytes()),
355            auths_crypto::CurveType::P256 => auths_crypto::TypedSeed::P256(*seed.as_bytes()),
356        };
357        auths_crypto::typed_sign(&typed, message)
358            .map_err(|e| auths_core::AgentError::CryptoError(e.to_string()))
359    }
360
361    fn sign_for_identity(
362        &self,
363        _identity_did: &IdentityDID,
364        _passphrase_provider: &dyn PassphraseProvider,
365        _message: &[u8],
366    ) -> Result<Vec<u8>, auths_core::AgentError> {
367        Err(auths_core::AgentError::KeyNotFound)
368    }
369}
370
371struct ResolvedKey {
372    alias: KeyAlias,
373    /// None for hardware backends (SE) — signing goes through the keychain directly.
374    seed: Option<SecureSeed>,
375    public_key_bytes: Vec<u8>,
376    curve: auths_crypto::CurveType,
377    /// True if this key is backed by hardware (Secure Enclave, HSM).
378    is_hardware: bool,
379    /// The identity (KERI AID) the stored key belongs to, when known. Present for a
380    /// software key loaded by alias; `None` for direct seeds and hardware backends,
381    /// which carry no resolvable registry identity here.
382    identity_did: Option<IdentityDID>,
383}
384
385fn resolve_optional_key(
386    material: Option<&SigningKeyMaterial>,
387    synthetic_alias: &'static str,
388    keychain: &(dyn KeyStorage + Send + Sync),
389    passphrase_provider: &dyn PassphraseProvider,
390    passphrase_prompt: &str,
391) -> Result<Option<ResolvedKey>, ArtifactSigningError> {
392    match material {
393        None => Ok(None),
394        Some(SigningKeyMaterial::Alias(alias)) => {
395            // Hardware backends (Secure Enclave): resolve pubkey only; signing happens
396            // later via StorageSigner so private key material never leaves the enclave.
397            // MIRROR: keep in sync with workflows/signing.rs (SE branch)
398            if keychain.is_hardware_backend() {
399                let (pubkey, curve) = auths_core::storage::keychain::extract_public_key_bytes(
400                    keychain,
401                    alias,
402                    passphrase_provider,
403                )
404                .map_err(|e| ArtifactSigningError::KeyResolutionFailed(e.to_string()))?;
405                return Ok(Some(ResolvedKey {
406                    alias: alias.clone(),
407                    seed: None,
408                    public_key_bytes: pubkey,
409                    curve,
410                    is_hardware: true,
411                    identity_did: None,
412                }));
413            }
414
415            let (device_did, _role, encrypted) = keychain
416                .load_key(alias)
417                .map_err(|e| ArtifactSigningError::KeyResolutionFailed(e.to_string()))?;
418            let passphrase = passphrase_provider
419                .get_passphrase(passphrase_prompt)
420                .map_err(|e| ArtifactSigningError::KeyDecryptionFailed(e.to_string()))?;
421            // Defense-in-depth: SE keys must never reach the software decrypt path.
422            debug_assert!(
423                !keychain.is_hardware_backend(),
424                "SE keys must never reach decrypt_keypair"
425            );
426            let pkcs8 = core_signer::decrypt_keypair(&encrypted, &passphrase)
427                .map_err(|e| ArtifactSigningError::KeyDecryptionFailed(e.to_string()))?;
428            let (seed, pubkey, curve) = core_signer::load_seed_and_pubkey(&pkcs8)
429                .map_err(|e| ArtifactSigningError::KeyDecryptionFailed(e.to_string()))?;
430            Ok(Some(ResolvedKey {
431                alias: alias.clone(),
432                seed: Some(seed),
433                public_key_bytes: pubkey.to_vec(),
434                curve,
435                is_hardware: false,
436                identity_did: Some(device_did),
437            }))
438        }
439        Some(SigningKeyMaterial::Direct(seed)) => {
440            let typed = auths_crypto::TypedSeed::Ed25519(*seed.as_bytes());
441            let pubkey = auths_crypto::typed_public_key(&typed)
442                .map_err(|e| ArtifactSigningError::KeyDecryptionFailed(e.to_string()))?;
443            Ok(Some(ResolvedKey {
444                alias: KeyAlias::new_unchecked(synthetic_alias),
445                seed: Some(SecureSeed::new(*seed.as_bytes())),
446                public_key_bytes: pubkey,
447                curve: auths_crypto::CurveType::Ed25519,
448                is_hardware: false,
449                identity_did: None,
450            }))
451        }
452    }
453}
454
455fn resolve_required_key(
456    material: &SigningKeyMaterial,
457    synthetic_alias: &'static str,
458    keychain: &(dyn KeyStorage + Send + Sync),
459    passphrase_provider: &dyn PassphraseProvider,
460    passphrase_prompt: &str,
461) -> Result<ResolvedKey, ArtifactSigningError> {
462    resolve_optional_key(
463        Some(material),
464        synthetic_alias,
465        keychain,
466        passphrase_provider,
467        passphrase_prompt,
468    )
469    .map(|opt| {
470        opt.ok_or(ArtifactSigningError::KeyDecryptionFailed(
471            "expected key material but got None".into(),
472        ))
473    })?
474}
475
476/// Resolve the root identity's own signing key — the issuer of an artifact attestation when
477/// no explicit `--key` is given. Picks the first non-rotation (`--next-`) alias stored under
478/// the root's AID and loads it. A delegated device's key is deliberately NOT used here: the
479/// device signs the device slot, the root signs the issuer slot.
480///
481/// Args:
482/// * `controller_did`: the root identity whose signing key issues the attestation.
483/// * `keychain`: key storage holding the root's key under its AID.
484/// * `passphrase_provider`: passphrase source for decrypting the key.
485///
486/// Usage:
487/// ```ignore
488/// let issuer = resolve_root_issuer_key(&managed.controller_did, keychain, provider)?;
489/// ```
490fn resolve_root_issuer_key(
491    controller_did: &IdentityDID,
492    keychain: &(dyn KeyStorage + Send + Sync),
493    passphrase_provider: &dyn PassphraseProvider,
494) -> Result<ResolvedKey, ArtifactSigningError> {
495    let alias = keychain
496        .list_aliases_for_identity(controller_did)
497        .map_err(|e| ArtifactSigningError::KeyResolutionFailed(e.to_string()))?
498        .into_iter()
499        .find(|a| !a.as_str().contains("--next-"))
500        .ok_or_else(|| {
501            ArtifactSigningError::KeyResolutionFailed(format!(
502                "no signing key found for root identity {}",
503                controller_did.as_str()
504            ))
505        })?;
506    resolve_required_key(
507        &SigningKeyMaterial::Alias(alias),
508        "__artifact_issuer__",
509        keychain,
510        passphrase_provider,
511        "Enter passphrase for identity key:",
512    )
513}
514
515/// Refuses to sign with a device whose delegated identity has been revoked by the
516/// root, or whose key has been rotated out of its identity's KEL.
517///
518/// The check is registry-backed: it reads the root KEL for a revocation marker on the
519/// device, and the device KEL for its current key. It applies only to keys resolved by
520/// alias (which carry a stored KERI AID); direct seeds and hardware keys carry no AID
521/// here and are not checked.
522///
523/// Args:
524/// * `ctx`: The signing context, providing the registry.
525/// * `controller_did`: The root identity that may have revoked the device.
526/// * `device_did`: The signing key's stored KERI identity (AID).
527/// * `device_pk_bytes`: The raw public key bytes being signed with.
528///
529/// Usage:
530/// ```ignore
531/// enforce_signer_authority(ctx, &managed.controller_did, device_did, &pubkey)?;
532/// ```
533fn enforce_signer_authority(
534    ctx: &AuthsContext,
535    controller_did: &IdentityDID,
536    device_did: &IdentityDID,
537    device_pk_bytes: &[u8],
538) -> Result<(), ArtifactSigningError> {
539    use std::ops::ControlFlow;
540
541    let device_prefix = auths_id::keri::parse_did_keri(device_did.as_str())
542        .map_err(|e| ArtifactSigningError::KeyResolutionFailed(e.to_string()))?;
543    let root_prefix = auths_id::keri::parse_did_keri(controller_did.as_str())
544        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
545
546    // Revoked? The root anchors a Seal::Digest of the device prefix on its own KEL.
547    // Fail closed: if the root KEL cannot be read, we cannot prove the device is live.
548    let mut revoked = false;
549    ctx.registry
550        .visit_events(&root_prefix, 0, &mut |event| {
551            let hit = event.anchors().iter().any(|seal| {
552                matches!(seal, auths_id::keri::Seal::Digest { d } if d.as_str() == device_prefix.as_str())
553            });
554            if hit {
555                revoked = true;
556                ControlFlow::Break(())
557            } else {
558                ControlFlow::Continue(())
559            }
560        })
561        .map_err(|e| {
562            ArtifactSigningError::KeyResolutionFailed(format!(
563                "cannot read root KEL to check revocation: {e}"
564            ))
565        })?;
566    if revoked {
567        return Err(ArtifactSigningError::DeviceRevoked(
568            device_did.as_str().to_string(),
569        ));
570    }
571
572    // Rotated out? The signing key must be the current key in the device's KEL.
573    // Fail closed: if the KEL state cannot be read, we cannot prove the key is current.
574    let state = ctx.registry.get_key_state(&device_prefix).map_err(|e| {
575        ArtifactSigningError::KeyResolutionFailed(format!(
576            "cannot read KEL state for {} to verify the signing key is current: {e}",
577            device_did.as_str()
578        ))
579    })?;
580    let is_current = state.current_keys.iter().any(|k| match k.parse() {
581        // Public keys, decoded from CESR (curve-tag-aware) to raw bytes; a plain
582        // comparison is correct — no secret is involved.
583        Ok(pk) => {
584            let key_bytes: &[u8] = pk.as_bytes();
585            key_bytes == device_pk_bytes
586        }
587        Err(_) => false,
588    });
589    if !is_current {
590        return Err(ArtifactSigningError::KeyRotatedOut(
591            device_did.as_str().to_string(),
592        ));
593    }
594    Ok(())
595}
596
597/// Validate and normalize a commit SHA (40-char SHA-1 or 64-char SHA-256).
598///
599/// Args:
600/// * `sha`: The raw commit SHA string to validate.
601///
602/// Usage:
603/// ```ignore
604/// let normalized = validate_commit_sha("AbCd1234...")?;
605/// ```
606pub fn validate_commit_sha(sha: &str) -> Result<String, ArtifactSigningError> {
607    let normalized = sha.to_ascii_lowercase();
608    let len = normalized.len();
609    if (len != 40 && len != 64) || !normalized.bytes().all(|b| b.is_ascii_hexdigit()) {
610        return Err(ArtifactSigningError::InvalidCommitSha(sha.to_string()));
611    }
612    Ok(normalized)
613}
614
615/// Full artifact attestation signing pipeline.
616///
617/// Loads the identity, resolves key material (supporting both keychain aliases
618/// and direct in-memory seed injection), computes the artifact digest, and
619/// produces a dual-signed attestation JSON.
620///
621/// Args:
622/// * `params`: All inputs required for signing, including key material and artifact source.
623/// * `ctx`: Runtime context providing identity storage, key storage, passphrase provider, and clock.
624///
625/// Usage:
626/// ```ignore
627/// let params = ArtifactSigningParams {
628///     artifact: Arc::new(FileArtifact::new(Path::new("release.tar.gz"))),
629///     identity_key: Some(SigningKeyMaterial::Alias("my-key".into())),
630///     device_key: SigningKeyMaterial::Direct(seed),
631///     expires_in: Some(31_536_000),
632///     note: None,
633///     commit_sha: None,
634/// };
635/// let result = sign_artifact(params, &ctx)?;
636/// ```
637// A straight-line dual-signature pipeline: resolve the issuer (root) + device keys,
638// build and sign the attestation, then anchor it on the root KEL. The steps are
639// sequential with shared locals; splitting the flow would scatter it across helpers
640// without making any one part clearer.
641#[allow(clippy::too_many_lines)]
642pub fn sign_artifact(
643    params: ArtifactSigningParams,
644    ctx: &AuthsContext,
645) -> Result<ArtifactSigningResult, ArtifactSigningError> {
646    let managed = ctx
647        .identity_storage
648        .load_identity()
649        .map_err(|_| ArtifactSigningError::IdentityNotFound)?;
650
651    let keychain = ctx.key_storage.as_ref();
652    let passphrase_provider = ctx.passphrase_provider.as_ref();
653
654    // The attestation is issued by the ROOT identity (the dual-signature model: issuer +
655    // device). Resolve the issuer's key — an explicit `--key`, otherwise the root's own
656    // signing key, NOT the delegated device (device #0 signs only the device slot). A
657    // delegated device is not the root's key, so it cannot produce a valid issuer signature
658    // or a root-KEL anchor.
659    let issuer_did = CanonicalDid::from(managed.controller_did.clone());
660    let identity_resolved = match params.identity_key.as_ref() {
661        Some(explicit) => resolve_optional_key(
662            Some(explicit),
663            "__artifact_identity__",
664            keychain,
665            passphrase_provider,
666            "Enter passphrase for identity key:",
667        )?,
668        None => Some(resolve_root_issuer_key(
669            &managed.controller_did,
670            keychain,
671            passphrase_provider,
672        )?),
673    };
674
675    let device_resolved = resolve_required_key(
676        &params.device_key,
677        "__artifact_device__",
678        keychain,
679        passphrase_provider,
680        "Enter passphrase for device key:",
681    )?;
682
683    if let Some(ref device_did) = device_resolved.identity_did {
684        enforce_signer_authority(
685            ctx,
686            &managed.controller_did,
687            device_did,
688            &device_resolved.public_key_bytes,
689        )?;
690    }
691
692    let mut seeds: HashMap<String, (SecureSeed, auths_crypto::CurveType)> = HashMap::new();
693    let identity_alias: Option<KeyAlias> = identity_resolved.map(|r| {
694        let alias = r.alias.clone();
695        let curve = r.curve;
696        if let Some(seed) = r.seed {
697            seeds.insert(r.alias.into_inner(), (seed, curve));
698        }
699        alias
700    });
701    let device_alias = device_resolved.alias.clone();
702    let device_is_hardware = device_resolved.is_hardware;
703    let device_curve = device_resolved.curve;
704    if let Some(seed) = device_resolved.seed {
705        seeds.insert(device_resolved.alias.into_inner(), (seed, device_curve));
706    }
707    let device_pk_bytes = device_resolved.public_key_bytes;
708
709    // Prefer the device key's stored delegated `did:keri` AID (device #0's own identifier)
710    // over a raw `did:key` derived from the pubkey, so the device is reported by ONE
711    // canonical `did:keri` everywhere (attestation subject == whoami's device_did). Falls
712    // back to `did:key` for keys with no stored KERI identity (ephemeral/raw signers).
713    let device_did = device_resolved
714        .identity_did
715        .clone()
716        .map(CanonicalDid::from)
717        .unwrap_or_else(|| {
718            CanonicalDid::from_public_key_did_key(&device_pk_bytes, device_resolved.curve)
719        });
720
721    let artifact_meta = params
722        .artifact
723        .metadata()
724        .map_err(|e| ArtifactSigningError::DigestFailed(e.to_string()))?;
725
726    let rid = ResourceId::new(format!("sha256:{}", artifact_meta.digest.hex));
727    let now = ctx.clock.now();
728    let meta = AttestationMetadata {
729        timestamp: Some(now),
730        expires_at: params
731            .expires_in
732            .map(|s| now + chrono::Duration::seconds(s as i64)),
733        note: params.note,
734    };
735
736    let payload = serde_json::to_value(&artifact_meta)
737        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
738
739    let validated_commit_sha = params
740        .commit_sha
741        .map(|sha| validate_commit_sha(&sha))
742        .transpose()?;
743
744    let attestation_json = create_and_sign_attestation(
745        ctx,
746        seeds,
747        device_is_hardware,
748        now,
749        &rid,
750        // Dual-signature: the root identity is the issuer (its key co-signs) and the
751        // delegated device #0 is the subject/device signer. The issuer is the verifiable
752        // trust anchor a bundle/pinned-root verifier resolves.
753        &issuer_did,
754        &device_did,
755        &device_pk_bytes,
756        device_curve,
757        payload,
758        &meta,
759        identity_alias.as_ref(),
760        &device_alias,
761        validated_commit_sha,
762    )?;
763
764    // Anchor the attestation digest on the root KEL under the issuer's (root's) key. The
765    // issuer key controls the root KEL, so the anchoring ixn is a valid root event that
766    // stateless (identity-bundle) verification accepts.
767    if let Some(ref alias) = identity_alias {
768        anchor_artifact_attestation(ctx, &managed.controller_did, alias, &attestation_json, now)?;
769    }
770
771    Ok(ArtifactSigningResult {
772        attestation_json,
773        rid,
774        digest: artifact_meta.digest.hex,
775        dsse_signature: None,
776    })
777}
778
779fn anchor_artifact_attestation(
780    ctx: &AuthsContext,
781    controller_did: &auths_core::storage::keychain::IdentityDID,
782    alias: &KeyAlias,
783    attestation_json: &str,
784    now: DateTime<Utc>,
785) -> Result<(), ArtifactSigningError> {
786    let prefix = auths_id::keri::parse_did_keri(controller_did.as_str())
787        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
788    let att: auths_verifier::core::Attestation = serde_json::from_str(attestation_json)
789        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
790    let storage_signer = auths_core::signing::StorageSigner::new(Arc::clone(&ctx.key_storage));
791    let mut batch = auths_id::storage::registry::backend::AtomicWriteBatch::new();
792    batch.stage_attestation(att.clone());
793    auths_id::keri::anchor_and_persist_via_backend(
794        ctx.registry.as_ref(),
795        &storage_signer,
796        alias,
797        ctx.passphrase_provider.as_ref(),
798        &prefix,
799        &att,
800        &mut batch,
801        &ctx.witness_params(),
802        now,
803    )
804    .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
805    Ok(())
806}
807
808/// Create, sign, and serialize an attestation. Handles both hardware and software signers.
809#[allow(clippy::too_many_arguments)]
810fn create_and_sign_attestation(
811    ctx: &AuthsContext,
812    seeds: HashMap<String, (SecureSeed, auths_crypto::CurveType)>,
813    device_is_hardware: bool,
814    now: DateTime<Utc>,
815    rid: &ResourceId,
816    issuer: &CanonicalDid,
817    subject: &CanonicalDid,
818    device_pk_bytes: &[u8],
819    device_curve: auths_crypto::CurveType,
820    payload: serde_json::Value,
821    meta: &AttestationMetadata,
822    identity_alias: Option<&KeyAlias>,
823    device_alias: &KeyAlias,
824    commit_sha: Option<String>,
825) -> Result<String, ArtifactSigningError> {
826    let seed_signer = SeedMapSigner { seeds };
827    let storage_signer = auths_core::signing::StorageSigner::new(Arc::clone(&ctx.key_storage));
828    let signer: &dyn SecureSigner = if device_is_hardware {
829        &storage_signer
830    } else {
831        &seed_signer
832    };
833    let noop_provider = auths_core::PrefilledPassphraseProvider::new("");
834
835    let issuer_canonical = issuer.clone();
836    let mut attestation = create_signed_attestation(
837        now,
838        auths_id::attestation::create::AttestationInput {
839            rid: rid.as_str(),
840            issuer: &issuer_canonical,
841            subject,
842            device_public_key: device_pk_bytes,
843            device_curve,
844            payload: Some(payload),
845            meta,
846            identity_alias,
847            device_alias: Some(device_alias),
848            delegated_by: None,
849            commit_sha,
850            signer_type: None,
851            oidc_binding: None,
852        },
853        signer,
854        &noop_provider,
855    )
856    .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
857
858    resign_attestation(
859        &mut attestation,
860        signer,
861        &noop_provider,
862        identity_alias,
863        device_alias,
864    )
865    .map_err(|e| ArtifactSigningError::ResignFailed(e.to_string()))?;
866
867    serde_json::to_string_pretty(&attestation)
868        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))
869}
870
871/// Inputs for [`sign_artifact_ephemeral`] — the artifact bytes plus the
872/// provenance the one-time key binds them to. Named fields (the
873/// `AttestationInput` pattern) so call sites stay readable as the shape
874/// evolves.
875pub struct EphemeralSignRequest<'a> {
876    /// Raw artifact bytes to sign.
877    pub data: &'a [u8],
878    /// Optional human-readable name for the artifact.
879    pub artifact_name: Option<String>,
880    /// Git commit SHA this artifact was built from (required, 40 or 64 hex chars).
881    pub commit_sha: String,
882    /// Curve the one-time ephemeral key is minted on. A parsed
883    /// [`CurveType`](auths_crypto::CurveType) (the boundary validated it), so
884    /// the signer never branches on a string. Defaults to P-256 via
885    /// `CurveType::default()`.
886    pub curve: auths_crypto::CurveType,
887    /// Optional TTL in seconds.
888    pub expires_in: Option<u64>,
889    /// Optional attestation note.
890    pub note: Option<String>,
891    /// Optional CI environment metadata (serialized into payload, covered by signature).
892    pub ci_env: Option<serde_json::Value>,
893    /// Verified OIDC workload binding, if the runner presented a token
894    /// (signed envelope field, so verify-time policy joins can trust it).
895    pub oidc_binding: Option<OidcBinding>,
896}
897
898/// Signs artifact bytes with a one-time ephemeral key on the requested curve.
899/// No keychain, no identity storage, no passphrase — the key is generated,
900/// used, and zeroized within this function call.
901///
902/// The ephemeral key signs "this artifact was built from this commit." Trust
903/// derives transitively: consumers verify the commit is signed by a maintainer,
904/// then verify this attestation's ephemeral signature covers the artifact hash
905/// and commit SHA.
906///
907/// Args:
908/// * `now` - Current UTC time (injected per clock pattern).
909/// * `req` - The artifact bytes plus provenance to bind (see
910///   [`EphemeralSignRequest`]).
911///
912/// Usage:
913/// ```ignore
914/// let result = sign_artifact_ephemeral(Utc::now(), EphemeralSignRequest {
915///     data: b"artifact bytes",
916///     artifact_name: Some("release.tar.gz".into()),
917///     commit_sha: "abc123def456abc123def456abc123def456abc1".into(),
918///     curve: auths_crypto::CurveType::default(),
919///     expires_in: None,
920///     note: None,
921///     ci_env: None,
922///     oidc_binding: None,
923/// })?;
924/// ```
925pub fn sign_artifact_ephemeral(
926    now: DateTime<Utc>,
927    req: EphemeralSignRequest<'_>,
928) -> Result<ArtifactSigningResult, ArtifactSigningError> {
929    let EphemeralSignRequest {
930        data,
931        artifact_name,
932        commit_sha,
933        curve,
934        expires_in,
935        note,
936        ci_env,
937        oidc_binding,
938    } = req;
939    // 1. Generate the ephemeral seed and zeroize on drop
940    let mut seed_bytes = Zeroizing::new([0u8; 32]);
941    ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), seed_bytes.as_mut())
942        .map_err(|_| ArtifactSigningError::AttestationFailed("RNG failure".into()))?;
943
944    // 2. Derive pubkey and DIDs on the requested curve
945    let typed_seed = auths_crypto::TypedSeed::from_curve(curve, *seed_bytes);
946    let pubkey_vec = auths_crypto::typed_public_key(&typed_seed)
947        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
948
949    // The ephemeral key is its own issuer, so the issuer DID here is the
950    // `did:key:` of that key, not a `did:keri:` identity.
951    let device_did = CanonicalDid::from_public_key_did_key(&pubkey_vec, curve);
952
953    // 3. Build artifact metadata with optional CI environment in payload
954    let digest_hex = hex::encode(Sha256::digest(data));
955    let artifact_meta = ArtifactMetadata {
956        artifact_type: "file".to_string(),
957        digest: ArtifactDigest {
958            algorithm: "sha256".to_string(),
959            hex: digest_hex,
960        },
961        name: artifact_name,
962        size: Some(data.len() as u64),
963    };
964
965    let mut payload_value = serde_json::to_value(&artifact_meta)
966        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
967
968    if let Some(env) = ci_env
969        && let serde_json::Value::Object(ref mut map) = payload_value
970    {
971        map.insert("ci_environment".to_string(), env);
972    }
973
974    let rid = ResourceId::new(format!("sha256:{}", artifact_meta.digest.hex));
975    let meta = AttestationMetadata {
976        timestamp: Some(now),
977        expires_at: expires_in.map(|s| now + chrono::Duration::seconds(s as i64)),
978        note,
979    };
980
981    // 4. Validate commit SHA
982    let validated_sha = validate_commit_sha(&commit_sha)?;
983
984    // 5. Set up ephemeral signer
985    let identity_alias = KeyAlias::new_unchecked("__ephemeral_identity__");
986    let device_alias = KeyAlias::new_unchecked("__ephemeral_device__");
987
988    let mut seeds: HashMap<String, (SecureSeed, auths_crypto::CurveType)> = HashMap::new();
989    seeds.insert(
990        identity_alias.as_str().to_string(),
991        (SecureSeed::new(*seed_bytes), curve),
992    );
993    seeds.insert(
994        device_alias.as_str().to_string(),
995        (SecureSeed::new(*seed_bytes), curve),
996    );
997    let signer = SeedMapSigner { seeds };
998    let noop_provider = auths_core::PrefilledPassphraseProvider::new("");
999
1000    // 6. Create signed attestation with Workload signer type
1001    let attestation = create_signed_attestation(
1002        now,
1003        auths_id::attestation::create::AttestationInput {
1004            rid: rid.as_str(),
1005            issuer: &device_did,
1006            subject: &device_did,
1007            device_public_key: &pubkey_vec,
1008            device_curve: curve,
1009            payload: Some(payload_value),
1010            meta: &meta,
1011            identity_alias: Some(&identity_alias),
1012            device_alias: Some(&device_alias),
1013            delegated_by: None,
1014            commit_sha: Some(validated_sha),
1015            signer_type: Some(SignerType::Workload),
1016            oidc_binding,
1017        },
1018        &signer,
1019        &noop_provider,
1020    )
1021    .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
1022
1023    let attestation_json = serde_json::to_string_pretty(&attestation)
1024        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
1025
1026    // Compute DSSE signature for transparency log submission (key still in scope)
1027    let pae = dsse_pae("application/vnd.auths+json", attestation_json.as_bytes());
1028    let dsse_sig = auths_crypto::typed_sign(&typed_seed, &pae)
1029        .map_err(|e| ArtifactSigningError::AttestationFailed(format!("DSSE sign: {e}")))?;
1030
1031    Ok(ArtifactSigningResult {
1032        attestation_json,
1033        rid,
1034        digest: artifact_meta.digest.hex,
1035        dsse_signature: Some(dsse_sig),
1036    })
1037}
1038
1039/// Signs artifact bytes with a raw Ed25519 seed, bypassing keychain and identity storage.
1040///
1041/// This is the raw-key equivalent of [`sign_artifact`]. It does not require an
1042/// [`AuthsContext`] or any filesystem/keychain access. The same seed is used for
1043/// both identity and device signing roles.
1044///
1045/// Args:
1046/// * `now` - Current UTC time (injected per clock pattern).
1047/// * `seed` - Ed25519 32-byte seed.
1048/// * `identity_did` - Parsed identity DID (must be `did:keri:` — caller validates via `IdentityDID::parse()`).
1049/// * `data` - Raw artifact bytes to sign.
1050/// * `expires_in` - Optional TTL in seconds.
1051/// * `note` - Optional attestation note.
1052/// * `commit_sha` - Optional git commit SHA for provenance binding (40 or 64 hex chars).
1053///
1054/// Usage:
1055/// ```ignore
1056/// let did = IdentityDID::parse("did:keri:E...")?;
1057/// let result = sign_artifact_raw(Utc::now(), &seed, &did, b"payload", None, None, None)?;
1058/// ```
1059pub fn sign_artifact_raw(
1060    now: DateTime<Utc>,
1061    seed: &SecureSeed,
1062    identity_did: &IdentityDID,
1063    data: &[u8],
1064    expires_in: Option<u64>,
1065    note: Option<String>,
1066    commit_sha: Option<String>,
1067) -> Result<ArtifactSigningResult, ArtifactSigningError> {
1068    // Default to P-256 per workspace convention. The raw seed is 32 bytes for both curves.
1069    let curve = auths_crypto::CurveType::default();
1070    let typed = auths_crypto::TypedSeed::from_curve(curve, *seed.as_bytes());
1071    let pubkey = auths_crypto::typed_public_key(&typed)
1072        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
1073
1074    let device_did = CanonicalDid::from_public_key_did_key(&pubkey, curve);
1075
1076    let digest_hex = hex::encode(Sha256::digest(data));
1077    let artifact_meta = ArtifactMetadata {
1078        artifact_type: "bytes".to_string(),
1079        digest: ArtifactDigest {
1080            algorithm: "sha256".to_string(),
1081            hex: digest_hex,
1082        },
1083        name: None,
1084        size: Some(data.len() as u64),
1085    };
1086
1087    let rid = ResourceId::new(format!("sha256:{}", artifact_meta.digest.hex));
1088    let meta = AttestationMetadata {
1089        timestamp: Some(now),
1090        expires_at: expires_in.map(|s| now + chrono::Duration::seconds(s as i64)),
1091        note,
1092    };
1093
1094    let payload = serde_json::to_value(&artifact_meta)
1095        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
1096
1097    let identity_alias = KeyAlias::new_unchecked("__raw_identity__");
1098    let device_alias = KeyAlias::new_unchecked("__raw_device__");
1099
1100    let mut seeds: HashMap<String, (SecureSeed, auths_crypto::CurveType)> = HashMap::new();
1101    seeds.insert(
1102        identity_alias.as_str().to_string(),
1103        (SecureSeed::new(*seed.as_bytes()), curve),
1104    );
1105    seeds.insert(
1106        device_alias.as_str().to_string(),
1107        (SecureSeed::new(*seed.as_bytes()), curve),
1108    );
1109    let signer = SeedMapSigner { seeds };
1110    // Seeds are already resolved — passphrase provider will not be called.
1111    let noop_provider = auths_core::PrefilledPassphraseProvider::new("");
1112
1113    let validated_commit_sha = commit_sha
1114        .map(|sha| validate_commit_sha(&sha))
1115        .transpose()?;
1116
1117    let issuer_canonical = CanonicalDid::from(identity_did.clone());
1118    let attestation = create_signed_attestation(
1119        now,
1120        auths_id::attestation::create::AttestationInput {
1121            rid: rid.as_str(),
1122            issuer: &issuer_canonical,
1123            subject: &device_did,
1124            device_public_key: &pubkey,
1125            device_curve: curve,
1126            payload: Some(payload),
1127            meta: &meta,
1128            identity_alias: Some(&identity_alias),
1129            device_alias: Some(&device_alias),
1130            delegated_by: None,
1131            commit_sha: validated_commit_sha,
1132            signer_type: None,
1133            oidc_binding: None,
1134        },
1135        &signer,
1136        &noop_provider,
1137    )
1138    .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
1139
1140    let attestation_json = serde_json::to_string_pretty(&attestation)
1141        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
1142
1143    Ok(ArtifactSigningResult {
1144        attestation_json,
1145        rid,
1146        digest: artifact_meta.digest.hex,
1147        dsse_signature: None,
1148    })
1149}
1150
1151/// Compute the DSSE Pre-Authentication Encoding (PAE).
1152///
1153/// Format per the DSSE spec:
1154/// `"DSSEv1" SP len(payloadType) SP payloadType SP len(payload) SP payload`
1155pub fn dsse_pae(payload_type: &str, payload: &[u8]) -> Vec<u8> {
1156    let header = format!(
1157        "DSSEv1 {} {} {} ",
1158        payload_type.len(),
1159        payload_type,
1160        payload.len()
1161    );
1162    let mut result = Vec::with_capacity(header.len() + payload.len());
1163    result.extend_from_slice(header.as_bytes());
1164    result.extend_from_slice(payload);
1165    result
1166}