Skip to main content

auths_sdk/workflows/
signing.rs

1//! Commit signing workflow with three-tier fallback.
2//!
3//! Tier 1: Agent-based signing (passphrase-free, fastest).
4//! Tier 2: Auto-start agent + decrypt key + direct sign.
5//! Tier 3: Direct signing with decrypted seed.
6
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use chrono::{DateTime, Utc};
11
12use auths_core::AgentError;
13use auths_core::crypto::signer::decrypt_keypair;
14use auths_core::crypto::ssh;
15use auths_core::crypto::ssh::SecureSeed;
16use auths_core::signing::PassphraseProvider;
17use auths_core::storage::keychain::{KeyAlias, KeyStorage};
18use auths_crypto::Pkcs8Der;
19
20use crate::ports::agent::{AgentSigningError, AgentSigningPort};
21use crate::signing::{self, SigningError};
22
23const DEFAULT_MAX_PASSPHRASE_ATTEMPTS: usize = 3;
24
25/// Minimal dependency set for the commit signing workflow.
26///
27/// Avoids requiring the full [`AuthsContext`](crate::context::AuthsContext)
28/// when only signing-related ports are needed (e.g. in the `auths-sign` binary).
29///
30/// Usage:
31/// ```ignore
32/// let deps = CommitSigningContext {
33///     key_storage: Arc::from(keychain),
34///     passphrase_provider: Arc::new(my_provider),
35///     agent_signing: Arc::new(my_agent),
36/// };
37/// CommitSigningWorkflow::execute(&deps, params, Utc::now())?;
38/// ```
39pub struct CommitSigningContext {
40    /// Platform keychain or test fake for key material storage.
41    pub key_storage: Arc<dyn KeyStorage + Send + Sync>,
42    /// Passphrase provider for key decryption during signing operations.
43    pub passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
44    /// Agent-based signing port for delegating operations to a running agent process.
45    pub agent_signing: Arc<dyn AgentSigningPort + Send + Sync>,
46}
47
48impl From<&crate::context::AuthsContext> for CommitSigningContext {
49    fn from(ctx: &crate::context::AuthsContext) -> Self {
50        Self {
51            key_storage: ctx.key_storage.clone(),
52            passphrase_provider: ctx.passphrase_provider.clone(),
53            agent_signing: ctx.agent_signing.clone(),
54        }
55    }
56}
57
58/// Parameters for a commit signing operation.
59///
60/// Args:
61/// * `key_alias`: The keychain alias identifying the signing key.
62/// * `namespace`: The SSHSIG namespace (typically `"git"`).
63/// * `data`: The raw bytes to sign (commit or tag content).
64/// * `pubkey`: Cached Ed25519 public key bytes for agent signing.
65/// * `repo_path`: Optional path to the auths repository for freeze validation.
66/// * `max_passphrase_attempts`: Maximum passphrase retry attempts (default 3).
67///
68/// Usage:
69/// ```ignore
70/// let params = CommitSigningParams::new("my-key", "git", commit_bytes)
71///     .with_pubkey(cached_pubkey)
72///     .with_repo_path(repo_path);
73/// ```
74pub struct CommitSigningParams {
75    /// Keychain alias for the signing key.
76    pub key_alias: String,
77    /// SSHSIG namespace (e.g. `"git"`).
78    pub namespace: String,
79    /// Raw bytes to sign.
80    pub data: Vec<u8>,
81    /// Cached public key for agent signing.
82    pub pubkey: Option<auths_verifier::DevicePublicKey>,
83    /// Optional auths repository path for freeze validation.
84    pub repo_path: Option<PathBuf>,
85    /// Maximum number of passphrase attempts before returning `PassphraseExhausted`.
86    pub max_passphrase_attempts: usize,
87}
88
89impl CommitSigningParams {
90    /// Create signing params with required fields.
91    ///
92    /// Args:
93    /// * `key_alias`: The keychain alias for the signing key.
94    /// * `namespace`: The SSHSIG namespace.
95    /// * `data`: The raw bytes to sign.
96    pub fn new(key_alias: impl Into<String>, namespace: impl Into<String>, data: Vec<u8>) -> Self {
97        Self {
98            key_alias: key_alias.into(),
99            namespace: namespace.into(),
100            data,
101            pubkey: None,
102            repo_path: None,
103            max_passphrase_attempts: DEFAULT_MAX_PASSPHRASE_ATTEMPTS,
104        }
105    }
106
107    /// Set the cached public key for agent signing.
108    pub fn with_pubkey(mut self, pubkey: auths_verifier::DevicePublicKey) -> Self {
109        self.pubkey = Some(pubkey);
110        self
111    }
112
113    /// Set the auths repository path for freeze validation.
114    pub fn with_repo_path(mut self, path: PathBuf) -> Self {
115        self.repo_path = Some(path);
116        self
117    }
118
119    /// Set the maximum number of passphrase attempts.
120    pub fn with_max_passphrase_attempts(mut self, max: usize) -> Self {
121        self.max_passphrase_attempts = max;
122        self
123    }
124}
125
126/// Commit signing workflow with three-tier fallback.
127///
128/// Tier 1: Agent signing (no passphrase needed).
129/// Tier 2: Auto-start agent, decrypt key, load into agent, then direct sign.
130/// Tier 3: Direct signing with decrypted seed.
131///
132/// Args:
133/// * `ctx`: Signing dependencies (keychain, passphrase provider, agent port).
134/// * `params`: Signing parameters.
135/// * `now`: Wall-clock time for freeze validation.
136///
137/// Usage:
138/// ```ignore
139/// let params = CommitSigningParams::new("my-key", "git", data);
140/// let pem = CommitSigningWorkflow::execute(&ctx, params, Utc::now())?;
141/// ```
142pub struct CommitSigningWorkflow;
143
144impl CommitSigningWorkflow {
145    /// Execute the three-tier commit signing flow.
146    ///
147    /// Args:
148    /// * `ctx`: Signing dependencies providing keychain, passphrase provider, and agent port.
149    /// * `params`: Commit signing parameters.
150    /// * `now`: Current wall-clock time for freeze validation.
151    pub fn execute(
152        ctx: &CommitSigningContext,
153        params: CommitSigningParams,
154        now: DateTime<Utc>,
155    ) -> Result<String, SigningError> {
156        // Tier 1: try agent signing
157        match try_agent_sign(ctx, &params) {
158            Ok(pem) => return Ok(pem),
159            Err(SigningError::AgentUnavailable(_)) => {}
160            Err(e) => return Err(e),
161        }
162
163        // Tier 2: auto-start agent + decrypt key + load into agent + direct sign
164        let _ = ctx.agent_signing.ensure_running();
165
166        // Hardware backends (Secure Enclave): sign directly via keychain, skip decrypt
167        // MIRROR: keep in sync with domains/signing/service.rs (SE branch)
168        if ctx.key_storage.is_hardware_backend() {
169            if let Some(ref repo_path) = params.repo_path {
170                signing::validate_freeze_state(repo_path, now)?;
171            }
172
173            let alias = KeyAlias::new_unchecked(&params.key_alias);
174
175            // Build SSHSIG signed data (the message SSH signs over)
176            let sshsig_data = ssh::construct_sshsig_signed_data(&params.data, &params.namespace)
177                .map_err(|e| SigningError::PemEncoding(e.to_string()))?;
178
179            // Sign the SSHSIG data with hardware key (Touch ID)
180            let (sig, pubkey, curve) = auths_core::storage::keychain::sign_with_key(
181                ctx.key_storage.as_ref(),
182                &alias,
183                ctx.passphrase_provider.as_ref(),
184                &sshsig_data,
185            )
186            .map_err(|e| SigningError::KeyDecryptionFailed(e.to_string()))?;
187
188            // Build the SSHSIG PEM from raw pubkey + signature
189            return ssh::construct_sshsig_pem(&pubkey, &sig, &params.namespace, curve)
190                .map_err(|e| SigningError::PemEncoding(e.to_string()));
191        }
192
193        let pkcs8 = load_key_with_passphrase_retry(ctx, &params)?;
194        let (seed, _pubkey, curve) =
195            auths_core::crypto::signer::load_seed_and_pubkey(pkcs8.as_ref())
196                .map_err(|e| SigningError::KeyDecryptionFailed(e.to_string()))?;
197
198        // Best-effort: load identity into agent for future Tier 1 hits
199        let _ = ctx
200            .agent_signing
201            .add_identity(&params.namespace, pkcs8.as_ref());
202
203        // Tier 3: direct sign
204        direct_sign(&params, &seed, now, curve)
205    }
206}
207
208fn try_agent_sign(
209    ctx: &CommitSigningContext,
210    params: &CommitSigningParams,
211) -> Result<String, SigningError> {
212    let pubkey = params.pubkey.as_ref().ok_or_else(|| {
213        SigningError::AgentUnavailable("no cached public key for agent signing".into())
214    })?;
215    ctx.agent_signing
216        .try_sign(&params.namespace, pubkey, &params.data)
217        .map_err(|e| match e {
218            AgentSigningError::Unavailable(msg) | AgentSigningError::ConnectionFailed(msg) => {
219                SigningError::AgentUnavailable(msg)
220            }
221            other => SigningError::AgentSigningFailed(other),
222        })
223}
224
225/// Map a keychain `load_key` failure onto the right signing error.
226///
227/// A missing alias is not a broken keychain: it gets its own error so the fix
228/// points at `auths key list`, not the keychain subsystem. Every other failure
229/// means the keychain itself could not be accessed.
230///
231/// Args:
232/// * `err`: the error `KeyStorage::load_key` returned.
233/// * `alias`: the alias the caller asked for.
234///
235/// Usage:
236/// ```ignore
237/// let e = classify_load_key_error(AgentError::KeyNotFound, "main");
238/// ```
239fn classify_load_key_error(err: AgentError, alias: &str) -> SigningError {
240    match err {
241        AgentError::KeyNotFound => SigningError::KeyNotFound {
242            alias: alias.to_string(),
243        },
244        other => SigningError::KeychainUnavailable(other.to_string()),
245    }
246}
247
248fn load_key_with_passphrase_retry(
249    ctx: &CommitSigningContext,
250    params: &CommitSigningParams,
251) -> Result<Pkcs8Der, SigningError> {
252    let alias = KeyAlias::new_unchecked(&params.key_alias);
253    let (_identity_did, _role, encrypted_data) = ctx
254        .key_storage
255        .load_key(&alias)
256        .map_err(|e| classify_load_key_error(e, &params.key_alias))?;
257
258    let prompt = format!("Enter passphrase for '{}':", params.key_alias);
259
260    for attempt in 1..=params.max_passphrase_attempts {
261        let passphrase = ctx
262            .passphrase_provider
263            .get_passphrase(&prompt)
264            .map_err(|e| SigningError::KeyDecryptionFailed(e.to_string()))?;
265
266        match decrypt_keypair(&encrypted_data, &passphrase) {
267            Ok(decrypted) => return Ok(Pkcs8Der::new(&decrypted[..])),
268            Err(AgentError::IncorrectPassphrase) => {
269                if attempt < params.max_passphrase_attempts {
270                    ctx.passphrase_provider.on_incorrect_passphrase(&prompt);
271                }
272            }
273            Err(e) => return Err(SigningError::KeyDecryptionFailed(e.to_string())),
274        }
275    }
276
277    Err(SigningError::PassphraseExhausted {
278        attempts: params.max_passphrase_attempts,
279    })
280}
281
282fn direct_sign(
283    params: &CommitSigningParams,
284    seed: &SecureSeed,
285    now: DateTime<Utc>,
286    curve: auths_crypto::CurveType,
287) -> Result<String, SigningError> {
288    if let Some(ref repo_path) = params.repo_path {
289        signing::validate_freeze_state(repo_path, now)?;
290    }
291
292    signing::sign_with_seed(seed, &params.data, &params.namespace, curve)
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use auths_core::error::AuthsErrorInfo;
299
300    #[test]
301    fn missing_alias_is_key_not_found_not_keychain_unavailable() {
302        // A nonexistent alias must surface as KeyNotFound (AUTHS-E5911) so the fix
303        // names `auths key list` — not the healthy keychain (AUTHS-E5909).
304        let err = classify_load_key_error(AgentError::KeyNotFound, "nonexistent");
305        assert!(matches!(err, SigningError::KeyNotFound { .. }));
306        assert_eq!(err.error_code(), "AUTHS-E5911");
307        assert!(
308            err.suggestion()
309                .unwrap_or_default()
310                .contains("auths key list"),
311            "E5911 must point at `auths key list`"
312        );
313    }
314
315    #[test]
316    fn other_load_failures_stay_keychain_unavailable() {
317        let err = classify_load_key_error(AgentError::StorageLocked, "main");
318        assert!(matches!(err, SigningError::KeychainUnavailable(_)));
319        assert_eq!(err.error_code(), "AUTHS-E5909");
320    }
321}