Skip to main content

tsaur_core/
crypto.rs

1//! Envelope encryption and signatures.
2//!
3//! * A random 256-bit **archive key** (DEK) never encrypts anything directly; sub-keys are derived
4//!   with BLAKE3 `derive_key` (blob key, nonce key, ...).
5//! * Every section/blob is sealed with **XChaCha20-Poly1305**; the 24-byte nonce is derived from
6//!   the nonce key, a label and the blob index, and the AAD binds the blob header.
7//! * The DEK is wrapped by a **KEK** derived from a passphrase with **Argon2id** (RFC 9106);
8//!   readers enforce a floor on the KDF parameters. Hybrid X25519 + ML-KEM-768 recipients and
9//!   FIDO2 stanzas are the next steps (spec §8).
10//! * Signatures: **Ed25519** over the signing message of the section table (ML-DSA to follow).
11
12use crate::error::{Error, Result};
13use argon2::{Algorithm, Argon2, Params, Version};
14use chacha20poly1305::aead::{Aead, KeyInit, Payload};
15use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
16use serde::{Deserialize, Serialize};
17use serde_bytes::ByteBuf;
18
19pub const LABEL_BLOB: u8 = 1;
20pub const LABEL_INDEX: u8 = 2;
21pub const LABEL_MANIFEST: u8 = 3;
22pub const LABEL_DICT: u8 = 4;
23
24pub fn random_bytes(n: usize) -> Result<Vec<u8>> {
25    let mut v = vec![0u8; n];
26    getrandom::getrandom(&mut v).map_err(|e| Error::Crypto(format!("rng: {e}")))?;
27    Ok(v)
28}
29
30#[derive(Clone)]
31pub struct ArchiveKey(pub [u8; 32]);
32
33impl ArchiveKey {
34    pub fn random() -> Result<Self> {
35        let v = random_bytes(32)?;
36        let mut k = [0u8; 32];
37        k.copy_from_slice(&v);
38        Ok(ArchiveKey(k))
39    }
40
41    fn sub(&self, context: &str) -> [u8; 32] {
42        blake3::derive_key(context, &self.0)
43    }
44
45    pub fn blob_key(&self) -> [u8; 32] {
46        self.sub("tsaur v1 blob key")
47    }
48
49    pub fn nonce_key(&self) -> [u8; 32] {
50        self.sub("tsaur v1 nonce key")
51    }
52}
53
54fn nonce_for(key: &ArchiveKey, label: u8, index: u64) -> [u8; 24] {
55    let mut m = [0u8; 9];
56    m[0] = label;
57    m[1..].copy_from_slice(&index.to_le_bytes());
58    let h = blake3::keyed_hash(&key.nonce_key(), &m);
59    let mut n = [0u8; 24];
60    n.copy_from_slice(&h.as_bytes()[..24]);
61    n
62}
63
64pub fn seal(key: &ArchiveKey, label: u8, index: u64, aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>> {
65    let cipher = XChaCha20Poly1305::new(Key::from_slice(&key.blob_key()));
66    let nonce = nonce_for(key, label, index);
67    cipher.encrypt(XNonce::from_slice(&nonce), Payload { msg: plaintext, aad }).map_err(|_| Error::Crypto("encryption failed".into()))
68}
69
70pub fn open(key: &ArchiveKey, label: u8, index: u64, aad: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>> {
71    let cipher = XChaCha20Poly1305::new(Key::from_slice(&key.blob_key()));
72    let nonce = nonce_for(key, label, index);
73    cipher.decrypt(XNonce::from_slice(&nonce), Payload { msg: ciphertext, aad }).map_err(|_| Error::Crypto("authentication failed (wrong password or corrupt data)".into()))
74}
75
76#[derive(Clone, Copy, Debug)]
77pub struct KdfParams {
78    pub m_kib: u32,
79    pub t: u32,
80    pub p: u32,
81}
82
83impl Default for KdfParams {
84    /// Spec default: 256 MiB, t = 3, p = 4.
85    fn default() -> Self {
86        Self { m_kib: 256 * 1024, t: 3, p: 4 }
87    }
88}
89
90impl KdfParams {
91    /// Reader-enforced floor (RFC 9106 option 2): 64 MiB, t = 3.
92    pub const FLOOR: KdfParams = KdfParams { m_kib: 64 * 1024, t: 3, p: 1 };
93}
94
95/// One way to unlock the archive key. `t` selects the stanza type:
96/// * `argon2id` — passphrase: `salt`, `m_kib`, `t_cost`, `p`
97/// * `x25519mlkem768` — hybrid recipient: `eph` (ephemeral X25519 public key), `ct` (ML-KEM-768 ciphertext)
98///
99/// In both cases `wrapped` = nonce (24 B) ‖ XChaCha20-Poly1305(DEK) under the derived KEK.
100#[derive(Clone, Debug, Serialize, Deserialize)]
101pub struct Stanza {
102    pub t: String,
103    #[serde(default, skip_serializing_if = "bytebuf_is_empty")]
104    pub salt: ByteBuf,
105    #[serde(default, skip_serializing_if = "is_zero")]
106    pub m_kib: u32,
107    #[serde(default, skip_serializing_if = "is_zero")]
108    pub t_cost: u32,
109    #[serde(default, skip_serializing_if = "is_zero")]
110    pub p: u32,
111    #[serde(default, skip_serializing_if = "bytebuf_is_empty")]
112    pub eph: ByteBuf,
113    #[serde(default, skip_serializing_if = "bytebuf_is_empty")]
114    pub ct: ByteBuf,
115    pub wrapped: ByteBuf,
116}
117
118fn bytebuf_is_empty(b: &ByteBuf) -> bool {
119    b.as_ref().is_empty()
120}
121
122fn is_zero(x: &u32) -> bool {
123    *x == 0
124}
125
126#[derive(Clone, Debug, Serialize, Deserialize, Default)]
127pub struct RecipientsBlock {
128    pub stanzas: Vec<Stanza>,
129}
130
131/// What a reader can present to unlock an archive.
132#[derive(Clone, Default)]
133pub struct Credentials {
134    pub password: Option<String>,
135    pub identities: Vec<hybrid::Identity>,
136}
137
138fn argon2id(password: &[u8], salt: &[u8], kp: &KdfParams) -> Result<[u8; 32]> {
139    let params = Params::new(kp.m_kib, kp.t, kp.p, Some(32)).map_err(|e| Error::Crypto(format!("argon2 params: {e}")))?;
140    let a = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
141    let mut out = [0u8; 32];
142    a.hash_password_into(password, salt, &mut out).map_err(|e| Error::Crypto(format!("argon2: {e}")))?;
143    Ok(out)
144}
145
146fn wrap_dek(dek: &ArchiveKey, kek: &[u8; 32]) -> Result<Vec<u8>> {
147    let nonce = random_bytes(24)?;
148    let cipher = XChaCha20Poly1305::new(Key::from_slice(kek));
149    let ct = cipher.encrypt(XNonce::from_slice(&nonce), Payload { msg: &dek.0, aad: b"tsaur v1 dek" }).map_err(|_| Error::Crypto("wrap failed".into()))?;
150    let mut wrapped = nonce;
151    wrapped.extend_from_slice(&ct);
152    Ok(wrapped)
153}
154
155fn unwrap_dek(wrapped: &[u8], kek: &[u8; 32]) -> Result<ArchiveKey> {
156    if wrapped.len() < 24 + 32 + 16 {
157        return Err(Error::Corrupt("recipient stanza too short".into()));
158    }
159    let cipher = XChaCha20Poly1305::new(Key::from_slice(kek));
160    let (nonce, ct) = wrapped.split_at(24);
161    let dek = cipher.decrypt(XNonce::from_slice(nonce), Payload { msg: ct, aad: b"tsaur v1 dek" }).map_err(|_| Error::Crypto("wrong password/identity (or corrupt recipient stanza)".into()))?;
162    let mut k = [0u8; 32];
163    k.copy_from_slice(&dek);
164    Ok(ArchiveKey(k))
165}
166
167pub fn wrap_password(dek: &ArchiveKey, password: &[u8], kp: &KdfParams) -> Result<Stanza> {
168    if kp.m_kib < KdfParams::FLOOR.m_kib || kp.t < KdfParams::FLOOR.t || kp.p < 1 {
169        return Err(Error::Invalid("KDF parameters below the allowed floor (64 MiB, t=3)".into()));
170    }
171    let salt = random_bytes(16)?;
172    let kek = argon2id(password, &salt, kp)?;
173    let wrapped = wrap_dek(dek, &kek)?;
174    Ok(Stanza { t: "argon2id".into(), salt: ByteBuf::from(salt), m_kib: kp.m_kib, t_cost: kp.t, p: kp.p, eph: ByteBuf::new(), ct: ByteBuf::new(), wrapped: ByteBuf::from(wrapped) })
175}
176
177pub fn unwrap_password(stanza: &Stanza, password: &[u8]) -> Result<ArchiveKey> {
178    if stanza.m_kib < KdfParams::FLOOR.m_kib || stanza.t_cost < KdfParams::FLOOR.t || stanza.p < 1 {
179        return Err(Error::Policy("archive KDF parameters are below the allowed floor".into()));
180    }
181    let kp = KdfParams { m_kib: stanza.m_kib, t: stanza.t_cost, p: stanza.p };
182    let kek = argon2id(password, &stanza.salt, &kp)?;
183    unwrap_dek(&stanza.wrapped, &kek)
184}
185
186/// Try every stanza against the credentials; the first that opens wins.
187pub fn unlock(block: &RecipientsBlock, creds: &Credentials) -> Result<ArchiveKey> {
188    let mut last = Error::Crypto("archive is encrypted: no matching password or identity".into());
189    for s in &block.stanzas {
190        match s.t.as_str() {
191            "argon2id" => {
192                if let Some(pw) = &creds.password {
193                    match unwrap_password(s, pw.as_bytes()) {
194                        Ok(k) => return Ok(k),
195                        Err(e) => last = e,
196                    }
197                }
198            }
199            "x25519mlkem768" => {
200                for id in &creds.identities {
201                    match hybrid::unwrap(s, id) {
202                        Ok(k) => return Ok(k),
203                        Err(e) => last = e,
204                    }
205                }
206            }
207            _ => {}
208        }
209    }
210    Err(last)
211}
212
213/// Hybrid X25519 + ML-KEM-768 recipients (FIPS 203; combiner as in RFC 10024 / OpenSSH mlkem768x25519):
214/// KEK = HKDF-SHA-256(ss_mlkem ‖ ss_x25519, salt = eph_pub ‖ BLAKE3(ct), info = "tsaur v1 x25519mlkem768").
215/// Confidentiality holds if either component holds, which protects archives against
216/// "harvest now, decrypt later".
217pub mod hybrid {
218    use super::*;
219    use hkdf::Hkdf;
220    use kem::{Decapsulate, Encapsulate, FromSeed, KeyExport};
221    use ml_kem::MlKem768;
222    use sha2::Sha256;
223    use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret};
224
225    pub const IDENTITY_LEN: usize = 32 + 64;
226    pub const RECIPIENT_LEN: usize = 32 + 1184;
227    pub const CT_LEN: usize = 1088;
228
229    /// Secret identity: X25519 static secret (32 B) + ML-KEM-768 seed (64 B).
230    #[derive(Clone)]
231    pub struct Identity {
232        x_secret: [u8; 32],
233        k_seed: [u8; 64],
234    }
235
236    /// Public recipient: X25519 public key (32 B) + ML-KEM-768 encapsulation key (1184 B).
237    #[derive(Clone, Debug, PartialEq, Eq)]
238    pub struct Recipient {
239        pub x_pub: [u8; 32],
240        pub k_ek: Vec<u8>,
241    }
242
243    impl Identity {
244        pub fn generate() -> Result<Self> {
245            let x = random_bytes(32)?;
246            let s = random_bytes(64)?;
247            let mut x_secret = [0u8; 32];
248            x_secret.copy_from_slice(&x);
249            let mut k_seed = [0u8; 64];
250            k_seed.copy_from_slice(&s);
251            Ok(Self { x_secret, k_seed })
252        }
253
254        pub fn to_bytes(&self) -> Vec<u8> {
255            let mut v = self.x_secret.to_vec();
256            v.extend_from_slice(&self.k_seed);
257            v
258        }
259
260        pub fn from_bytes(b: &[u8]) -> Result<Self> {
261            if b.len() != IDENTITY_LEN {
262                return Err(Error::Invalid(format!("identity must be {IDENTITY_LEN} bytes")));
263            }
264            let mut x_secret = [0u8; 32];
265            x_secret.copy_from_slice(&b[..32]);
266            let mut k_seed = [0u8; 64];
267            k_seed.copy_from_slice(&b[32..]);
268            Ok(Self { x_secret, k_seed })
269        }
270
271        pub fn recipient(&self) -> Recipient {
272            let x_pub = PublicKey::from(&StaticSecret::from(self.x_secret)).to_bytes();
273            let seed = kem::Seed::<MlKem768>::from(self.k_seed);
274            let (_dk, ek) = MlKem768::from_seed(&seed);
275            Recipient { x_pub, k_ek: ek.to_bytes().to_vec() }
276        }
277    }
278
279    impl Recipient {
280        pub fn to_bytes(&self) -> Vec<u8> {
281            let mut v = self.x_pub.to_vec();
282            v.extend_from_slice(&self.k_ek);
283            v
284        }
285
286        pub fn from_bytes(b: &[u8]) -> Result<Self> {
287            if b.len() != RECIPIENT_LEN {
288                return Err(Error::Invalid(format!("recipient key must be {RECIPIENT_LEN} bytes")));
289            }
290            let mut x_pub = [0u8; 32];
291            x_pub.copy_from_slice(&b[..32]);
292            Ok(Self { x_pub, k_ek: b[32..].to_vec() })
293        }
294    }
295
296    fn kek(ss_kem: &[u8], ss_x: &[u8], eph_pub: &[u8; 32], ct: &[u8]) -> [u8; 32] {
297        let mut ikm = ss_kem.to_vec();
298        ikm.extend_from_slice(ss_x);
299        let mut salt = eph_pub.to_vec();
300        salt.extend_from_slice(blake3::hash(ct).as_bytes());
301        let hk = Hkdf::<Sha256>::new(Some(&salt), &ikm);
302        let mut out = [0u8; 32];
303        hk.expand(b"tsaur v1 x25519mlkem768", &mut out).expect("hkdf output length");
304        out
305    }
306
307    pub fn wrap(dek: &ArchiveKey, r: &Recipient) -> Result<Stanza> {
308        let ek_arr = kem::Key::<ml_kem::EncapsulationKey<MlKem768>>::try_from(&r.k_ek[..]).map_err(|_| Error::Invalid("bad ML-KEM encapsulation key length".into()))?;
309        let ek = ml_kem::EncapsulationKey::<MlKem768>::new(&ek_arr).map_err(|_| Error::Invalid("invalid ML-KEM encapsulation key".into()))?;
310        let (ct, ss_kem) = ek.encapsulate();
311        let eph = EphemeralSecret::random();
312        let eph_pub = PublicKey::from(&eph).to_bytes();
313        let ss_x = eph.diffie_hellman(&PublicKey::from(r.x_pub));
314        let k = kek(ss_kem.as_slice(), ss_x.as_bytes(), &eph_pub, ct.as_slice());
315        Ok(Stanza {
316            t: "x25519mlkem768".into(),
317            salt: ByteBuf::new(),
318            m_kib: 0,
319            t_cost: 0,
320            p: 0,
321            eph: ByteBuf::from(eph_pub.to_vec()),
322            ct: ByteBuf::from(ct.as_slice().to_vec()),
323            wrapped: ByteBuf::from(wrap_dek(dek, &k)?),
324        })
325    }
326
327    pub fn unwrap(stanza: &Stanza, id: &Identity) -> Result<ArchiveKey> {
328        if stanza.eph.len() != 32 || stanza.ct.len() != CT_LEN {
329            return Err(Error::Corrupt("malformed hybrid stanza".into()));
330        }
331        let seed = kem::Seed::<MlKem768>::from(id.k_seed);
332        let (dk, _ek) = MlKem768::from_seed(&seed);
333        let ct = kem::Ciphertext::<MlKem768>::try_from(&stanza.ct[..]).map_err(|_| Error::Corrupt("bad ML-KEM ciphertext length".into()))?;
334        let ss_kem = dk.decapsulate(&ct);
335        let mut eph_pub = [0u8; 32];
336        eph_pub.copy_from_slice(&stanza.eph);
337        let ss_x = StaticSecret::from(id.x_secret).diffie_hellman(&PublicKey::from(eph_pub));
338        let k = kek(ss_kem.as_slice(), ss_x.as_bytes(), &eph_pub, &stanza.ct);
339        unwrap_dek(&stanza.wrapped, &k)
340    }
341}
342
343/// Ed25519 signatures (composite ML-DSA-65 planned).
344pub mod sig {
345    use super::*;
346    use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
347
348    /// Returns (secret seed, public key).
349    pub fn keygen() -> Result<([u8; 32], [u8; 32])> {
350        let v = random_bytes(32)?;
351        let mut seed = [0u8; 32];
352        seed.copy_from_slice(&v);
353        let sk = SigningKey::from_bytes(&seed);
354        Ok((seed, sk.verifying_key().to_bytes()))
355    }
356
357    pub fn public_key(seed: &[u8; 32]) -> [u8; 32] {
358        SigningKey::from_bytes(seed).verifying_key().to_bytes()
359    }
360
361    pub fn sign(seed: &[u8; 32], msg: &[u8]) -> [u8; 64] {
362        SigningKey::from_bytes(seed).sign(msg).to_bytes()
363    }
364
365    pub fn verify(pubkey: &[u8; 32], msg: &[u8], sig: &[u8]) -> bool {
366        let Ok(vk) = VerifyingKey::from_bytes(pubkey) else { return false };
367        let Ok(sig_arr): std::result::Result<[u8; 64], _> = sig.try_into() else { return false };
368        let s = Signature::from_bytes(&sig_arr);
369        vk.verify_strict(msg, &s).is_ok()
370    }
371}