Skip to content

Commit 3253bb3

Browse files
authored
Merge pull request #23 from auths-dev/feat/wire-keychain-passphrase-cache
feat: wire OS keychain passphrase cache into signing flow
2 parents f2241f2 + c801d6b commit 3253bb3

5 files changed

Lines changed: 136 additions & 12 deletions

File tree

crates/auths-cli/src/bin/sign.rs

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@ use clap::Parser;
3131

3232
use auths_cli::core::pubkey_cache::get_cached_pubkey;
3333
use auths_cli::factories::build_agent_provider;
34-
use auths_core::config::EnvironmentConfig;
35-
use auths_core::signing::{CachedPassphraseProvider, PassphraseProvider};
34+
use auths_core::config::{EnvironmentConfig, load_config};
35+
use auths_core::signing::{KeychainPassphraseProvider, PassphraseProvider};
3636
use auths_core::storage::keychain::get_platform_keychain;
37+
use auths_core::storage::passphrase_cache::{get_passphrase_cache, parse_duration_str};
3738
use auths_sdk::workflows::signing::{
3839
CommitSigningContext, CommitSigningParams, CommitSigningWorkflow,
3940
};
@@ -101,7 +102,7 @@ fn parse_key_identifier(key_file: &str) -> Result<String> {
101102
}
102103
}
103104

104-
fn build_signing_context() -> Result<CommitSigningContext> {
105+
fn build_signing_context(alias: &str) -> Result<CommitSigningContext> {
105106
let env_config = EnvironmentConfig::from_env();
106107

107108
let keychain =
@@ -111,10 +112,20 @@ fn build_signing_context() -> Result<CommitSigningContext> {
111112
if let Some(passphrase) = env_config.keychain.passphrase.clone() {
112113
Arc::new(auths_core::PrefilledPassphraseProvider::new(&passphrase))
113114
} else {
115+
let config = load_config();
116+
let cache = get_passphrase_cache(config.passphrase.biometric);
117+
let ttl_secs = config
118+
.passphrase
119+
.duration
120+
.as_deref()
121+
.and_then(parse_duration_str);
114122
let inner = Arc::new(auths_cli::core::provider::CliPassphraseProvider::new());
115-
Arc::new(CachedPassphraseProvider::new(
123+
Arc::new(KeychainPassphraseProvider::new(
116124
inner,
117-
std::time::Duration::from_secs(3600),
125+
cache,
126+
alias.to_string(),
127+
config.passphrase.cache,
128+
ttl_secs,
118129
))
119130
};
120131

@@ -243,7 +254,7 @@ fn run_sign(args: &Args) -> Result<()> {
243254

244255
let repo_path = auths_id::storage::layout::resolve_repo_path(None).ok();
245256

246-
let ctx = build_signing_context()?;
257+
let ctx = build_signing_context(&alias)?;
247258
let mut params = CommitSigningParams::new(&alias, namespace, data).with_pubkey(pubkey);
248259
if let Some(path) = repo_path {
249260
params = params.with_repo_path(path);

crates/auths-core/src/config.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,8 @@ fn default_biometric() -> bool {
217217
impl Default for PassphraseConfig {
218218
fn default() -> Self {
219219
Self {
220-
cache: PassphraseCachePolicy::default(),
221-
duration: None,
220+
cache: PassphraseCachePolicy::Duration,
221+
duration: Some("1h".to_string()),
222222
biometric: default_biometric(),
223223
}
224224
}

crates/auths-core/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,4 @@ pub use agent::{
8282
};
8383
pub use crypto::{EncryptionAlgorithm, SignerKey};
8484
pub use error::{AgentError, AuthsErrorInfo};
85-
pub use signing::PrefilledPassphraseProvider;
85+
pub use signing::{KeychainPassphraseProvider, PrefilledPassphraseProvider};

crates/auths-core/src/signing.rs

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@ use crate::crypto::signer::{decrypt_keypair, extract_seed_from_key_bytes};
77
use crate::error::AgentError;
88
use crate::storage::keychain::{IdentityDID, KeyAlias, KeyStorage};
99

10+
use crate::config::PassphraseCachePolicy;
11+
use crate::storage::passphrase_cache::PassphraseCache;
12+
1013
use std::collections::HashMap;
1114
use std::sync::{Arc, Mutex};
12-
use std::time::{Duration, Instant};
15+
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
1316
use zeroize::Zeroizing;
1417

1518
/// Type alias for passphrase callback functions.
@@ -428,6 +431,116 @@ impl PassphraseProvider for CachedPassphraseProvider {
428431
}
429432
}
430433

434+
/// A `PassphraseProvider` that wraps an inner provider with OS keychain caching.
435+
///
436+
/// On `get_passphrase()`, checks the OS keychain first via `PassphraseCache::load`.
437+
/// If a cached value exists and hasn't expired per the configured policy/TTL,
438+
/// returns it immediately. Otherwise delegates to the inner provider, then
439+
/// stores the result in the OS keychain for subsequent invocations.
440+
///
441+
/// Args:
442+
/// * `inner`: The underlying provider to prompt the user when cache misses.
443+
/// * `cache`: Platform keychain cache (macOS Security Framework, Linux Secret Service, etc.).
444+
/// * `alias`: Key alias used as the cache key in the OS keychain.
445+
/// * `policy`: The configured `PassphraseCachePolicy`.
446+
/// * `ttl_secs`: Optional TTL in seconds (for `Duration` policy).
447+
///
448+
/// Usage:
449+
/// ```ignore
450+
/// use auths_core::signing::{KeychainPassphraseProvider, PassphraseProvider};
451+
/// use auths_core::config::PassphraseCachePolicy;
452+
/// use auths_core::storage::passphrase_cache::get_passphrase_cache;
453+
///
454+
/// let inner = Arc::new(some_provider);
455+
/// let cache = get_passphrase_cache(true);
456+
/// let provider = KeychainPassphraseProvider::new(
457+
/// inner, cache, "main".to_string(),
458+
/// PassphraseCachePolicy::Duration, Some(3600),
459+
/// );
460+
/// let passphrase = provider.get_passphrase("Enter passphrase:")?;
461+
/// ```
462+
pub struct KeychainPassphraseProvider {
463+
inner: Arc<dyn PassphraseProvider + Send + Sync>,
464+
cache: Box<dyn PassphraseCache>,
465+
alias: String,
466+
policy: PassphraseCachePolicy,
467+
ttl_secs: Option<i64>,
468+
}
469+
470+
impl KeychainPassphraseProvider {
471+
/// Creates a new `KeychainPassphraseProvider`.
472+
///
473+
/// Args:
474+
/// * `inner`: Fallback provider for cache misses.
475+
/// * `cache`: OS keychain cache implementation.
476+
/// * `alias`: Key alias used as the keychain entry identifier.
477+
/// * `policy`: Caching policy controlling storage/expiry behavior.
478+
/// * `ttl_secs`: TTL in seconds when `policy` is `Duration`.
479+
pub fn new(
480+
inner: Arc<dyn PassphraseProvider + Send + Sync>,
481+
cache: Box<dyn PassphraseCache>,
482+
alias: String,
483+
policy: PassphraseCachePolicy,
484+
ttl_secs: Option<i64>,
485+
) -> Self {
486+
Self {
487+
inner,
488+
cache,
489+
alias,
490+
policy,
491+
ttl_secs,
492+
}
493+
}
494+
495+
fn is_expired(&self, stored_at_unix: i64) -> bool {
496+
match self.policy {
497+
PassphraseCachePolicy::Always => false,
498+
PassphraseCachePolicy::Never => true,
499+
PassphraseCachePolicy::Session => true,
500+
PassphraseCachePolicy::Duration => {
501+
let ttl = self.ttl_secs.unwrap_or(3600);
502+
let now = SystemTime::now()
503+
.duration_since(UNIX_EPOCH)
504+
.unwrap_or_default()
505+
.as_secs() as i64;
506+
now - stored_at_unix > ttl
507+
}
508+
}
509+
}
510+
}
511+
512+
impl PassphraseProvider for KeychainPassphraseProvider {
513+
fn get_passphrase(&self, prompt_message: &str) -> Result<Zeroizing<String>, AgentError> {
514+
if self.policy != PassphraseCachePolicy::Never
515+
&& let Ok(Some((passphrase, stored_at))) = self.cache.load(&self.alias)
516+
{
517+
if !self.is_expired(stored_at) {
518+
return Ok(passphrase);
519+
}
520+
let _ = self.cache.delete(&self.alias);
521+
}
522+
523+
let passphrase = self.inner.get_passphrase(prompt_message)?;
524+
525+
if self.policy != PassphraseCachePolicy::Never
526+
&& self.policy != PassphraseCachePolicy::Session
527+
{
528+
let now = SystemTime::now()
529+
.duration_since(UNIX_EPOCH)
530+
.unwrap_or_default()
531+
.as_secs() as i64;
532+
let _ = self.cache.store(&self.alias, &passphrase, now);
533+
}
534+
535+
Ok(passphrase)
536+
}
537+
538+
fn on_incorrect_passphrase(&self, prompt_message: &str) {
539+
let _ = self.cache.delete(&self.alias);
540+
self.inner.on_incorrect_passphrase(prompt_message);
541+
}
542+
}
543+
431544
/// Provides a pre-collected passphrase for headless and automated environments.
432545
///
433546
/// Unlike [`CallbackPassphraseProvider`] which prompts interactively, this provider

scripts/releases/1_github.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
What it does:
1010
1. Reads the version from [workspace.package] in Cargo.toml
1111
2. Checks crates.io to make sure the version has been bumped
12-
3. Checks that the git tag doesn't already exist
13-
4. Creates a git tag v{version} and pushes it to origin
12+
3. Checks that the git tag doesn't already exist on GitHub
13+
4. Creates a git tag v{version} and pushes it to origin on GitHub
1414
1515
Requires:
1616
- python3 (no external dependencies)

0 commit comments

Comments
 (0)