diff --git a/crates/encryptoglyph/src/lib.rs b/crates/encryptoglyph/src/lib.rs index b8ae5fe..bcbfe32 100644 --- a/crates/encryptoglyph/src/lib.rs +++ b/crates/encryptoglyph/src/lib.rs @@ -29,6 +29,7 @@ pub mod ff1; pub mod pq; pub mod policy; pub mod tenancy; +pub mod secrets; pub const MAGIC: &[u8; 2] = b"EG"; pub const VERSION_LEGACY: u8 = 1; diff --git a/crates/encryptoglyph/src/secrets.rs b/crates/encryptoglyph/src/secrets.rs new file mode 100644 index 0000000..99353a9 --- /dev/null +++ b/crates/encryptoglyph/src/secrets.rs @@ -0,0 +1,97 @@ +//! Versioned secrets with rotation hooks (backlog #14), the "one console" surface. +//! Each version is a normal v2 envelope whose record context binds tenant, name and version, +//! so versions can't be swapped. Values are never logged or returned by `list`. +use crate::{decrypt, encrypt, Error, Keyring, RecordContext, VERSION_V2}; +use std::collections::BTreeMap; +use zeroize::Zeroizing; + +/// Called after a new version is stored, for example to push it to an app or bump a deploy. +/// It receives only metadata, never the value. +pub trait RotationHook { + fn rotated(&mut self, tenant: &str, name: &str, version: u32); +} + +#[derive(Debug, Clone, PartialEq)] +pub struct VersionMeta { + pub version: u32, + pub created_unix: u64, + pub destroyed: bool, +} + +struct Entry { + meta: VersionMeta, + sealed: Option>, +} + +pub struct SecretStore<'k> { + kr: &'k Keyring, + tenant: String, + items: BTreeMap>, + hooks: Vec>, +} + +fn ctx(tenant: &str, name: &str, v: u32) -> RecordContext { + RecordContext { tenant: tenant.into(), purpose: "secret".into(), table: "secrets".into(), record_id: name.into(), field: format!("v{v}"), schema_version: 1 } +} + +impl<'k> SecretStore<'k> { + pub fn new(kr: &'k Keyring, tenant: &str) -> Self { + SecretStore { kr, tenant: tenant.into(), items: BTreeMap::new(), hooks: vec![] } + } + + pub fn on_rotate(&mut self, h: Box) { + self.hooks.push(h); + } + + /// Store a new version and return its number (starting at 1). + pub fn put(&mut self, name: &str, value: &[u8], now_unix: u64) -> Result { + if name.is_empty() || name.len() > 200 || name.contains(char::is_whitespace) { + return Err(Error::InvalidArgument); + } + let list = self.items.entry(name.into()).or_default(); + let v = list.len() as u32 + 1; + let sealed = encrypt(self.kr, &ctx(&self.tenant, name, v), value)?; + list.push(Entry { meta: VersionMeta { version: v, created_unix: now_unix, destroyed: false }, sealed: Some(sealed) }); + for h in self.hooks.iter_mut() { + h.rotated(&self.tenant, name, v); + } + Ok(v) + } + + /// Fetch a version. `None` means the latest version that hasn't been destroyed. + pub fn get(&self, name: &str, version: Option) -> Result>, Error> { + let list = self.items.get(name).ok_or(Error::Decrypt)?; + let e = match version { + Some(v) => list.get((v as usize).checked_sub(1).ok_or(Error::InvalidArgument)?), + None => list.iter().rev().find(|e| !e.meta.destroyed), + } + .ok_or(Error::Decrypt)?; + let sealed = e.sealed.as_ref().ok_or(Error::Decrypt)?; + decrypt(self.kr, &ctx(&self.tenant, name, e.meta.version), sealed, VERSION_V2) + } + + pub fn versions(&self, name: &str) -> Vec { + self.items.get(name).map(|l| l.iter().map(|e| e.meta.clone()).collect()).unwrap_or_default() + } + + pub fn names(&self) -> Vec { + self.items.keys().cloned().collect() + } + + /// Irreversibly drop a version's ciphertext. Its metadata stays for audit. + pub fn destroy(&mut self, name: &str, version: u32) -> bool { + match self.items.get_mut(name).and_then(|l| l.get_mut((version as usize).wrapping_sub(1))) { + Some(e) if !e.meta.destroyed => { + e.sealed = None; + e.meta.destroyed = true; + true + } + _ => false, + } + } + + /// Export a version's sealed blob (for example to a DB row). Restore with the same ctx. + pub fn sealed(&self, name: &str, version: u32) -> Option<&[u8]> { + self.items.get(name)?.get((version as usize).wrapping_sub(1))?.sealed.as_deref() + } +} diff --git a/crates/encryptoglyph/tests/secrets.rs b/crates/encryptoglyph/tests/secrets.rs new file mode 100644 index 0000000..e8edf85 --- /dev/null +++ b/crates/encryptoglyph/tests/secrets.rs @@ -0,0 +1,35 @@ +use encryptoglyph::secrets::{RotationHook, SecretStore}; +use encryptoglyph::Keyring; +use std::sync::{Arc, Mutex}; + +struct Rec(Arc>>); +impl RotationHook for Rec { + fn rotated(&mut self, t: &str, n: &str, v: u32) { + self.0.lock().unwrap().push(format!("{t}/{n}@{v}")); + } +} + +#[test] +fn versions_rotation_destroy() { + let kr = Keyring::new("k1", &[2u8; 32]).unwrap(); + let mut s = SecretStore::new(&kr, "acme"); + let log = Arc::new(Mutex::new(vec![])); + s.on_rotate(Box::new(Rec(log.clone()))); + assert_eq!(s.put("DB_PASSWORD", b"one", 10).unwrap(), 1); + assert_eq!(s.put("DB_PASSWORD", b"two", 20).unwrap(), 2); + assert_eq!(&s.get("DB_PASSWORD", None).unwrap()[..], b"two"); + assert_eq!(&s.get("DB_PASSWORD", Some(1)).unwrap()[..], b"one"); + assert_eq!(*log.lock().unwrap(), vec!["acme/DB_PASSWORD@1", "acme/DB_PASSWORD@2"]); + assert!(s.destroy("DB_PASSWORD", 2)); + assert!(!s.destroy("DB_PASSWORD", 2)); + assert_eq!(&s.get("DB_PASSWORD", None).unwrap()[..], b"one"); + assert!(s.get("DB_PASSWORD", Some(2)).is_err()); + assert!(s.versions("DB_PASSWORD")[1].destroyed); + assert!(s.put("bad name", b"x", 0).is_err()); + assert!(s.get("DB_PASSWORD", Some(0)).is_err()); + // Version ciphertexts aren't interchangeable. + let v1 = s.sealed("DB_PASSWORD", 1).unwrap().to_vec(); + s.put("OTHER", b"z", 30).unwrap(); + let ctx_other = encryptoglyph::RecordContext { tenant: "acme".into(), purpose: "secret".into(), table: "secrets".into(), record_id: "OTHER".into(), field: "v1".into(), schema_version: 1 }; + assert!(encryptoglyph::decrypt(&kr, &ctx_other, &v1, 2).is_err()); +}