1use chacha20poly1305::aead::{AeadInOut, KeyInit};
22use chacha20poly1305::XChaCha20Poly1305;
23use x25519_dalek::{PublicKey, StaticSecret};
24use zeroize::{Zeroize, ZeroizeOnDrop};
25
26const TAG_BYTES: usize = 16;
27const EPK_BYTES: usize = 32;
28const XNONCE_BYTES: usize = 24;
29
30const SEAL_KEY_CTX: &str = "q42:sanctuary:audit:seal:key:v1";
31const SEAL_NONCE_CTX: &str = "q42:sanctuary:audit:seal:nonce:v1";
32const CHAIN_HASH_LEN: usize = 32;
33
34pub const GENESIS_PARENT: [u8; CHAIN_HASH_LEN] = [0u8; CHAIN_HASH_LEN];
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum SanctuaryAuditError {
39 Rng,
40 Encrypt,
41 Decrypt,
42 Malformed,
43}
44
45fn rand_bytes<const N: usize>() -> Result<[u8; N], SanctuaryAuditError> {
46 let mut b = [0u8; N];
47 getrandom::fill(&mut b).map_err(|_| SanctuaryAuditError::Rng)?;
48 Ok(b)
49}
50
51#[derive(Zeroize, ZeroizeOnDrop)]
55pub struct AuditKeypair {
56 #[zeroize(skip)]
57 pub public: [u8; 32],
58 secret: [u8; 32],
59}
60
61impl AuditKeypair {
62 pub fn generate() -> Result<Self, SanctuaryAuditError> {
63 let secret = rand_bytes::<32>()?;
64 let public = PublicKey::from(&StaticSecret::from(secret)).to_bytes();
65 Ok(Self { public, secret })
66 }
67
68 pub fn from_secret(secret: [u8; 32]) -> Self {
73 let public = PublicKey::from(&StaticSecret::from(secret)).to_bytes();
74 Self { public, secret }
75 }
76
77 pub fn secret_bytes(&self) -> &[u8; 32] {
79 &self.secret
80 }
81}
82
83fn derive_seal_key_nonce(
84 shared: &[u8],
85 ephemeral_public: &[u8; 32],
86 recipient_public: &[u8; 32],
87) -> ([u8; 32], [u8; XNONCE_BYTES]) {
88 let mut km = Vec::with_capacity(shared.len() + 64);
90 km.extend_from_slice(shared);
91 km.extend_from_slice(ephemeral_public);
92 km.extend_from_slice(recipient_public);
93 let key = blake3::derive_key(SEAL_KEY_CTX, &km);
94 let nonce_full = blake3::derive_key(SEAL_NONCE_CTX, &km);
95 let mut nonce = [0u8; XNONCE_BYTES];
96 nonce.copy_from_slice(&nonce_full[..XNONCE_BYTES]);
97 (key, nonce)
98}
99
100fn cipher_for(key: &[u8; 32]) -> Result<XChaCha20Poly1305, SanctuaryAuditError> {
101 let key =
102 <&chacha20poly1305::Key>::try_from(&key[..]).map_err(|_| SanctuaryAuditError::Encrypt)?;
103 Ok(XChaCha20Poly1305::new(key))
104}
105
106fn aead_seal(
107 key: &[u8; 32],
108 nonce: &[u8; XNONCE_BYTES],
109 plaintext: &[u8],
110 aad: &[u8],
111) -> Result<Vec<u8>, SanctuaryAuditError> {
112 let cipher = cipher_for(key)?;
113 let nonce = <&chacha20poly1305::XNonce>::try_from(&nonce[..])
114 .map_err(|_| SanctuaryAuditError::Encrypt)?;
115 let mut buffer = plaintext.to_vec();
116 let tag = cipher
117 .encrypt_inout_detached(nonce, aad, buffer.as_mut_slice().into())
118 .map_err(|_| SanctuaryAuditError::Encrypt)?;
119 buffer.extend_from_slice(tag.as_slice());
120 Ok(buffer)
121}
122
123fn aead_open(
124 key: &[u8; 32],
125 nonce: &[u8; XNONCE_BYTES],
126 ct_and_tag: &[u8],
127 aad: &[u8],
128) -> Result<Vec<u8>, SanctuaryAuditError> {
129 if ct_and_tag.len() < TAG_BYTES {
130 return Err(SanctuaryAuditError::Malformed);
131 }
132 let split = ct_and_tag.len() - TAG_BYTES;
133 let (ct, tag_bytes) = ct_and_tag.split_at(split);
134 let cipher = cipher_for(key)?;
135 let nonce = <&chacha20poly1305::XNonce>::try_from(&nonce[..])
136 .map_err(|_| SanctuaryAuditError::Decrypt)?;
137 let tag = <&chacha20poly1305::Tag>::try_from(tag_bytes)
138 .map_err(|_| SanctuaryAuditError::Malformed)?;
139 let mut buffer = ct.to_vec();
140 cipher
141 .decrypt_inout_detached(nonce, aad, buffer.as_mut_slice().into(), tag)
142 .map_err(|_| SanctuaryAuditError::Decrypt)?;
143 Ok(buffer)
144}
145
146pub fn seal_to(
150 recipient_public: &[u8; 32],
151 plaintext: &[u8],
152 aad: &[u8],
153) -> Result<Vec<u8>, SanctuaryAuditError> {
154 let ephemeral_secret = StaticSecret::from(rand_bytes::<32>()?);
155 let ephemeral_public = PublicKey::from(&ephemeral_secret).to_bytes();
156 let shared = ephemeral_secret.diffie_hellman(&PublicKey::from(*recipient_public));
157 let (key, nonce) =
158 derive_seal_key_nonce(shared.as_bytes(), &ephemeral_public, recipient_public);
159 let body = aead_seal(&key, &nonce, plaintext, aad)?;
160 let mut out = Vec::with_capacity(EPK_BYTES + body.len());
161 out.extend_from_slice(&ephemeral_public);
162 out.extend_from_slice(&body);
163 Ok(out)
164}
165
166pub fn open_sealed(
169 recipient_secret: &[u8; 32],
170 sealed: &[u8],
171 aad: &[u8],
172) -> Result<Vec<u8>, SanctuaryAuditError> {
173 if sealed.len() < EPK_BYTES + TAG_BYTES {
174 return Err(SanctuaryAuditError::Malformed);
175 }
176 let mut ephemeral_public = [0u8; 32];
177 ephemeral_public.copy_from_slice(&sealed[..EPK_BYTES]);
178 let body = &sealed[EPK_BYTES..];
179
180 let secret = StaticSecret::from(*recipient_secret);
181 let recipient_public = PublicKey::from(&secret).to_bytes();
182 let shared = secret.diffie_hellman(&PublicKey::from(ephemeral_public));
183 let (key, nonce) =
184 derive_seal_key_nonce(shared.as_bytes(), &ephemeral_public, &recipient_public);
185 aead_open(&key, &nonce, body, aad)
186}
187
188pub fn wrap_key(
191 wrapping_key: &[u8; 32],
192 key_material: &[u8],
193 aad: &[u8],
194) -> Result<Vec<u8>, SanctuaryAuditError> {
195 let nonce = rand_bytes::<XNONCE_BYTES>()?;
196 let body = aead_seal(wrapping_key, &nonce, key_material, aad)?;
197 let mut out = Vec::with_capacity(XNONCE_BYTES + body.len());
198 out.extend_from_slice(&nonce);
199 out.extend_from_slice(&body);
200 Ok(out)
201}
202
203pub fn unwrap_key(
205 wrapping_key: &[u8; 32],
206 wrapped: &[u8],
207 aad: &[u8],
208) -> Result<Vec<u8>, SanctuaryAuditError> {
209 if wrapped.len() < XNONCE_BYTES + TAG_BYTES {
210 return Err(SanctuaryAuditError::Malformed);
211 }
212 let mut nonce = [0u8; XNONCE_BYTES];
213 nonce.copy_from_slice(&wrapped[..XNONCE_BYTES]);
214 aead_open(wrapping_key, &nonce, &wrapped[XNONCE_BYTES..], aad)
215}
216
217pub fn chain_hash(parent: &[u8; CHAIN_HASH_LEN], payload: &[u8]) -> [u8; CHAIN_HASH_LEN] {
221 let mut hasher = blake3::Hasher::new();
222 hasher.update(parent);
223 hasher.update(payload);
224 *hasher.finalize().as_bytes()
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 #[test]
232 fn sealed_box_round_trips() {
233 let kp = AuditKeypair::generate().unwrap();
234 let msg = b"decoy session 1: coercer added note at 12:04";
235 let sealed = seal_to(&kp.public, msg, b"branch:session-1").unwrap();
236 let opened = open_sealed(kp.secret_bytes(), &sealed, b"branch:session-1").unwrap();
237 assert_eq!(opened, msg);
238 }
239
240 #[test]
241 fn only_the_secret_holder_can_open() {
242 let kp = AuditKeypair::generate().unwrap();
243 let other = AuditKeypair::generate().unwrap();
244 let sealed = seal_to(&kp.public, b"evidence", b"").unwrap();
245 assert!(open_sealed(other.secret_bytes(), &sealed, b"").is_err());
246 }
247
248 #[test]
249 fn public_key_alone_cannot_read() {
250 let kp = AuditKeypair::generate().unwrap();
253 let sealed = seal_to(&kp.public, b"only-the-real-lane-reads-this", b"").unwrap();
254 match open_sealed(&kp.public, &sealed, b"") {
255 Err(_) => {}
256 Ok(pt) => assert_ne!(pt.as_slice(), b"only-the-real-lane-reads-this"),
257 }
258 }
259
260 #[test]
261 fn tampered_sealed_box_is_rejected() {
262 let kp = AuditKeypair::generate().unwrap();
263 let mut sealed = seal_to(&kp.public, b"unaltered", b"").unwrap();
264 let mid = sealed.len() / 2;
265 sealed[mid] ^= 0xFF;
266 assert!(open_sealed(kp.secret_bytes(), &sealed, b"").is_err());
267 }
268
269 #[test]
270 fn aad_is_bound() {
271 let kp = AuditKeypair::generate().unwrap();
272 let sealed = seal_to(&kp.public, b"m", b"branch:session-1").unwrap();
273 assert!(open_sealed(kp.secret_bytes(), &sealed, b"branch:session-2").is_err());
274 }
275
276 #[test]
277 fn sealing_is_non_deterministic() {
278 let kp = AuditKeypair::generate().unwrap();
281 let a = seal_to(&kp.public, b"same-note", b"").unwrap();
282 let b = seal_to(&kp.public, b"same-note", b"").unwrap();
283 assert_ne!(a, b);
284 }
285
286 #[test]
287 fn key_wrap_round_trips_and_binds_key_and_aad() {
288 let real_key = rand_bytes::<32>().unwrap();
289 let decoy_key = [7u8; 32];
290 let wrapped = wrap_key(&real_key, &decoy_key, b"role:decoy-lane-key").unwrap();
291 assert_eq!(
292 unwrap_key(&real_key, &wrapped, b"role:decoy-lane-key").unwrap(),
293 decoy_key
294 );
295 assert!(unwrap_key(&[9u8; 32], &wrapped, b"role:decoy-lane-key").is_err());
297 assert!(unwrap_key(&real_key, &wrapped, b"role:something-else").is_err());
299 }
300
301 #[test]
302 fn hash_chain_is_deterministic_and_tamper_evident() {
303 let r1 = chain_hash(&GENESIS_PARENT, b"session-1 opened");
304 let r2 = chain_hash(&r1, b"note added: 'call me'");
305 let r3 = chain_hash(&r2, b"note edited");
306
307 assert_eq!(r1, chain_hash(&GENESIS_PARENT, b"session-1 opened"));
309 assert_ne!(r1, r2);
310 assert_ne!(r2, r3);
311
312 let r2_tampered = chain_hash(&r1, b"note added: 'do NOT call'");
314 assert_ne!(r2, r2_tampered);
315 assert_ne!(r3, chain_hash(&r2_tampered, b"note edited"));
316 }
317}