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}