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
225fn load_key_with_passphrase_retry(
226    ctx: &CommitSigningContext,
227    params: &CommitSigningParams,
228) -> Result<Pkcs8Der, SigningError> {
229    let alias = KeyAlias::new_unchecked(&params.key_alias);
230    let (_identity_did, _role, encrypted_data) = ctx
231        .key_storage
232        .load_key(&alias)
233        .map_err(|e| SigningError::KeychainUnavailable(e.to_string()))?;
234
235    let prompt = format!("Enter passphrase for '{}':", params.key_alias);
236
237    for attempt in 1..=params.max_passphrase_attempts {
238        let passphrase = ctx
239            .passphrase_provider
240            .get_passphrase(&prompt)
241            .map_err(|e| SigningError::KeyDecryptionFailed(e.to_string()))?;
242
243        match decrypt_keypair(&encrypted_data, &passphrase) {
244            Ok(decrypted) => return Ok(Pkcs8Der::new(&decrypted[..])),
245            Err(AgentError::IncorrectPassphrase) => {
246                if attempt < params.max_passphrase_attempts {
247                    ctx.passphrase_provider.on_incorrect_passphrase(&prompt);
248                }
249            }
250            Err(e) => return Err(SigningError::KeyDecryptionFailed(e.to_string())),
251        }
252    }
253
254    Err(SigningError::PassphraseExhausted {
255        attempts: params.max_passphrase_attempts,
256    })
257}
258
259fn direct_sign(
260    params: &CommitSigningParams,
261    seed: &SecureSeed,
262    now: DateTime<Utc>,
263    curve: auths_crypto::CurveType,
264) -> Result<String, SigningError> {
265    if let Some(ref repo_path) = params.repo_path {
266        signing::validate_freeze_state(repo_path, now)?;
267    }
268
269    signing::sign_with_seed(seed, &params.data, &params.namespace, curve)
270}