Skip to main content

kovra_core/
crypto.rs

1//! AEAD encryption at rest (spec §10.1; ADR-0001).
2//!
3//! Each record is independently sealed with ChaCha20-Poly1305 under a fresh
4//! random nonce (unique nonce per write), sealing metadata + value together so
5//! no plaintext — and no coordinate — is exposed on disk. The 32-byte master
6//! key is supplied by the caller; key management (OS keyring / Argon2 fallback)
7//! is L2's concern, not this layer's.
8
9use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng};
10use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
11use serde::{Deserialize, Serialize};
12use zeroize::Zeroize;
13
14use crate::error::CoreError;
15use crate::record::SecretRecord;
16
17/// AEAD key length in bytes (ChaCha20-Poly1305).
18pub const KEY_LEN: usize = 32;
19/// AEAD nonce length in bytes.
20pub const NONCE_LEN: usize = 12;
21
22/// A sealed record: AEAD nonce + ciphertext (the ciphertext includes the
23/// Poly1305 authentication tag). Safe to persist or serialize — it contains no
24/// plaintext.
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct SealedRecord {
27    /// The unique nonce used for this record.
28    pub nonce: Vec<u8>,
29    /// Ciphertext + authentication tag.
30    pub ciphertext: Vec<u8>,
31}
32
33/// Seal arbitrary plaintext bytes under a fresh random nonce. The single AEAD
34/// path used by both [`seal`] (secret records) and the metadata index
35/// (ADR-0001 §A.4 sealed-at-rest). The caller's buffer is the caller's to
36/// zeroize; this function does not retain it.
37pub fn seal_bytes(plaintext: &[u8], key: &[u8; KEY_LEN]) -> Result<SealedRecord, CoreError> {
38    let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
39    let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
40    let ciphertext = cipher
41        .encrypt(&nonce, plaintext)
42        .map_err(|_| CoreError::Crypto)?;
43    Ok(SealedRecord {
44        nonce: nonce.to_vec(),
45        ciphertext,
46    })
47}
48
49/// Open bytes sealed by [`seal_bytes`]. Fails (without detail) on a wrong key,
50/// a tampered ciphertext, or a malformed nonce — opaque so it cannot act as an
51/// oracle.
52pub fn open_bytes(sealed: &SealedRecord, key: &[u8; KEY_LEN]) -> Result<Vec<u8>, CoreError> {
53    if sealed.nonce.len() != NONCE_LEN {
54        return Err(CoreError::Crypto);
55    }
56    let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
57    let nonce = Nonce::from_slice(&sealed.nonce);
58    cipher
59        .decrypt(nonce, sealed.ciphertext.as_slice())
60        .map_err(|_| CoreError::Crypto)
61}
62
63/// Seal a record: serialize it, then AEAD-encrypt metadata + value together.
64///
65/// A fresh random nonce is generated on every call, so two seals of the same
66/// record always differ. The transient plaintext buffer is zeroized.
67pub fn seal(record: &SecretRecord, key: &[u8; KEY_LEN]) -> Result<SealedRecord, CoreError> {
68    let mut plaintext =
69        serde_json::to_vec(record).map_err(|e| CoreError::Serialization(e.to_string()))?;
70    let sealed = seal_bytes(&plaintext, key);
71    plaintext.zeroize();
72    sealed
73}
74
75/// Open a sealed record: AEAD-decrypt then deserialize. Fails (without detail)
76/// on a wrong key, a tampered ciphertext, or a malformed nonce. The transient
77/// plaintext buffer is zeroized before returning.
78pub fn open(sealed: &SealedRecord, key: &[u8; KEY_LEN]) -> Result<SecretRecord, CoreError> {
79    let mut plaintext = open_bytes(sealed, key)?;
80    let record = serde_json::from_slice::<SecretRecord>(&plaintext)
81        .map_err(|e| CoreError::Serialization(e.to_string()));
82    plaintext.zeroize();
83    record
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use crate::secret::SecretValue;
90    use crate::sensitivity::Sensitivity;
91
92    fn key() -> [u8; KEY_LEN] {
93        [7u8; KEY_LEN]
94    }
95
96    fn literal(value: &str) -> SecretRecord {
97        SecretRecord::Literal {
98            value: SecretValue::from(value),
99            sensitivity: Sensitivity::High,
100            revealable: false,
101            environment: "prod".to_string(),
102            component: "db".to_string(),
103            key: "password".to_string(),
104            description: None,
105            created: "2026-05-30T00:00:00Z".to_string(),
106            updated: "2026-05-30T00:00:00Z".to_string(),
107        }
108    }
109
110    #[test]
111    fn seal_open_round_trip() {
112        let record = literal("hunter2");
113        let sealed = seal(&record, &key()).unwrap();
114        let opened = open(&sealed, &key()).unwrap();
115        assert_eq!(opened, record);
116    }
117
118    #[test]
119    fn nonce_is_unique_per_write() {
120        let record = literal("hunter2");
121        let a = seal(&record, &key()).unwrap();
122        let b = seal(&record, &key()).unwrap();
123        assert_ne!(a.nonce, b.nonce);
124        assert_ne!(a.ciphertext, b.ciphertext);
125    }
126
127    #[test]
128    fn sealed_bytes_do_not_contain_plaintext() {
129        let sealed = seal(&literal("hunter2"), &key()).unwrap();
130        assert!(!sealed.ciphertext.windows(7).any(|w| w == b"hunter2"));
131        // and the coordinate metadata is sealed too (not exposed)
132        assert!(!sealed.ciphertext.windows(8).any(|w| w == b"password"));
133    }
134
135    #[test]
136    fn wrong_key_fails_to_open() {
137        let sealed = seal(&literal("hunter2"), &key()).unwrap();
138        let err = open(&sealed, &[9u8; KEY_LEN]).unwrap_err();
139        assert!(matches!(err, CoreError::Crypto));
140    }
141
142    #[test]
143    fn tampered_ciphertext_fails_to_open() {
144        let mut sealed = seal(&literal("hunter2"), &key()).unwrap();
145        sealed.ciphertext[0] ^= 0xff;
146        assert!(matches!(open(&sealed, &key()), Err(CoreError::Crypto)));
147    }
148
149    #[test]
150    fn malformed_nonce_fails_to_open() {
151        let mut sealed = seal(&literal("hunter2"), &key()).unwrap();
152        sealed.nonce.truncate(4);
153        assert!(matches!(open(&sealed, &key()), Err(CoreError::Crypto)));
154    }
155}