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