agentmux_srv\identity/
secret_store.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! OS-native secret storage for Trust Center API keys.
5//!
6//! Backs `SecretRef::Keychain { service, account }`. The plaintext key
7//! lives only in the OS keychain (macOS Keychain / Windows Credential
8//! Manager / Linux Secret Service via the `keyring` crate); the DB holds
9//! just the pointer plus non-secret metadata + a masked tail. Reads return
10//! a `Zeroizing<String>` so the plaintext is wiped from memory on drop.
11//!
12//! See specs/SPEC_TRUST_CENTER_2026_06_15.md §7 (best practices) and §12.2.
13//!
14//! NOTE: an encrypted-file fallback for headless Linux without a Secret
15//! Service agent is a documented follow-up (spec §12.2); the desktop app
16//! ships with a keychain on all three platforms, so keyring is the path
17//! here. Failures surface as a typed error rather than a silent downgrade.
18
19use keyring::Entry;
20use zeroize::Zeroizing;
21
22/// Keychain service string — constant across all AgentMux secrets.
23pub const SERVICE: &str = "agentmux";
24
25/// Build the keychain account string for an identity-account id.
26pub fn account_key(account_id: &str) -> String {
27    format!("acct:{account_id}")
28}
29
30fn entry(account_id: &str) -> Result<Entry, String> {
31    Entry::new(SERVICE, &account_key(account_id))
32        .map_err(|e| format!("keychain entry init failed: {e}"))
33}
34
35/// Store (or overwrite) the secret for `account_id` in the OS keychain.
36pub fn put(account_id: &str, secret: &str) -> Result<(), String> {
37    entry(account_id)?
38        .set_password(secret)
39        .map_err(|e| format!("keychain write failed: {e}"))
40}
41
42/// Read the secret for `account_id`. Returned wrapped in `Zeroizing` so it
43/// is wiped on drop. Resolved at agent spawn time when injecting env vars.
44pub fn get(account_id: &str) -> Result<Zeroizing<String>, String> {
45    let pw = entry(account_id)?
46        .get_password()
47        .map_err(|e| format!("keychain read failed: {e}"))?;
48    Ok(Zeroizing::new(pw))
49}
50
51/// Delete the secret for `account_id`. A missing entry is treated as
52/// success (idempotent delete).
53pub fn delete(account_id: &str) -> Result<(), String> {
54    match entry(account_id)?.delete_password() {
55        Ok(()) => Ok(()),
56        Err(keyring::Error::NoEntry) => Ok(()),
57        Err(e) => Err(format!("keychain delete failed: {e}")),
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    #[test]
66    fn account_key_is_namespaced() {
67        assert_eq!(account_key("abc123"), "acct:abc123");
68    }
69}