Skip to main content

auths_sdk/
signing.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
6use crate::context::AuthsContext;
7use crate::ports::artifact::ArtifactSource;
8use auths_core::crypto::ssh::{self, SecureSeed};
9use auths_core::crypto::{provider_bridge, signer as core_signer};
10use auths_core::signing::{PassphraseProvider, SecureSigner};
11use auths_core::storage::keychain::{IdentityDID, KeyAlias, KeyStorage};
12use auths_id::attestation::core::resign_attestation;
13use auths_id::attestation::create::create_signed_attestation;
14use auths_id::storage::git_refs::AttestationMetadata;
15use auths_verifier::core::Capability;
16use auths_verifier::types::DeviceDID;
17use std::collections::HashMap;
18use std::path::Path;
19use std::sync::Arc;
20
21/// Errors from the signing pipeline.
22#[derive(Debug, thiserror::Error)]
23#[non_exhaustive]
24pub enum SigningError {
25    /// The identity is in a freeze state and signing is not permitted.
26    #[error("identity is frozen: {0}")]
27    IdentityFrozen(String),
28    /// The requested key alias could not be resolved from the keychain.
29    #[error("key resolution failed: {0}")]
30    KeyResolution(String),
31    /// The cryptographic signing operation failed.
32    #[error("signing operation failed: {0}")]
33    SigningFailed(String),
34    /// The supplied passphrase was incorrect.
35    #[error("invalid passphrase")]
36    InvalidPassphrase,
37    /// SSHSIG PEM encoding failed after signing.
38    #[error("PEM encoding failed: {0}")]
39    PemEncoding(String),
40    /// The agent is not available (platform unsupported, not installed, or not reachable).
41    #[error("agent unavailable: {0}")]
42    AgentUnavailable(String),
43    /// The agent accepted the signing request but it failed.
44    #[error("agent signing failed")]
45    AgentSigningFailed(#[source] crate::ports::agent::AgentSigningError),
46    /// All passphrase attempts were exhausted without a successful decryption.
47    #[error("passphrase exhausted after {attempts} attempt(s)")]
48    PassphraseExhausted {
49        /// Number of failed attempts before giving up.
50        attempts: usize,
51    },
52    /// The platform keychain could not be accessed.
53    #[error("keychain unavailable: {0}")]
54    KeychainUnavailable(String),
55    /// The encrypted key material could not be decrypted.
56    #[error("key decryption failed: {0}")]
57    KeyDecryptionFailed(String),
58}
59
60/// Configuration for a signing operation.
61///
62/// Args:
63/// * `namespace`: The SSHSIG namespace (typically "git").
64///
65/// Usage:
66/// ```ignore
67/// let config = SigningConfig {
68///     namespace: "git".to_string(),
69/// };
70/// ```
71pub struct SigningConfig {
72    /// SSHSIG namespace string (e.g. `"git"` for commit signing).
73    pub namespace: String,
74}
75
76/// Validate that the identity is not frozen.
77///
78/// Args:
79/// * `repo_path`: Path to the auths repository (typically `~/.auths`).
80/// * `now`: The reference time used to check if the freeze is active.
81///
82/// Usage:
83/// ```ignore
84/// validate_freeze_state(&repo_path, clock.now())?;
85/// ```
86pub fn validate_freeze_state(
87    repo_path: &Path,
88    now: chrono::DateTime<chrono::Utc>,
89) -> Result<(), SigningError> {
90    use auths_id::freeze::load_active_freeze;
91
92    if let Some(state) = load_active_freeze(repo_path, now)
93        .map_err(|e| SigningError::IdentityFrozen(e.to_string()))?
94    {
95        return Err(SigningError::IdentityFrozen(format!(
96            "frozen until {}. Remaining: {}. To unfreeze: auths emergency unfreeze",
97            state.frozen_until.format("%Y-%m-%d %H:%M UTC"),
98            state.expires_description(now),
99        )));
100    }
101
102    Ok(())
103}
104
105/// Construct the SSHSIG signed-data payload for the given data and namespace.
106///
107/// Args:
108/// * `data`: The raw bytes to sign.
109/// * `namespace`: The SSHSIG namespace (e.g. "git").
110///
111/// Usage:
112/// ```ignore
113/// let payload = construct_signature_payload(b"data", "git")?;
114/// ```
115pub fn construct_signature_payload(data: &[u8], namespace: &str) -> Result<Vec<u8>, SigningError> {
116    ssh::construct_sshsig_signed_data(data, namespace)
117        .map_err(|e| SigningError::SigningFailed(e.to_string()))
118}
119
120/// Create a complete SSHSIG PEM signature from a seed and data.
121///
122/// Args:
123/// * `seed`: The Ed25519 signing seed.
124/// * `data`: The raw bytes to sign.
125/// * `namespace`: The SSHSIG namespace.
126///
127/// Usage:
128/// ```ignore
129/// let pem = sign_with_seed(&seed, b"data to sign", "git")?;
130/// ```
131pub fn sign_with_seed(
132    seed: &SecureSeed,
133    data: &[u8],
134    namespace: &str,
135) -> Result<String, SigningError> {
136    ssh::create_sshsig(seed, data, namespace).map_err(|e| SigningError::PemEncoding(e.to_string()))
137}
138
139// ---------------------------------------------------------------------------
140// Artifact attestation signing
141// ---------------------------------------------------------------------------
142
143/// Selects how a signing key is supplied to `sign_artifact_attestation`.
144///
145/// `Alias` resolves the key from the platform keychain at call time.
146/// `Direct` injects a raw seed, bypassing the keychain — intended for headless
147/// CI/CD runners that have no platform keychain available.
148pub enum SigningKeyMaterial {
149    /// Resolve by alias from the platform keychain.
150    Alias(KeyAlias),
151    /// Inject a raw Ed25519 seed directly. The passphrase provider is not called.
152    Direct(SecureSeed),
153}
154
155/// Parameters for the artifact attestation signing workflow.
156///
157/// Usage:
158/// ```ignore
159/// let params = ArtifactSigningParams {
160///     artifact: Arc::new(my_artifact),
161///     identity_key: Some(SigningKeyMaterial::Alias("my-identity".into())),
162///     device_key: SigningKeyMaterial::Direct(my_seed),
163///     expires_in_days: Some(365),
164///     note: None,
165/// };
166/// ```
167pub struct ArtifactSigningParams {
168    /// The artifact to attest. Provides the canonical digest and metadata.
169    pub artifact: Arc<dyn ArtifactSource>,
170    /// Identity key source. `None` skips the identity signature.
171    pub identity_key: Option<SigningKeyMaterial>,
172    /// Device key source. Required to produce a dual-signed attestation.
173    pub device_key: SigningKeyMaterial,
174    /// Number of days until the attestation expires. `None` means no expiry.
175    pub expires_in_days: Option<u32>,
176    /// Optional human-readable annotation embedded in the attestation.
177    pub note: Option<String>,
178}
179
180/// Result of a successful artifact attestation signing operation.
181///
182/// Usage:
183/// ```ignore
184/// let result = sign_artifact_attestation(params, &ctx)?;
185/// std::fs::write(&output_path, &result.attestation_json)?;
186/// println!("Signed {} (sha256:{})", result.rid, result.digest);
187/// ```
188#[derive(Debug)]
189pub struct ArtifactSigningResult {
190    /// Canonical JSON of the signed attestation.
191    pub attestation_json: String,
192    /// Resource identifier assigned to the attestation in the identity store.
193    pub rid: String,
194    /// Hex-encoded SHA-256 digest of the attested artifact.
195    pub digest: String,
196}
197
198/// Errors from the artifact attestation signing workflow.
199#[derive(Debug, thiserror::Error)]
200#[non_exhaustive]
201pub enum ArtifactSigningError {
202    /// No auths identity was found in the configured identity storage.
203    #[error("identity not found in configured identity storage")]
204    IdentityNotFound,
205
206    /// The key alias could not be resolved to usable key material.
207    #[error("key resolution failed: {0}")]
208    KeyResolutionFailed(String),
209
210    /// The encrypted key material could not be decrypted (e.g. wrong passphrase).
211    #[error("key decryption failed: {0}")]
212    KeyDecryptionFailed(String),
213
214    /// Computing the artifact digest failed.
215    #[error("digest computation failed: {0}")]
216    DigestFailed(String),
217
218    /// Building or serializing the attestation failed.
219    #[error("attestation creation failed: {0}")]
220    AttestationFailed(String),
221
222    /// Adding the device signature to a partially-signed attestation failed.
223    #[error("attestation re-signing failed: {0}")]
224    ResignFailed(String),
225}
226
227/// A `SecureSigner` backed by pre-resolved in-memory seeds.
228///
229/// Seeds are keyed by alias. The passphrase provider is never called because
230/// all key material was resolved before construction.
231struct SeedMapSigner {
232    seeds: HashMap<String, SecureSeed>,
233}
234
235impl SecureSigner for SeedMapSigner {
236    fn sign_with_alias(
237        &self,
238        alias: &auths_core::storage::keychain::KeyAlias,
239        _passphrase_provider: &dyn PassphraseProvider,
240        message: &[u8],
241    ) -> Result<Vec<u8>, auths_core::AgentError> {
242        let seed = self
243            .seeds
244            .get(alias.as_str())
245            .ok_or(auths_core::AgentError::KeyNotFound)?;
246        provider_bridge::sign_ed25519_sync(seed, message)
247            .map_err(|e| auths_core::AgentError::CryptoError(e.to_string()))
248    }
249
250    fn sign_for_identity(
251        &self,
252        _identity_did: &IdentityDID,
253        _passphrase_provider: &dyn PassphraseProvider,
254        _message: &[u8],
255    ) -> Result<Vec<u8>, auths_core::AgentError> {
256        Err(auths_core::AgentError::KeyNotFound)
257    }
258}
259
260struct ResolvedKey {
261    alias: KeyAlias,
262    seed: SecureSeed,
263    public_key_bytes: Vec<u8>,
264}
265
266fn resolve_optional_key(
267    material: Option<&SigningKeyMaterial>,
268    synthetic_alias: &'static str,
269    keychain: &(dyn KeyStorage + Send + Sync),
270    passphrase_provider: &dyn PassphraseProvider,
271    passphrase_prompt: &str,
272) -> Result<Option<ResolvedKey>, ArtifactSigningError> {
273    match material {
274        None => Ok(None),
275        Some(SigningKeyMaterial::Alias(alias)) => {
276            let (_, encrypted) = keychain
277                .load_key(alias)
278                .map_err(|e| ArtifactSigningError::KeyResolutionFailed(e.to_string()))?;
279            let passphrase = passphrase_provider
280                .get_passphrase(passphrase_prompt)
281                .map_err(|e| ArtifactSigningError::KeyDecryptionFailed(e.to_string()))?;
282            let pkcs8 = core_signer::decrypt_keypair(&encrypted, &passphrase)
283                .map_err(|e| ArtifactSigningError::KeyDecryptionFailed(e.to_string()))?;
284            let (seed, pubkey) = core_signer::load_seed_and_pubkey(&pkcs8)
285                .map_err(|e| ArtifactSigningError::KeyDecryptionFailed(e.to_string()))?;
286            Ok(Some(ResolvedKey {
287                alias: alias.clone(),
288                seed,
289                public_key_bytes: pubkey.to_vec(),
290            }))
291        }
292        Some(SigningKeyMaterial::Direct(seed)) => {
293            let pubkey = provider_bridge::ed25519_public_key_from_seed_sync(seed)
294                .map_err(|e| ArtifactSigningError::KeyDecryptionFailed(e.to_string()))?;
295            Ok(Some(ResolvedKey {
296                alias: KeyAlias::new_unchecked(synthetic_alias),
297                seed: SecureSeed::new(*seed.as_bytes()),
298                public_key_bytes: pubkey.to_vec(),
299            }))
300        }
301    }
302}
303
304fn resolve_required_key(
305    material: &SigningKeyMaterial,
306    synthetic_alias: &'static str,
307    keychain: &(dyn KeyStorage + Send + Sync),
308    passphrase_provider: &dyn PassphraseProvider,
309    passphrase_prompt: &str,
310) -> Result<ResolvedKey, ArtifactSigningError> {
311    resolve_optional_key(
312        Some(material),
313        synthetic_alias,
314        keychain,
315        passphrase_provider,
316        passphrase_prompt,
317    )
318    .map(|opt| {
319        opt.ok_or(ArtifactSigningError::KeyDecryptionFailed(
320            "expected key material but got None".into(),
321        ))
322    })?
323}
324
325/// Full artifact attestation signing pipeline.
326///
327/// Loads the identity, resolves key material (supporting both keychain aliases
328/// and direct in-memory seed injection), computes the artifact digest, and
329/// produces a dual-signed attestation JSON.
330///
331/// Args:
332/// * `params`: All inputs required for signing, including key material and artifact source.
333/// * `ctx`: Runtime context providing identity storage, key storage, passphrase provider, and clock.
334///
335/// Usage:
336/// ```ignore
337/// let params = ArtifactSigningParams {
338///     artifact: Arc::new(FileArtifact::new(Path::new("release.tar.gz"))),
339///     identity_key: Some(SigningKeyMaterial::Alias("my-key".into())),
340///     device_key: SigningKeyMaterial::Direct(seed),
341///     expires_in_days: Some(365),
342///     note: None,
343/// };
344/// let result = sign_artifact_attestation(params, &ctx)?;
345/// ```
346pub fn sign_artifact_attestation(
347    params: ArtifactSigningParams,
348    ctx: &AuthsContext,
349) -> Result<ArtifactSigningResult, ArtifactSigningError> {
350    let managed = ctx
351        .identity_storage
352        .load_identity()
353        .map_err(|_| ArtifactSigningError::IdentityNotFound)?;
354
355    let keychain = ctx.key_storage.as_ref();
356    let passphrase_provider = ctx.passphrase_provider.as_ref();
357
358    let identity_resolved = resolve_optional_key(
359        params.identity_key.as_ref(),
360        "__artifact_identity__",
361        keychain,
362        passphrase_provider,
363        "Enter passphrase for identity key:",
364    )?;
365
366    let device_resolved = resolve_required_key(
367        &params.device_key,
368        "__artifact_device__",
369        keychain,
370        passphrase_provider,
371        "Enter passphrase for device key:",
372    )?;
373
374    let mut seeds: HashMap<String, SecureSeed> = HashMap::new();
375    let identity_alias: Option<KeyAlias> = identity_resolved.map(|r| {
376        let alias = r.alias.clone();
377        seeds.insert(r.alias.into_inner(), r.seed);
378        alias
379    });
380    let device_alias = device_resolved.alias.clone();
381    seeds.insert(device_resolved.alias.into_inner(), device_resolved.seed);
382    let device_pk_bytes = device_resolved.public_key_bytes;
383
384    let device_did =
385        DeviceDID::from_ed25519(device_pk_bytes.as_slice().try_into().map_err(|_| {
386            ArtifactSigningError::AttestationFailed("device public key must be 32 bytes".into())
387        })?);
388
389    let artifact_meta = params
390        .artifact
391        .metadata()
392        .map_err(|e| ArtifactSigningError::DigestFailed(e.to_string()))?;
393
394    let rid = format!("sha256:{}", artifact_meta.digest.hex);
395    let now = ctx.clock.now();
396    let meta = AttestationMetadata {
397        timestamp: Some(now),
398        expires_at: params
399            .expires_in_days
400            .map(|d| now + chrono::Duration::days(d as i64)),
401        note: params.note,
402    };
403
404    let payload = serde_json::to_value(&artifact_meta)
405        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
406
407    let signer = SeedMapSigner { seeds };
408    // Seeds are already resolved — passphrase provider will not be called.
409    let noop_provider = auths_core::PrefilledPassphraseProvider::new("");
410
411    let mut attestation = create_signed_attestation(
412        now,
413        &rid,
414        &managed.controller_did,
415        &device_did,
416        &device_pk_bytes,
417        Some(payload),
418        &meta,
419        &signer,
420        &noop_provider,
421        identity_alias.as_ref(),
422        Some(&device_alias),
423        vec![Capability::sign_release()],
424        None,
425        None,
426    )
427    .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
428
429    resign_attestation(
430        &mut attestation,
431        &signer,
432        &noop_provider,
433        identity_alias.as_ref(),
434        &device_alias,
435    )
436    .map_err(|e| ArtifactSigningError::ResignFailed(e.to_string()))?;
437
438    let attestation_json = serde_json::to_string_pretty(&attestation)
439        .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
440
441    Ok(ArtifactSigningResult {
442        attestation_json,
443        rid,
444        digest: artifact_meta.digest.hex,
445    })
446}