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::{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
283impl auths_core::error::AuthsErrorInfo for ArtifactSigningError {
284    fn error_code(&self) -> &'static str {
285        match self {
286            Self::IdentityNotFound => "AUTHS-E5850",
287            Self::KeyResolutionFailed(_) => "AUTHS-E5851",
288            Self::KeyDecryptionFailed(_) => "AUTHS-E5852",
289            Self::DigestFailed(_) => "AUTHS-E5853",
290            Self::AttestationFailed(_) => "AUTHS-E5854",
291            Self::ResignFailed(_) => "AUTHS-E5855",
292            Self::InvalidCommitSha(_) => "AUTHS-E5856",
293        }
294    }
295
296    fn suggestion(&self) -> Option<&'static str> {
297        match self {
298            Self::IdentityNotFound => {
299                Some("Run `auths init` to create an identity, or `auths key import` to restore one")
300            }
301            Self::KeyResolutionFailed(_) => {
302                Some("Run `auths status` to see available device aliases")
303            }
304            Self::KeyDecryptionFailed(_) => Some("Check your passphrase and try again"),
305            Self::DigestFailed(_) => Some("Verify the file exists and is readable"),
306            Self::AttestationFailed(_) => Some("Check identity storage with `auths status`"),
307            Self::ResignFailed(_) => {
308                Some("Verify your device key is accessible with `auths status`")
309            }
310            Self::InvalidCommitSha(_) => {
311                Some("Provide a full SHA-1 (40 hex chars) or SHA-256 (64 hex chars) commit hash")
312            }
313        }
314    }
315}
316
317/// A `SecureSigner` backed by pre-resolved in-memory seeds.
318///
319/// Seeds are keyed by alias and carry their curve so sign dispatches correctly
320/// for Ed25519 + P-256 identities. The passphrase provider is never called
321/// because all key material was resolved before construction.
322struct SeedMapSigner {
323    seeds: HashMap<String, (SecureSeed, auths_crypto::CurveType)>,
324}
325
326impl SecureSigner for SeedMapSigner {
327    fn sign_with_alias(
328        &self,
329        alias: &auths_core::storage::keychain::KeyAlias,
330        _passphrase_provider: &dyn PassphraseProvider,
331        message: &[u8],
332    ) -> Result<Vec<u8>, auths_core::AgentError> {
333        let (seed, curve) = self
334            .seeds
335            .get(alias.as_str())
336            .ok_or(auths_core::AgentError::KeyNotFound)?;
337        let typed = match curve {
338            auths_crypto::CurveType::Ed25519 => auths_crypto::TypedSeed::Ed25519(*seed.as_bytes()),
339            auths_crypto::CurveType::P256 => auths_crypto::TypedSeed::P256(*seed.as_bytes()),
340        };
341        auths_crypto::typed_sign(&typed, message)
342            .map_err(|e| auths_core::AgentError::CryptoError(e.to_string()))
343    }
344
345    fn sign_for_identity(
346        &self,
347        _identity_did: &IdentityDID,
348        _passphrase_provider: &dyn PassphraseProvider,
349        _message: &[u8],
350    ) -> Result<Vec<u8>, auths_core::AgentError> {
351        Err(auths_core::AgentError::KeyNotFound)
352    }
353}
354
355struct ResolvedKey {
356    alias: KeyAlias,
357    /// None for hardware backends (SE) — signing goes through the keychain directly.
358    seed: Option<SecureSeed>,
359    public_key_bytes: Vec<u8>,
360    curve: auths_crypto::CurveType,
361    /// True if this key is backed by hardware (Secure Enclave, HSM).
362    is_hardware: bool,
363}
364
365fn resolve_optional_key(
366    material: Option<&SigningKeyMaterial>,
367    synthetic_alias: &'static str,
368    keychain: &(dyn KeyStorage + Send + Sync),
369    passphrase_provider: &dyn PassphraseProvider,
370    passphrase_prompt: &str,
371) -> Result<Option<ResolvedKey>, ArtifactSigningError> {
372    match material {
373        None => Ok(None),
374        Some(SigningKeyMaterial::Alias(alias)) => {
375            // Hardware backends (Secure Enclave): resolve pubkey only; signing happens
376            // later via StorageSigner so private key material never leaves the enclave.
377            // MIRROR: keep in sync with workflows/signing.rs (SE branch)
378            if keychain.is_hardware_backend() {
379                let (pubkey, curve) = auths_core::storage::keychain::extract_public_key_bytes(
380                    keychain,
381                    alias,
382                    passphrase_provider,
383                )
384                .map_err(|e| ArtifactSigningError::KeyResolutionFailed(e.to_string()))?;
385                return Ok(Some(ResolvedKey {
386                    alias: alias.clone(),
387                    seed: None,
388                    public_key_bytes: pubkey,
389                    curve,
390                    is_hardware: true,
391                }));
392            }
393
394            let (_, _role, encrypted) = keychain
395                .load_key(alias)
396                .map_err(|e| ArtifactSigningError::KeyResolutionFailed(e.to_string()))?;
397            let passphrase = passphrase_provider
398                .get_passphrase(passphrase_prompt)
399                .map_err(|e| ArtifactSigningError::KeyDecryptionFailed(e.to_string()))?;
400            // Defense-in-depth: SE keys must never reach the software decrypt path.
401            debug_assert!(
402                !keychain.is_hardware_backend(),
403                "SE keys must never reach decrypt_keypair"
404            );
405            let pkcs8 = core_signer::decrypt_keypair(&encrypted, &passphrase)
406                .map_err(|e| ArtifactSigningError::KeyDecryptionFailed(e.to_string()))?;
407            let (seed, pubkey, curve) = core_signer::load_seed_and_pubkey(&pkcs8)
408                .map_err(|e| ArtifactSigningError::KeyDecryptionFailed(e.to_string()))?;
409            Ok(Some(ResolvedKey {
410                alias: alias.clone(),
411                seed: Some(seed),
412                public_key_bytes: pubkey.to_vec(),
413                curve,
414                is_hardware: false,
415            }))
416        }
417        Some(SigningKeyMaterial::Direct(seed)) => {
418            let typed = auths_crypto::TypedSeed::Ed25519(*seed.as_bytes());
419            let pubkey = auths_crypto::typed_public_key(&typed)
420                .map_err(|e| ArtifactSigningError::KeyDecryptionFailed(e.to_string()))?;
421            Ok(Some(ResolvedKey {
422                alias: KeyAlias::new_unchecked(synthetic_alias),
423                seed: Some(SecureSeed::new(*seed.as_bytes())),
424                public_key_bytes: pubkey,
425                curve: auths_crypto::CurveType::Ed25519,
426                is_hardware: false,
427            }))
428        }
429    }
430}
431
432fn resolve_required_key(
433    material: &SigningKeyMaterial,
434    synthetic_alias: &'static str,
435    keychain: &(dyn KeyStorage + Send + Sync),
436    passphrase_provider: &dyn PassphraseProvider,
437    passphrase_prompt: &str,
438) -> Result<ResolvedKey, ArtifactSigningError> {
439    resolve_optional_key(
440        Some(material),
441        synthetic_alias,
442        keychain,
443        passphrase_provider,
444        passphrase_prompt,
445    )
446    .map(|opt| {
447        opt.ok_or(ArtifactSigningError::KeyDecryptionFailed(
448            "expected key material but got None".into(),
449        ))
450    })?
451}
452
453/// Validate and normalize a commit SHA (40-char SHA-1 or 64-char SHA-256).
454///
455/// Args:
456/// * `sha`: The raw commit SHA string to validate.
457///
458/// Usage:
459/// ```ignore
460/// let normalized = validate_commit_sha("AbCd1234...")?;
461/// ```
462pub fn validate_commit_sha(sha: &str) -> Result<String, ArtifactSigningError> {
463    let normalized = sha.to_ascii_lowercase();
464    let len = normalized.len();
465    if (len != 40 && len != 64) || !normalized.bytes().all(|b| b.is_ascii_hexdigit()) {
466        return Err(ArtifactSigningError::InvalidCommitSha(sha.to_string()));
467    }
468    Ok(normalized)
469}
470
471/// Full artifact attestation signing pipeline.
472///
473/// Loads the identity, resolves key material (supporting both keychain aliases
474/// and direct in-memory seed injection), computes the artifact digest, and
475/// produces a dual-signed attestation JSON.
476///
477/// Args:
478/// * `params`: All inputs required for signing, including key material and artifact source.
479/// * `ctx`: Runtime context providing identity storage, key storage, passphrase provider, and clock.
480///
481/// Usage:
482/// ```ignore
483/// let params = ArtifactSigningParams {
484///     artifact: Arc::new(FileArtifact::new(Path::new("release.tar.gz"))),
485///     identity_key: Some(SigningKeyMaterial::Alias("my-key".into())),
486///     device_key: SigningKeyMaterial::Direct(seed),
487///     expires_in: Some(31_536_000),
488///     note: None,
489///     commit_sha: None,
490/// };
491/// let result = sign_artifact(params, &ctx)?;
492/// ```
493pub fn sign_artifact(
494    params: ArtifactSigningParams,
495    ctx: &AuthsContext,
496) -> Result<ArtifactSigningResult, ArtifactSigningError> {
497    let managed = ctx
498        .identity_storage
499        .load_identity()
500        .map_err(|_| ArtifactSigningError::IdentityNotFound)?;
501
502    let keychain = ctx.key_storage.as_ref();
503    let passphrase_provider = ctx.passphrase_provider.as_ref();
504
505    let identity_resolved = resolve_optional_key(
506        params.identity_key.as_ref(),
507        "__artifact_identity__",
508        keychain,
509        passphrase_provider,
510        "Enter passphrase for identity key:",
511    )?;
512
513    let device_resolved = resolve_required_key(
514        &params.device_key,
515        "__artifact_device__",
516        keychain,
517        passphrase_provider,
518        "Enter passphrase for device key:",
519    )?;
520
521    let mut seeds: HashMap<String, (SecureSeed, auths_crypto::CurveType)> = HashMap::new();
522    let identity_alias: Option<KeyAlias> = identity_resolved.map(|r| {
523        let alias = r.alias.clone();
524        let curve = r.curve;
525        if let Some(seed) = r.seed {
526            seeds.insert(r.alias.into_inner(), (seed, curve));
527        }
528        alias
529    });
530    let device_alias = device_resolved.alias.clone();
531    let device_is_hardware = device_resolved.is_hardware;
532    let device_curve = device_resolved.curve;
533    if let Some(seed) = device_resolved.seed {
534        seeds.insert(device_resolved.alias.into_inner(), (seed, device_curve));
535    }
536    let device_pk_bytes = device_resolved.public_key_bytes;
537
538    let device_did = CanonicalDid::from_public_key_did_key(&device_pk_bytes, device_resolved.curve);
539
540    let artifact_meta = params
541        .artifact
542        .metadata()
543        .map_err(|e| ArtifactSigningError::DigestFailed(e.to_string()))?;
544
545    let rid = ResourceId::new(format!("sha256:{}", artifact_meta.digest.hex));
546    let now = ctx.clock.now();
547    let meta = AttestationMetadata {
548        timestamp: Some(now),
549        expires_at: params
550            .expires_in
551            .map(|s| now + chrono::Duration::seconds(s as i64)),
552        note: params.note,
553    };
554
555    let payload = serde_json::to_value(&artifact_meta)
556        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
557
558    let validated_commit_sha = params
559        .commit_sha
560        .map(|sha| validate_commit_sha(&sha))
561        .transpose()?;
562
563    let attestation_json = create_and_sign_attestation(
564        ctx,
565        seeds,
566        device_is_hardware,
567        now,
568        &rid,
569        &managed.controller_did,
570        &device_did,
571        &device_pk_bytes,
572        device_curve,
573        payload,
574        &meta,
575        identity_alias.as_ref(),
576        &device_alias,
577        validated_commit_sha,
578    )?;
579
580    if let Some(ref alias) = identity_alias {
581        anchor_artifact_attestation(ctx, &managed.controller_did, alias, &attestation_json, now)?;
582    }
583
584    Ok(ArtifactSigningResult {
585        attestation_json,
586        rid,
587        digest: artifact_meta.digest.hex,
588        dsse_signature: None,
589    })
590}
591
592fn anchor_artifact_attestation(
593    ctx: &AuthsContext,
594    controller_did: &auths_core::storage::keychain::IdentityDID,
595    alias: &KeyAlias,
596    attestation_json: &str,
597    now: DateTime<Utc>,
598) -> Result<(), ArtifactSigningError> {
599    let prefix = auths_id::keri::parse_did_keri(controller_did.as_str())
600        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
601    let att: auths_verifier::core::Attestation = serde_json::from_str(attestation_json)
602        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
603    let storage_signer = auths_core::signing::StorageSigner::new(Arc::clone(&ctx.key_storage));
604    let mut batch = auths_id::storage::registry::backend::AtomicWriteBatch::new();
605    batch.stage_attestation(att.clone());
606    auths_id::keri::anchor_and_persist_via_backend(
607        ctx.registry.as_ref(),
608        &storage_signer,
609        alias,
610        ctx.passphrase_provider.as_ref(),
611        &prefix,
612        &att,
613        &mut batch,
614        &ctx.witness_params(),
615        now,
616    )
617    .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
618    Ok(())
619}
620
621/// Create, sign, and serialize an attestation. Handles both hardware and software signers.
622#[allow(clippy::too_many_arguments)]
623fn create_and_sign_attestation(
624    ctx: &AuthsContext,
625    seeds: HashMap<String, (SecureSeed, auths_crypto::CurveType)>,
626    device_is_hardware: bool,
627    now: DateTime<Utc>,
628    rid: &ResourceId,
629    controller_did: &IdentityDID,
630    subject: &CanonicalDid,
631    device_pk_bytes: &[u8],
632    device_curve: auths_crypto::CurveType,
633    payload: serde_json::Value,
634    meta: &AttestationMetadata,
635    identity_alias: Option<&KeyAlias>,
636    device_alias: &KeyAlias,
637    commit_sha: Option<String>,
638) -> Result<String, ArtifactSigningError> {
639    let seed_signer = SeedMapSigner { seeds };
640    let storage_signer = auths_core::signing::StorageSigner::new(Arc::clone(&ctx.key_storage));
641    let signer: &dyn SecureSigner = if device_is_hardware {
642        &storage_signer
643    } else {
644        &seed_signer
645    };
646    let noop_provider = auths_core::PrefilledPassphraseProvider::new("");
647
648    let mut attestation = create_signed_attestation(
649        now,
650        auths_id::attestation::create::AttestationInput {
651            rid: rid.as_str(),
652            identity_did: controller_did,
653            subject,
654            device_public_key: device_pk_bytes,
655            device_curve,
656            payload: Some(payload),
657            meta,
658            identity_alias,
659            device_alias: Some(device_alias),
660            delegated_by: None,
661            commit_sha,
662            signer_type: None,
663        },
664        signer,
665        &noop_provider,
666    )
667    .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
668
669    resign_attestation(
670        &mut attestation,
671        signer,
672        &noop_provider,
673        identity_alias,
674        device_alias,
675    )
676    .map_err(|e| ArtifactSigningError::ResignFailed(e.to_string()))?;
677
678    serde_json::to_string_pretty(&attestation)
679        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))
680}
681
682/// Signs artifact bytes with a one-time ephemeral Ed25519 key. No keychain, no
683/// identity storage, no passphrase — the key is generated, used, and zeroized
684/// within this function call.
685///
686/// The ephemeral key signs "this artifact was built from this commit." Trust
687/// derives transitively: consumers verify the commit is signed by a maintainer,
688/// then verify this attestation's ephemeral signature covers the artifact hash
689/// and commit SHA.
690///
691/// Args:
692/// * `now` - Current UTC time (injected per clock pattern).
693/// * `data` - Raw artifact bytes to sign.
694/// * `artifact_name` - Optional human-readable name for the artifact.
695/// * `commit_sha` - Git commit SHA this artifact was built from (required, 40 or 64 hex chars).
696/// * `expires_in` - Optional TTL in seconds.
697/// * `note` - Optional attestation note.
698/// * `ci_env` - Optional CI environment metadata (serialized into payload, covered by signature).
699///
700/// Usage:
701/// ```ignore
702/// let result = sign_artifact_ephemeral(
703///     Utc::now(), b"artifact bytes", Some("release.tar.gz".into()),
704///     "abc123def456abc123def456abc123def456abc1".into(), None, None, None,
705/// )?;
706/// ```
707pub fn sign_artifact_ephemeral(
708    now: DateTime<Utc>,
709    data: &[u8],
710    artifact_name: Option<String>,
711    commit_sha: String,
712    expires_in: Option<u64>,
713    note: Option<String>,
714    ci_env: Option<serde_json::Value>,
715) -> Result<ArtifactSigningResult, ArtifactSigningError> {
716    // 1. Generate ephemeral P-256 seed and zeroize on drop
717    let mut seed_bytes = Zeroizing::new([0u8; 32]);
718    ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), seed_bytes.as_mut())
719        .map_err(|_| ArtifactSigningError::AttestationFailed("RNG failure".into()))?;
720
721    // 2. Derive pubkey and DIDs using P-256 (default curve)
722    let typed_seed = auths_crypto::TypedSeed::P256(*seed_bytes);
723    let pubkey_vec = auths_crypto::typed_public_key(&typed_seed)
724        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
725
726    let device_did =
727        CanonicalDid::from_public_key_did_key(&pubkey_vec, auths_crypto::CurveType::P256);
728    #[allow(clippy::disallowed_methods)]
729    let identity_did = IdentityDID::new_unchecked(device_did.as_str());
730
731    // 3. Build artifact metadata with optional CI environment in payload
732    let digest_hex = hex::encode(Sha256::digest(data));
733    let artifact_meta = ArtifactMetadata {
734        artifact_type: "file".to_string(),
735        digest: ArtifactDigest {
736            algorithm: "sha256".to_string(),
737            hex: digest_hex,
738        },
739        name: artifact_name,
740        size: Some(data.len() as u64),
741    };
742
743    let mut payload_value = serde_json::to_value(&artifact_meta)
744        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
745
746    if let Some(env) = ci_env
747        && let serde_json::Value::Object(ref mut map) = payload_value
748    {
749        map.insert("ci_environment".to_string(), env);
750    }
751
752    let rid = ResourceId::new(format!("sha256:{}", artifact_meta.digest.hex));
753    let meta = AttestationMetadata {
754        timestamp: Some(now),
755        expires_at: expires_in.map(|s| now + chrono::Duration::seconds(s as i64)),
756        note,
757    };
758
759    // 4. Validate commit SHA
760    let validated_sha = validate_commit_sha(&commit_sha)?;
761
762    // 5. Set up ephemeral signer
763    let identity_alias = KeyAlias::new_unchecked("__ephemeral_identity__");
764    let device_alias = KeyAlias::new_unchecked("__ephemeral_device__");
765
766    let mut seeds: HashMap<String, (SecureSeed, auths_crypto::CurveType)> = HashMap::new();
767    seeds.insert(
768        identity_alias.as_str().to_string(),
769        (SecureSeed::new(*seed_bytes), auths_crypto::CurveType::P256),
770    );
771    seeds.insert(
772        device_alias.as_str().to_string(),
773        (SecureSeed::new(*seed_bytes), auths_crypto::CurveType::P256),
774    );
775    let signer = SeedMapSigner { seeds };
776    let noop_provider = auths_core::PrefilledPassphraseProvider::new("");
777
778    // 6. Create signed attestation with Workload signer type
779    let attestation = create_signed_attestation(
780        now,
781        auths_id::attestation::create::AttestationInput {
782            rid: rid.as_str(),
783            identity_did: &identity_did,
784            subject: &device_did,
785            device_public_key: &pubkey_vec,
786            device_curve: auths_crypto::CurveType::P256,
787            payload: Some(payload_value),
788            meta: &meta,
789            identity_alias: Some(&identity_alias),
790            device_alias: Some(&device_alias),
791            delegated_by: None,
792            commit_sha: Some(validated_sha),
793            signer_type: Some(SignerType::Workload),
794        },
795        &signer,
796        &noop_provider,
797    )
798    .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
799
800    let attestation_json = serde_json::to_string_pretty(&attestation)
801        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
802
803    // Compute DSSE signature for transparency log submission (key still in scope)
804    let pae = dsse_pae("application/vnd.auths+json", attestation_json.as_bytes());
805    let dsse_sig = auths_crypto::typed_sign(&typed_seed, &pae)
806        .map_err(|e| ArtifactSigningError::AttestationFailed(format!("DSSE sign: {e}")))?;
807
808    Ok(ArtifactSigningResult {
809        attestation_json,
810        rid,
811        digest: artifact_meta.digest.hex,
812        dsse_signature: Some(dsse_sig),
813    })
814}
815
816/// Signs artifact bytes with a raw Ed25519 seed, bypassing keychain and identity storage.
817///
818/// This is the raw-key equivalent of [`sign_artifact`]. It does not require an
819/// [`AuthsContext`] or any filesystem/keychain access. The same seed is used for
820/// both identity and device signing roles.
821///
822/// Args:
823/// * `now` - Current UTC time (injected per clock pattern).
824/// * `seed` - Ed25519 32-byte seed.
825/// * `identity_did` - Parsed identity DID (must be `did:keri:` — caller validates via `IdentityDID::parse()`).
826/// * `data` - Raw artifact bytes to sign.
827/// * `expires_in` - Optional TTL in seconds.
828/// * `note` - Optional attestation note.
829/// * `commit_sha` - Optional git commit SHA for provenance binding (40 or 64 hex chars).
830///
831/// Usage:
832/// ```ignore
833/// let did = IdentityDID::parse("did:keri:E...")?;
834/// let result = sign_artifact_raw(Utc::now(), &seed, &did, b"payload", None, None, None)?;
835/// ```
836pub fn sign_artifact_raw(
837    now: DateTime<Utc>,
838    seed: &SecureSeed,
839    identity_did: &IdentityDID,
840    data: &[u8],
841    expires_in: Option<u64>,
842    note: Option<String>,
843    commit_sha: Option<String>,
844) -> Result<ArtifactSigningResult, ArtifactSigningError> {
845    // Default to P-256 per workspace convention. The raw seed is 32 bytes for both curves.
846    let curve = auths_crypto::CurveType::default();
847    let typed = match curve {
848        auths_crypto::CurveType::Ed25519 => auths_crypto::TypedSeed::Ed25519(*seed.as_bytes()),
849        auths_crypto::CurveType::P256 => auths_crypto::TypedSeed::P256(*seed.as_bytes()),
850    };
851    let pubkey = auths_crypto::typed_public_key(&typed)
852        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
853
854    let device_did = CanonicalDid::from_public_key_did_key(&pubkey, curve);
855
856    let digest_hex = hex::encode(Sha256::digest(data));
857    let artifact_meta = ArtifactMetadata {
858        artifact_type: "bytes".to_string(),
859        digest: ArtifactDigest {
860            algorithm: "sha256".to_string(),
861            hex: digest_hex,
862        },
863        name: None,
864        size: Some(data.len() as u64),
865    };
866
867    let rid = ResourceId::new(format!("sha256:{}", artifact_meta.digest.hex));
868    let meta = AttestationMetadata {
869        timestamp: Some(now),
870        expires_at: expires_in.map(|s| now + chrono::Duration::seconds(s as i64)),
871        note,
872    };
873
874    let payload = serde_json::to_value(&artifact_meta)
875        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
876
877    let identity_alias = KeyAlias::new_unchecked("__raw_identity__");
878    let device_alias = KeyAlias::new_unchecked("__raw_device__");
879
880    let mut seeds: HashMap<String, (SecureSeed, auths_crypto::CurveType)> = HashMap::new();
881    seeds.insert(
882        identity_alias.as_str().to_string(),
883        (SecureSeed::new(*seed.as_bytes()), curve),
884    );
885    seeds.insert(
886        device_alias.as_str().to_string(),
887        (SecureSeed::new(*seed.as_bytes()), curve),
888    );
889    let signer = SeedMapSigner { seeds };
890    // Seeds are already resolved — passphrase provider will not be called.
891    let noop_provider = auths_core::PrefilledPassphraseProvider::new("");
892
893    let validated_commit_sha = commit_sha
894        .map(|sha| validate_commit_sha(&sha))
895        .transpose()?;
896
897    let attestation = create_signed_attestation(
898        now,
899        auths_id::attestation::create::AttestationInput {
900            rid: rid.as_str(),
901            identity_did,
902            subject: &device_did,
903            device_public_key: &pubkey,
904            device_curve: curve,
905            payload: Some(payload),
906            meta: &meta,
907            identity_alias: Some(&identity_alias),
908            device_alias: Some(&device_alias),
909            delegated_by: None,
910            commit_sha: validated_commit_sha,
911            signer_type: None,
912        },
913        &signer,
914        &noop_provider,
915    )
916    .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
917
918    let attestation_json = serde_json::to_string_pretty(&attestation)
919        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
920
921    Ok(ArtifactSigningResult {
922        attestation_json,
923        rid,
924        digest: artifact_meta.digest.hex,
925        dsse_signature: None,
926    })
927}
928
929/// Compute the DSSE Pre-Authentication Encoding (PAE).
930///
931/// Format per the DSSE spec:
932/// `"DSSEv1" SP len(payloadType) SP payloadType SP len(payload) SP payload`
933pub fn dsse_pae(payload_type: &str, payload: &[u8]) -> Vec<u8> {
934    let header = format!(
935        "DSSEv1 {} {} {} ",
936        payload_type.len(),
937        payload_type,
938        payload.len()
939    );
940    let mut result = Vec::with_capacity(header.len() + payload.len());
941    result.extend_from_slice(header.as_bytes());
942    result.extend_from_slice(payload);
943    result
944}