Skip to main content

vs_store/
auth.rs

1//! Auth blob encryption and master-key resolution.
2//!
3//! Auth blobs are AES-256-GCM ciphertexts of opaque cookie/storage
4//! state. The key is local to the host (encryption is *not* portable
5//! across machines). Resolution order on the daemon:
6//!
7//! 1. OS keyring (`keyring` crate) under service `"vibesurfer"`,
8//!    account `"default"`.
9//! 2. Fallback: a 32-byte file at `~/.vibesurfer/key`.
10//!
11//! Tests skip the keyring (it would prompt the user) and pass keys
12//! explicitly via [`MasterKey::from_bytes`].
13
14use std::fs;
15use std::path::Path;
16
17use ring::aead::{
18    Aad, BoundKey, Nonce, NonceSequence, OpeningKey, SealingKey, UnboundKey, AES_256_GCM,
19};
20use ring::error::Unspecified;
21use ring::rand::{SecureRandom, SystemRandom};
22
23use crate::error::{Result, StoreError};
24
25/// One-time installer for the keyring-core default credential store.
26/// keyring-core 1.x split the per-platform implementations into
27/// separate crates (`apple-native-keyring-store`,
28/// `linux-keyutils-keyring-store`, `windows-native-keyring-store`);
29/// each lib has to register itself before `Entry::new` will work.
30fn ensure_default_keyring_store() -> Result<()> {
31    use std::sync::OnceLock;
32    static INIT: OnceLock<std::result::Result<(), String>> = OnceLock::new();
33    let r = INIT.get_or_init(install_default_store);
34    match r {
35        Ok(()) => Ok(()),
36        Err(msg) => Err(StoreError::Keyring(msg.clone())),
37    }
38}
39
40#[cfg(target_os = "macos")]
41fn install_default_store() -> std::result::Result<(), String> {
42    let store = apple_native_keyring_store::keychain::Store::new().map_err(|e| e.to_string())?;
43    keyring_core::set_default_store(store);
44    Ok(())
45}
46
47#[cfg(target_os = "linux")]
48fn install_default_store() -> std::result::Result<(), String> {
49    let store = linux_keyutils_keyring_store::Store::new().map_err(|e| e.to_string())?;
50    keyring_core::set_default_store(store);
51    Ok(())
52}
53
54#[cfg(target_os = "windows")]
55fn install_default_store() -> std::result::Result<(), String> {
56    let store = windows_native_keyring_store::Store::new().map_err(|e| e.to_string())?;
57    keyring_core::set_default_store(store);
58    Ok(())
59}
60
61#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
62fn install_default_store() -> std::result::Result<(), String> {
63    Err("no native keyring store available for this target".into())
64}
65
66/// Service name used for the OS keyring.
67pub const KEYRING_SERVICE: &str = "vibesurfer";
68/// Account name used for the OS keyring.
69pub const KEYRING_ACCOUNT: &str = "default";
70
71const KEY_LEN: usize = 32; // AES-256 key
72const NONCE_LEN: usize = 12; // GCM nonce
73
74/// A 32-byte AES-256 master key.
75///
76/// Construct via [`MasterKey::from_bytes`], [`MasterKey::from_file`],
77/// [`MasterKey::from_keyring`], [`MasterKey::resolve`], or
78/// [`MasterKey::generate`].
79#[derive(Clone)]
80pub struct MasterKey([u8; KEY_LEN]);
81
82impl std::fmt::Debug for MasterKey {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        // Never print the key bytes; tests that need to inspect equality
85        // can compare via `MasterKey::from_bytes` round-trips.
86        f.write_str("MasterKey([REDACTED])")
87    }
88}
89
90impl MasterKey {
91    /// Construct from explicit bytes. The agent must keep the bytes
92    /// confidential; the wrapper offers no extra protection beyond not
93    /// implementing `Debug` or `Display`.
94    #[must_use]
95    pub const fn from_bytes(bytes: [u8; KEY_LEN]) -> Self {
96        Self(bytes)
97    }
98
99    /// Read a 32-byte master key from `path`.
100    pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
101        let bytes = fs::read(path.as_ref())?;
102        if bytes.len() != KEY_LEN {
103            return Err(StoreError::KeyFileSize(bytes.len()));
104        }
105        let mut buf = [0u8; KEY_LEN];
106        buf.copy_from_slice(&bytes);
107        Ok(Self(buf))
108    }
109
110    /// Look up the master key in the OS keyring under
111    /// (`vibesurfer`, `default`). Reads the entry as a hex string so it
112    /// round-trips through cross-platform secret stores that don't
113    /// tolerate raw bytes.
114    pub fn from_keyring() -> Result<Self> {
115        ensure_default_keyring_store()?;
116        let entry = keyring_core::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT)
117            .map_err(|e| StoreError::Keyring(e.to_string()))?;
118        let hex = entry
119            .get_password()
120            .map_err(|e| StoreError::Keyring(e.to_string()))?;
121        decode_hex_key(&hex)
122    }
123
124    /// Try the OS keyring first; fall back to `path`. Errors only if
125    /// both fail.
126    pub fn resolve(fallback_path: impl AsRef<Path>) -> Result<Self> {
127        match Self::from_keyring() {
128            Ok(k) => Ok(k),
129            Err(_) => Self::from_file(fallback_path),
130        }
131    }
132
133    /// Generate a fresh random key from the system CSPRNG.
134    pub fn generate() -> Result<Self> {
135        let mut buf = [0u8; KEY_LEN];
136        SystemRandom::new()
137            .fill(&mut buf)
138            .map_err(|_| StoreError::Crypto("rng"))?;
139        Ok(Self(buf))
140    }
141
142    /// Persist the key as 32 raw bytes at `path`. Caller is responsible
143    /// for chmod-ing the file (typically 0600) and for ensuring the
144    /// destination directory exists.
145    pub fn write_to_file(&self, path: impl AsRef<Path>) -> Result<()> {
146        fs::write(path.as_ref(), self.0)?;
147        Ok(())
148    }
149
150    /// Persist the key into the OS keyring as a hex string.
151    pub fn write_to_keyring(&self) -> Result<()> {
152        use std::fmt::Write as _;
153        let mut hex = String::with_capacity(KEY_LEN * 2);
154        for b in &self.0 {
155            write!(hex, "{b:02x}").expect("write to String never fails");
156        }
157        ensure_default_keyring_store()?;
158        let entry = keyring_core::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT)
159            .map_err(|e| StoreError::Keyring(e.to_string()))?;
160        entry
161            .set_password(&hex)
162            .map_err(|e| StoreError::Keyring(e.to_string()))?;
163        Ok(())
164    }
165
166    fn raw(&self) -> &[u8; KEY_LEN] {
167        &self.0
168    }
169}
170
171fn decode_hex_key(s: &str) -> Result<MasterKey> {
172    if s.len() != KEY_LEN * 2 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
173        return Err(StoreError::Crypto("hex key shape"));
174    }
175    let mut buf = [0u8; KEY_LEN];
176    for (i, chunk) in s.as_bytes().chunks_exact(2).enumerate() {
177        let pair = std::str::from_utf8(chunk).map_err(|_| StoreError::Crypto("hex key utf8"))?;
178        buf[i] = u8::from_str_radix(pair, 16).map_err(|_| StoreError::Crypto("hex key digit"))?;
179    }
180    Ok(MasterKey::from_bytes(buf))
181}
182
183/// One ciphertext + its 12-byte GCM nonce.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct EncryptedBlob {
186    pub ciphertext: Vec<u8>,
187    pub nonce: [u8; NONCE_LEN],
188}
189
190/// Encrypt `plaintext` under `key`. The returned blob is suitable for
191/// inserting into `auth_blobs.ciphertext` (with `nonce` as a sibling
192/// column).
193pub fn encrypt(key: &MasterKey, plaintext: &[u8]) -> Result<EncryptedBlob> {
194    let mut nonce = [0u8; NONCE_LEN];
195    SystemRandom::new()
196        .fill(&mut nonce)
197        .map_err(|_| StoreError::Crypto("rng"))?;
198
199    let unbound =
200        UnboundKey::new(&AES_256_GCM, key.raw()).map_err(|_| StoreError::Crypto("key"))?;
201    let mut sealing = SealingKey::new(unbound, OneNonce(Some(nonce)));
202
203    let mut buf = plaintext.to_vec();
204    sealing
205        .seal_in_place_append_tag(Aad::empty(), &mut buf)
206        .map_err(|_| StoreError::Crypto("seal"))?;
207    Ok(EncryptedBlob {
208        ciphertext: buf,
209        nonce,
210    })
211}
212
213/// Decrypt a previously encrypted blob.
214pub fn decrypt(key: &MasterKey, blob: &EncryptedBlob) -> Result<Vec<u8>> {
215    let unbound =
216        UnboundKey::new(&AES_256_GCM, key.raw()).map_err(|_| StoreError::Crypto("key"))?;
217    let mut opening = OpeningKey::new(unbound, OneNonce(Some(blob.nonce)));
218    let mut buf = blob.ciphertext.clone();
219    let plaintext = opening
220        .open_in_place(Aad::empty(), &mut buf)
221        .map_err(|_| StoreError::Crypto("open (likely wrong key or tampered blob)"))?;
222    Ok(plaintext.to_vec())
223}
224
225/// Single-shot nonce sequence. AES-GCM is unsafe to reuse a nonce with
226/// the same key; we randomize per-message and never replay, so each
227/// `SealingKey`/`OpeningKey` gets exactly one nonce and is then dropped.
228struct OneNonce(Option<[u8; NONCE_LEN]>);
229
230impl NonceSequence for OneNonce {
231    fn advance(&mut self) -> std::result::Result<Nonce, Unspecified> {
232        let bytes = self.0.take().ok_or(Unspecified)?;
233        Ok(Nonce::assume_unique_for_key(bytes))
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    fn fixed_key() -> MasterKey {
242        MasterKey::from_bytes([7u8; KEY_LEN])
243    }
244
245    #[test]
246    fn round_trip_empty() {
247        let k = fixed_key();
248        let blob = encrypt(&k, &[]).unwrap();
249        assert_eq!(decrypt(&k, &blob).unwrap(), Vec::<u8>::new());
250    }
251
252    #[test]
253    fn round_trip_payload() {
254        let k = fixed_key();
255        let plain = b"cookies=session=abc123; theme=dark";
256        let blob = encrypt(&k, plain).unwrap();
257        assert_ne!(blob.ciphertext, plain);
258        assert_eq!(decrypt(&k, &blob).unwrap(), plain.to_vec());
259    }
260
261    #[test]
262    fn nonces_are_unique() {
263        let k = fixed_key();
264        let a = encrypt(&k, b"hello").unwrap();
265        let b = encrypt(&k, b"hello").unwrap();
266        assert_ne!(a.nonce, b.nonce);
267        assert_ne!(a.ciphertext, b.ciphertext);
268    }
269
270    #[test]
271    fn wrong_key_rejected() {
272        let k1 = MasterKey::from_bytes([1u8; KEY_LEN]);
273        let k2 = MasterKey::from_bytes([2u8; KEY_LEN]);
274        let blob = encrypt(&k1, b"secret").unwrap();
275        assert!(decrypt(&k2, &blob).is_err());
276    }
277
278    #[test]
279    fn tamper_rejected() {
280        let k = fixed_key();
281        let mut blob = encrypt(&k, b"secret").unwrap();
282        // Flip the first byte of ciphertext.
283        blob.ciphertext[0] ^= 0xff;
284        assert!(decrypt(&k, &blob).is_err());
285    }
286
287    #[test]
288    fn key_file_round_trip() {
289        let dir = tempfile::tempdir().unwrap();
290        let path = dir.path().join("key");
291        let k = MasterKey::generate().unwrap();
292        k.write_to_file(&path).unwrap();
293        let loaded = MasterKey::from_file(&path).unwrap();
294        assert_eq!(k.0, loaded.0);
295    }
296
297    #[test]
298    fn key_file_wrong_size_rejected() {
299        let dir = tempfile::tempdir().unwrap();
300        let path = dir.path().join("key");
301        std::fs::write(&path, b"too short").unwrap();
302        let err = MasterKey::from_file(&path).unwrap_err();
303        matches!(err, StoreError::KeyFileSize(_));
304    }
305}