Skip to main content

vyn_core/
keychain.rs

1use keyring::Entry;
2use secrecy::ExposeSecret;
3use thiserror::Error;
4
5use crate::crypto::SecretBytes;
6
7const SERVICE_NAME: &str = "vyn";
8const PROJECT_KEY_LEN: usize = 32;
9
10#[derive(Debug, Error)]
11pub enum KeychainError {
12    #[error("project key must be 32 bytes")]
13    InvalidKeyLength,
14    #[error("failed to encode key")]
15    EncodingFailure,
16    #[error("failed to decode key")]
17    DecodingFailure,
18    #[error("keychain operation failed: {0}")]
19    Keychain(#[from] keyring::Error),
20}
21
22pub fn account_for_vault(vault_id: &str) -> String {
23    format!("vault_{vault_id}")
24}
25
26pub fn store_project_key(vault_id: &str, key: &SecretBytes) -> Result<(), KeychainError> {
27    if key.expose_secret().len() != PROJECT_KEY_LEN {
28        return Err(KeychainError::InvalidKeyLength);
29    }
30
31    let account = account_for_vault(vault_id);
32    let entry = Entry::new(SERVICE_NAME, &account)?;
33    let encoded = hex_encode(key.expose_secret())?;
34    entry.set_password(&encoded)?;
35
36    Ok(())
37}
38
39pub fn load_project_key(vault_id: &str) -> Result<SecretBytes, KeychainError> {
40    let account = account_for_vault(vault_id);
41    let entry = Entry::new(SERVICE_NAME, &account)?;
42    let encoded = entry.get_password()?;
43    let decoded = hex_decode(&encoded)?;
44
45    if decoded.len() != PROJECT_KEY_LEN {
46        return Err(KeychainError::InvalidKeyLength);
47    }
48
49    Ok(SecretBytes::new(decoded.into_boxed_slice()))
50}
51
52#[cfg(test)]
53pub fn delete_project_key(vault_id: &str) -> Result<(), KeychainError> {
54    let account = account_for_vault(vault_id);
55    let entry = Entry::new(SERVICE_NAME, &account)?;
56
57    match entry.delete_credential() {
58        Ok(()) => Ok(()),
59        Err(keyring::Error::NoEntry) => Ok(()),
60        Err(err) => Err(KeychainError::Keychain(err)),
61    }
62}
63
64fn hex_encode(bytes: &[u8]) -> Result<String, KeychainError> {
65    let mut output = String::with_capacity(bytes.len() * 2);
66
67    for byte in bytes {
68        use core::fmt::Write;
69        write!(&mut output, "{byte:02x}").map_err(|_| KeychainError::EncodingFailure)?;
70    }
71
72    Ok(output)
73}
74
75fn hex_decode(input: &str) -> Result<Vec<u8>, KeychainError> {
76    if !input.len().is_multiple_of(2) {
77        return Err(KeychainError::DecodingFailure);
78    }
79
80    let mut output = Vec::with_capacity(input.len() / 2);
81    let mut idx = 0;
82    while idx < input.len() {
83        let hi = hex_nibble(input.as_bytes()[idx]).ok_or(KeychainError::DecodingFailure)?;
84        let lo = hex_nibble(input.as_bytes()[idx + 1]).ok_or(KeychainError::DecodingFailure)?;
85        output.push((hi << 4) | lo);
86        idx += 2;
87    }
88
89    Ok(output)
90}
91
92fn hex_nibble(byte: u8) -> Option<u8> {
93    match byte {
94        b'0'..=b'9' => Some(byte - b'0'),
95        b'a'..=b'f' => Some(byte - b'a' + 10),
96        b'A'..=b'F' => Some(byte - b'A' + 10),
97        _ => None,
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::{delete_project_key, load_project_key, store_project_key};
104    use keyring::credential::{
105        Credential, CredentialApi, CredentialBuilderApi, CredentialPersistence,
106    };
107    use keyring::{Error as KeyringError, set_default_credential_builder};
108    use secrecy::{ExposeSecret, SecretBox};
109    use std::any::Any;
110    use std::collections::HashMap;
111    use std::sync::Once;
112    use std::sync::{Arc, Mutex};
113    use uuid::Uuid;
114
115    static INIT_MOCK_KEYRING: Once = Once::new();
116
117    fn ensure_mock_keyring() {
118        INIT_MOCK_KEYRING.call_once(|| {
119            let shared = Arc::new(Mutex::new(HashMap::<String, Vec<u8>>::new()));
120            set_default_credential_builder(Box::new(PersistentMockBuilder { shared }));
121        });
122    }
123
124    #[derive(Debug)]
125    struct PersistentMockBuilder {
126        shared: Arc<Mutex<HashMap<String, Vec<u8>>>>,
127    }
128
129    impl CredentialBuilderApi for PersistentMockBuilder {
130        fn build(
131            &self,
132            target: Option<&str>,
133            service: &str,
134            user: &str,
135        ) -> keyring::Result<Box<Credential>> {
136            let key = format!("{}::{service}::{user}", target.unwrap_or_default());
137            Ok(Box::new(PersistentMockCredential {
138                shared: Arc::clone(&self.shared),
139                key,
140            }))
141        }
142
143        fn as_any(&self) -> &dyn Any {
144            self
145        }
146
147        fn persistence(&self) -> CredentialPersistence {
148            CredentialPersistence::ProcessOnly
149        }
150    }
151
152    #[derive(Debug)]
153    struct PersistentMockCredential {
154        shared: Arc<Mutex<HashMap<String, Vec<u8>>>>,
155        key: String,
156    }
157
158    impl CredentialApi for PersistentMockCredential {
159        fn set_secret(&self, secret: &[u8]) -> keyring::Result<()> {
160            let mut guard = self.shared.lock().map_err(|_| {
161                KeyringError::PlatformFailure("mock store poisoned".to_string().into())
162            })?;
163            guard.insert(self.key.clone(), secret.to_vec());
164            Ok(())
165        }
166
167        fn get_secret(&self) -> keyring::Result<Vec<u8>> {
168            let guard = self.shared.lock().map_err(|_| {
169                KeyringError::PlatformFailure("mock store poisoned".to_string().into())
170            })?;
171            guard.get(&self.key).cloned().ok_or(KeyringError::NoEntry)
172        }
173
174        fn delete_credential(&self) -> keyring::Result<()> {
175            let mut guard = self.shared.lock().map_err(|_| {
176                KeyringError::PlatformFailure("mock store poisoned".to_string().into())
177            })?;
178            match guard.remove(&self.key) {
179                Some(_) => Ok(()),
180                None => Err(KeyringError::NoEntry),
181            }
182        }
183
184        fn as_any(&self) -> &dyn Any {
185            self
186        }
187    }
188
189    #[test]
190    fn keychain_persistence() {
191        ensure_mock_keyring();
192
193        let vault_id = Uuid::new_v4().to_string();
194        let key = SecretBox::new(vec![7u8; 32].into_boxed_slice());
195
196        store_project_key(&vault_id, &key).expect("store should succeed");
197        let loaded = load_project_key(&vault_id).expect("load should succeed");
198        assert_eq!(loaded.expose_secret(), key.expose_secret());
199
200        delete_project_key(&vault_id).expect("cleanup should succeed");
201    }
202}