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};
11use zeroize::Zeroizing;
12
13use auths_core::AgentError;
14use auths_core::crypto::signer::decrypt_keypair;
15use auths_core::crypto::ssh::{SecureSeed, extract_seed_from_pkcs8};
16use auths_core::signing::PassphraseProvider;
17use auths_core::storage::keychain::{KeyAlias, KeyStorage};
18
19use crate::ports::agent::{AgentSigningError, AgentSigningPort};
20use crate::signing::{self, SigningError};
21
22const DEFAULT_MAX_PASSPHRASE_ATTEMPTS: usize = 3;
23
24/// Minimal dependency set for the commit signing workflow.
25///
26/// Avoids requiring the full [`AuthsContext`](crate::context::AuthsContext)
27/// when only signing-related ports are needed (e.g. in the `auths-sign` binary).
28///
29/// Usage:
30/// ```ignore
31/// let deps = CommitSigningContext {
32///     key_storage: Arc::from(keychain),
33///     passphrase_provider: Arc::new(my_provider),
34///     agent_signing: Arc::new(my_agent),
35/// };
36/// CommitSigningWorkflow::execute(&deps, params, Utc::now())?;
37/// ```
38pub struct CommitSigningContext {
39    /// Platform keychain or test fake for key material storage.
40    pub key_storage: Arc<dyn KeyStorage + Send + Sync>,
41    /// Passphrase provider for key decryption during signing operations.
42    pub passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
43    /// Agent-based signing port for delegating operations to a running agent process.
44    pub agent_signing: Arc<dyn AgentSigningPort + Send + Sync>,
45}
46
47impl From<&crate::context::AuthsContext> for CommitSigningContext {
48    fn from(ctx: &crate::context::AuthsContext) -> Self {
49        Self {
50            key_storage: ctx.key_storage.clone(),
51            passphrase_provider: ctx.passphrase_provider.clone(),
52            agent_signing: ctx.agent_signing.clone(),
53        }
54    }
55}
56
57/// Parameters for a commit signing operation.
58///
59/// Args:
60/// * `key_alias`: The keychain alias identifying the signing key.
61/// * `namespace`: The SSHSIG namespace (typically `"git"`).
62/// * `data`: The raw bytes to sign (commit or tag content).
63/// * `pubkey`: Cached Ed25519 public key bytes for agent signing.
64/// * `repo_path`: Optional path to the auths repository for freeze validation.
65/// * `max_passphrase_attempts`: Maximum passphrase retry attempts (default 3).
66///
67/// Usage:
68/// ```ignore
69/// let params = CommitSigningParams::new("my-key", "git", commit_bytes)
70///     .with_pubkey(cached_pubkey)
71///     .with_repo_path(repo_path);
72/// ```
73pub struct CommitSigningParams {
74    /// Keychain alias for the signing key.
75    pub key_alias: String,
76    /// SSHSIG namespace (e.g. `"git"`).
77    pub namespace: String,
78    /// Raw bytes to sign.
79    pub data: Vec<u8>,
80    /// Cached Ed25519 public key bytes for agent signing.
81    pub pubkey: Vec<u8>,
82    /// Optional auths repository path for freeze validation.
83    pub repo_path: Option<PathBuf>,
84    /// Maximum number of passphrase attempts before returning `PassphraseExhausted`.
85    pub max_passphrase_attempts: usize,
86}
87
88impl CommitSigningParams {
89    /// Create signing params with required fields.
90    ///
91    /// Args:
92    /// * `key_alias`: The keychain alias for the signing key.
93    /// * `namespace`: The SSHSIG namespace.
94    /// * `data`: The raw bytes to sign.
95    pub fn new(key_alias: impl Into<String>, namespace: impl Into<String>, data: Vec<u8>) -> Self {
96        Self {
97            key_alias: key_alias.into(),
98            namespace: namespace.into(),
99            data,
100            pubkey: Vec::new(),
101            repo_path: None,
102            max_passphrase_attempts: DEFAULT_MAX_PASSPHRASE_ATTEMPTS,
103        }
104    }
105
106    /// Set the cached public key for agent signing.
107    pub fn with_pubkey(mut self, pubkey: Vec<u8>) -> Self {
108        self.pubkey = pubkey;
109        self
110    }
111
112    /// Set the auths repository path for freeze validation.
113    pub fn with_repo_path(mut self, path: PathBuf) -> Self {
114        self.repo_path = Some(path);
115        self
116    }
117
118    /// Set the maximum number of passphrase attempts.
119    pub fn with_max_passphrase_attempts(mut self, max: usize) -> Self {
120        self.max_passphrase_attempts = max;
121        self
122    }
123}
124
125/// Commit signing workflow with three-tier fallback.
126///
127/// Tier 1: Agent signing (no passphrase needed).
128/// Tier 2: Auto-start agent, decrypt key, load into agent, then direct sign.
129/// Tier 3: Direct signing with decrypted seed.
130///
131/// Args:
132/// * `ctx`: Signing dependencies (keychain, passphrase provider, agent port).
133/// * `params`: Signing parameters.
134/// * `now`: Wall-clock time for freeze validation.
135///
136/// Usage:
137/// ```ignore
138/// let params = CommitSigningParams::new("my-key", "git", data);
139/// let pem = CommitSigningWorkflow::execute(&ctx, params, Utc::now())?;
140/// ```
141pub struct CommitSigningWorkflow;
142
143impl CommitSigningWorkflow {
144    /// Execute the three-tier commit signing flow.
145    ///
146    /// Args:
147    /// * `ctx`: Signing dependencies providing keychain, passphrase provider, and agent port.
148    /// * `params`: Commit signing parameters.
149    /// * `now`: Current wall-clock time for freeze validation.
150    pub fn execute(
151        ctx: &CommitSigningContext,
152        params: CommitSigningParams,
153        now: DateTime<Utc>,
154    ) -> Result<String, SigningError> {
155        // Tier 1: try agent signing
156        match try_agent_sign(ctx, &params) {
157            Ok(pem) => return Ok(pem),
158            Err(SigningError::AgentUnavailable(_)) => {}
159            Err(e) => return Err(e),
160        }
161
162        // Tier 2: auto-start agent + decrypt key + load into agent + direct sign
163        let _ = ctx.agent_signing.ensure_running();
164
165        let pkcs8_der = load_key_with_passphrase_retry(ctx, &params)?;
166        let seed = extract_seed_from_pkcs8(&pkcs8_der)
167            .map_err(|e| SigningError::KeyDecryptionFailed(e.to_string()))?;
168
169        // Best-effort: load identity into agent for future Tier 1 hits
170        let _ = ctx
171            .agent_signing
172            .add_identity(&params.namespace, &pkcs8_der);
173
174        // Tier 3: direct sign
175        direct_sign(&params, &seed, now)
176    }
177}
178
179fn try_agent_sign(
180    ctx: &CommitSigningContext,
181    params: &CommitSigningParams,
182) -> Result<String, SigningError> {
183    ctx.agent_signing
184        .try_sign(&params.namespace, &params.pubkey, &params.data)
185        .map_err(|e| match e {
186            AgentSigningError::Unavailable(msg) | AgentSigningError::ConnectionFailed(msg) => {
187                SigningError::AgentUnavailable(msg)
188            }
189            other => SigningError::AgentSigningFailed(other),
190        })
191}
192
193fn load_key_with_passphrase_retry(
194    ctx: &CommitSigningContext,
195    params: &CommitSigningParams,
196) -> Result<Zeroizing<Vec<u8>>, SigningError> {
197    let alias = KeyAlias::new_unchecked(&params.key_alias);
198    let (_identity_did, encrypted_data) = ctx
199        .key_storage
200        .load_key(&alias)
201        .map_err(|e| SigningError::KeychainUnavailable(e.to_string()))?;
202
203    let prompt = format!("Enter passphrase for '{}':", params.key_alias);
204
205    for attempt in 1..=params.max_passphrase_attempts {
206        let passphrase = ctx
207            .passphrase_provider
208            .get_passphrase(&prompt)
209            .map_err(|e| SigningError::KeyDecryptionFailed(e.to_string()))?;
210
211        match decrypt_keypair(&encrypted_data, &passphrase) {
212            Ok(decrypted) => return Ok(decrypted),
213            Err(AgentError::IncorrectPassphrase) => {
214                if attempt < params.max_passphrase_attempts {
215                    ctx.passphrase_provider.on_incorrect_passphrase(&prompt);
216                }
217            }
218            Err(e) => return Err(SigningError::KeyDecryptionFailed(e.to_string())),
219        }
220    }
221
222    Err(SigningError::PassphraseExhausted {
223        attempts: params.max_passphrase_attempts,
224    })
225}
226
227fn direct_sign(
228    params: &CommitSigningParams,
229    seed: &SecureSeed,
230    now: DateTime<Utc>,
231) -> Result<String, SigningError> {
232    if let Some(ref repo_path) = params.repo_path {
233        signing::validate_freeze_state(repo_path, now)?;
234    }
235
236    signing::sign_with_seed(seed, &params.data, &params.namespace)
237}