Skip to main content

qualia_client_core/
envelope_encryption.rs

1//! **Real envelope encryption for the accountability commons payload** — the crypto that makes
2//! [`ConsentCredential`](crate::consent_credential::ConsentCredential)'s "revoke destroys the wrapped key ⇒
3//! no key, no payload" a *fact*, not a model with opaque placeholder bytes.
4//!
5//! Two layers, both real (built on the already-tested `qualia_core_db::crypto::sanctuary_audit` primitives —
6//! X25519 sealed boxes + XChaCha20-Poly1305 AEAD; no new crate, no simulation):
7//!
8//! 1. **Payload layer (symmetric).** A random **data-encryption key (DEK)** encrypts the plaintext with an
9//!    AEAD ([`wrap_key`]); the ciphertext is content-addressed (`commitment = SHA-256(ciphertext)`), giving
10//!    the [`EncryptedCommonsPayload`] that many parties may replicate. Opening verifies the commitment (the
11//!    bytes are the committed bytes) *then* AEAD-decrypts (tamper ⇒ failure).
12//! 2. **Key layer (asymmetric, per recipient).** The DEK is **sealed to a recipient's public key**
13//!    ([`seal_to`]) — an anonymous ephemeral-DH box only that recipient's secret can open. This sealed DEK
14//!    **is** the credential's `wrapped_key`. So access is genuinely per-holder, and **revocation = destroying
15//!    that sealed DEK** ([`ConsentCredential::revoke`](crate::consent_credential::ConsentCredential::revoke)):
16//!    the recipient can no longer recover the DEK, and the ciphertext — wherever replicated — is opaque to
17//!    them. When **no** live credential holds a sealed DEK for a payload, the DEK is unrecoverable and the
18//!    payload is **crypto-shredded** (permanently unreadable though the bytes survive), exactly as the model
19//!    promised.
20//!
21//! Native-only (the sealed-box primitives are `not(wasm32)`; the desktop owns keys), matching
22//! `wellfair::sanctuary_vault`.
23//!
24//! What this does **not** yet do (named honestly, not deferred behind a lane): distribute a *remote* agent's
25//! X25519 public key — that comes from the peer's published key material in the connection/identity layer
26//! (`social_peers` / DID document), so a worker on their own device can be sealed to and decrypt
27//! independently. Until that is wired, the host seals to the **owner's** envelope keypair by default (the
28//! owner can always open their own data), and can seal to any supplied recipient public key.
29//!
30//! [`wrap_key`]: qualia_core_db::crypto::sanctuary_audit::wrap_key
31//! [`seal_to`]: qualia_core_db::crypto::sanctuary_audit::seal_to
32//! [`EncryptedCommonsPayload`]: crate::consent_credential::EncryptedCommonsPayload
33
34use qualia_core_db::crypto::sanctuary_audit::{
35    open_sealed, seal_to, unwrap_key, wrap_key, AuditKeypair,
36};
37use sha2::{Digest, Sha256};
38
39use crate::consent_credential::{EncryptedCommonsPayload, PayloadCommitment};
40
41/// AEAD associated-data domain separator for the payload layer (binds ciphertext to this use).
42const PAYLOAD_AAD: &[u8] = b"qualia:accountability:payload:v1";
43/// AEAD associated-data domain separator for the sealed DEK (the wrapped key).
44const DEK_AAD: &[u8] = b"qualia:accountability:dek:v1";
45
46/// A 32-byte data-encryption key. Secret: seal it to a recipient, never store it in the clear.
47pub type DataKey = [u8; 32];
48
49/// An X25519 envelope keypair for a party (the owner, or an agent). The **secret** opens sealed DEKs; the
50/// **public** is what a DEK is sealed *to*.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct EnvelopeKeypair {
53    pub public: [u8; 32],
54    pub secret: [u8; 32],
55}
56
57impl EnvelopeKeypair {
58    /// Generate a fresh keypair (real X25519, OS randomness).
59    pub fn generate() -> Result<Self, String> {
60        let kp = AuditKeypair::generate().map_err(|e| format!("keypair generate: {e:?}"))?;
61        Ok(Self {
62            public: kp.public,
63            secret: *kp.secret_bytes(),
64        })
65    }
66
67    pub fn public_hex(&self) -> String {
68        hex::encode(self.public)
69    }
70    pub fn secret_hex(&self) -> String {
71        hex::encode(self.secret)
72    }
73
74    /// Reconstruct from stored hex (e.g. an agent keypair supplied for opening).
75    pub fn from_hex(public_hex: &str, secret_hex: &str) -> Result<Self, String> {
76        Ok(Self {
77            public: parse_key_hex(public_hex, "public")?,
78            secret: parse_key_hex(secret_hex, "secret")?,
79        })
80    }
81
82    /// **Derive** a keypair deterministically from a root secret (the owner's ed25519 signing-key seed) and a
83    /// domain tag, so the **owner envelope keypair is re-derivable and NEVER stored at rest** — no plaintext
84    /// X25519 secret on disk. The derivation is `SHA-256(domain ‖ 0x1f ‖ root_secret)` → X25519 secret; it is
85    /// stable, so seals to this public key open with the re-derived secret across sessions.
86    pub fn derive(root_secret: &[u8; 32], domain: &[u8]) -> Self {
87        let mut h = Sha256::new();
88        h.update(domain);
89        h.update(b"\x1f");
90        h.update(root_secret);
91        let derived: [u8; 32] = h.finalize().into();
92        let kp = AuditKeypair::from_secret(derived);
93        Self {
94            public: kp.public,
95            secret: *kp.secret_bytes(),
96        }
97    }
98}
99
100/// Domain tag for deriving the owner's envelope keypair from their ed25519 signing-key seed.
101pub const OWNER_ENVELOPE_DOMAIN: &[u8] = b"qualia:accountability:envelope:owner:v1";
102
103fn parse_key_hex(s: &str, which: &str) -> Result<[u8; 32], String> {
104    let bytes = hex::decode(s.trim()).map_err(|e| format!("{which} key not hex: {e}"))?;
105    bytes
106        .as_slice()
107        .try_into()
108        .map_err(|_| format!("{which} key must be 32 bytes, got {}", bytes.len()))
109}
110
111/// A fresh random DEK (four 64-bit draws from the OS RNG — the API `rand::random::<u64>()` in use elsewhere).
112fn random_dek() -> DataKey {
113    let mut k = [0u8; 32];
114    for chunk in k.chunks_mut(8) {
115        let r = rand::random::<u64>().to_le_bytes();
116        chunk.copy_from_slice(&r[..chunk.len()]);
117    }
118    k
119}
120
121/// **Seal a plaintext payload.** Generates a random DEK, AEAD-encrypts under it, and content-addresses the
122/// ciphertext. Returns the replicable [`EncryptedCommonsPayload`] and the DEK (to be sealed per recipient by
123/// [`wrap_dek_to`], then dropped — do not persist it in the clear).
124pub fn seal_payload(
125    plaintext: &[u8],
126    storers: Vec<String>,
127) -> Result<(EncryptedCommonsPayload, DataKey), String> {
128    let dek = random_dek();
129    let ciphertext =
130        wrap_key(&dek, plaintext, PAYLOAD_AAD).map_err(|e| format!("seal payload: {e:?}"))?;
131    let commitment: PayloadCommitment = Sha256::digest(&ciphertext).into();
132    Ok((
133        EncryptedCommonsPayload::new(commitment, ciphertext, storers),
134        dek,
135    ))
136}
137
138/// **Seal (wrap) a DEK to a recipient's public key** — the credential's `wrapped_key`. Only the holder of
139/// the matching secret can [`unwrap_dek`] it; destroying this blob (revocation) removes that access.
140pub fn wrap_dek_to(recipient_public: &[u8; 32], dek: &DataKey) -> Result<Vec<u8>, String> {
141    seal_to(recipient_public, dek, DEK_AAD).map_err(|e| format!("wrap DEK: {e:?}"))
142}
143
144/// **Unwrap a DEK** with the recipient's secret key. Fails for the wrong recipient or a tampered blob.
145pub fn unwrap_dek(recipient_secret: &[u8; 32], wrapped: &[u8]) -> Result<DataKey, String> {
146    let opened = open_sealed(recipient_secret, wrapped, DEK_AAD)
147        .map_err(|e| format!("unwrap DEK: {e:?}"))?;
148    opened
149        .as_slice()
150        .try_into()
151        .map_err(|_| "unwrapped DEK was not 32 bytes".to_string())
152}
153
154/// **Open a payload** with the DEK — verifies the content-address commitment (the bytes are the committed
155/// bytes) *then* AEAD-decrypts. Any tamper (to ciphertext or a swapped payload) fails.
156pub fn open_payload(payload: &EncryptedCommonsPayload, dek: &DataKey) -> Result<Vec<u8>, String> {
157    let recomputed: PayloadCommitment = Sha256::digest(&payload.ciphertext).into();
158    if recomputed != payload.commitment {
159        return Err("commitment mismatch — ciphertext is not the committed bytes".into());
160    }
161    unwrap_key(dek, &payload.ciphertext, PAYLOAD_AAD).map_err(|e| format!("open payload: {e:?}"))
162}
163
164/// **Open a payload directly from a recipient's secret + the credential's wrapped DEK.** The end-to-end
165/// decrypt path: unwrap the sealed DEK, then open the payload. If the wrapped DEK is absent (revoked), the
166/// caller has nothing to pass here — that is the crypto-enforced revocation.
167pub fn open_payload_with_wrapped(
168    payload: &EncryptedCommonsPayload,
169    recipient_secret: &[u8; 32],
170    wrapped_dek: &[u8],
171) -> Result<Vec<u8>, String> {
172    let dek = unwrap_dek(recipient_secret, wrapped_dek)?;
173    open_payload(payload, &dek)
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn seal_wrap_unwrap_open_round_trips() {
182        let owner = EnvelopeKeypair::generate().unwrap();
183        let agent = EnvelopeKeypair::generate().unwrap();
184        let plaintext = b"housing record: emergency placement requested 2026-07-06";
185
186        // Owner seals the payload and seals the DEK to the agent.
187        let (payload, dek) = seal_payload(plaintext, vec!["did:wf:person".into()]).unwrap();
188        assert_ne!(
189            payload.ciphertext.as_slice(),
190            plaintext,
191            "payload is really encrypted"
192        );
193        let wrapped = wrap_dek_to(&agent.public, &dek).unwrap();
194
195        // The agent (their secret) recovers the DEK and opens the payload.
196        let opened = open_payload_with_wrapped(&payload, &agent.secret, &wrapped).unwrap();
197        assert_eq!(
198            opened.as_slice(),
199            plaintext,
200            "agent decrypts the exact plaintext"
201        );
202
203        // The owner can also seal-to-self and open (data returns to the person).
204        let wrapped_self = wrap_dek_to(&owner.public, &dek).unwrap();
205        assert_eq!(
206            open_payload_with_wrapped(&payload, &owner.secret, &wrapped_self)
207                .unwrap()
208                .as_slice(),
209            plaintext
210        );
211    }
212
213    #[test]
214    fn wrong_recipient_cannot_unwrap_the_dek() {
215        let agent = EnvelopeKeypair::generate().unwrap();
216        let attacker = EnvelopeKeypair::generate().unwrap();
217        let (_payload, dek) = seal_payload(b"secret", vec![]).unwrap();
218        let wrapped = wrap_dek_to(&agent.public, &dek).unwrap();
219        // A different secret cannot open the sealed DEK.
220        assert!(
221            unwrap_dek(&attacker.secret, &wrapped).is_err(),
222            "only the intended recipient unwraps"
223        );
224    }
225
226    #[test]
227    fn revocation_destroying_the_wrapped_dek_makes_the_payload_unrecoverable() {
228        // Model the credential holding the wrapped DEK; revocation drops it. Without it there is no path to
229        // the DEK, so the ciphertext (however replicated) cannot be opened.
230        let agent = EnvelopeKeypair::generate().unwrap();
231        let (payload, dek) = seal_payload(
232            b"the record",
233            vec!["did:wf:person".into(), "did:wf:archive".into()],
234        )
235        .unwrap();
236        let wrapped = Some(wrap_dek_to(&agent.public, &dek).unwrap());
237
238        // Live: the agent opens it.
239        assert!(
240            open_payload_with_wrapped(&payload, &agent.secret, wrapped.as_ref().unwrap()).is_ok()
241        );
242
243        // Revoke: the wrapped DEK is destroyed. The payload bytes survive (still replicated) but there is
244        // nothing to unwrap — the DEK cannot be recovered, so the payload is crypto-shredded for this holder.
245        let wrapped: Option<Vec<u8>> = None;
246        assert!(wrapped.is_none());
247        assert!(
248            payload.is_durable(),
249            "the commons bytes are NOT chased down — they survive"
250        );
251        // With no wrapped DEK and no other key, an attempt with a guessed/zero DEK fails (AEAD).
252        assert!(
253            open_payload(&payload, &[0u8; 32]).is_err(),
254            "no key, no payload"
255        );
256    }
257
258    #[test]
259    fn a_tampered_ciphertext_is_rejected() {
260        let agent = EnvelopeKeypair::generate().unwrap();
261        let (mut payload, dek) = seal_payload(b"unaltered record", vec![]).unwrap();
262        let wrapped = wrap_dek_to(&agent.public, &dek).unwrap();
263        // Flip a byte in the ciphertext. The commitment now mismatches (content-address), and even past that
264        // the AEAD tag would fail.
265        let mid = payload.ciphertext.len() / 2;
266        payload.ciphertext[mid] ^= 0xFF;
267        assert!(
268            open_payload_with_wrapped(&payload, &agent.secret, &wrapped).is_err(),
269            "tamper is detected"
270        );
271    }
272
273    #[test]
274    fn a_swapped_ciphertext_breaks_the_commitment() {
275        let (mut a, _dek_a) = seal_payload(b"record A", vec![]).unwrap();
276        let (b, _dek_b) = seal_payload(b"record B", vec![]).unwrap();
277        // Substitute B's ciphertext under A's commitment — the content-address check catches it.
278        a.ciphertext = b.ciphertext;
279        assert!(
280            open_payload(&a, &[0u8; 32]).is_err(),
281            "commitment binds the ciphertext"
282        );
283    }
284
285    #[test]
286    fn derived_owner_keypair_is_deterministic_and_usable() {
287        let seed = [42u8; 32]; // stands in for the owner's ed25519 signing-key seed
288        let a = EnvelopeKeypair::derive(&seed, OWNER_ENVELOPE_DOMAIN);
289        let b = EnvelopeKeypair::derive(&seed, OWNER_ENVELOPE_DOMAIN);
290        assert_eq!(
291            a, b,
292            "same seed + domain ⇒ same keypair (re-derivable, nothing stored)"
293        );
294        // A different domain (or seed) gives an independent keypair.
295        assert_ne!(a, EnvelopeKeypair::derive(&seed, b"other:domain"));
296        assert_ne!(
297            a,
298            EnvelopeKeypair::derive(&[7u8; 32], OWNER_ENVELOPE_DOMAIN)
299        );
300        // And it actually works as an envelope key: seal to it, re-derive, open.
301        let (payload, dek) = seal_payload(b"owner-held record", vec![]).unwrap();
302        let wrapped = wrap_dek_to(&a.public, &dek).unwrap();
303        let rederived = EnvelopeKeypair::derive(&seed, OWNER_ENVELOPE_DOMAIN);
304        assert_eq!(
305            open_payload_with_wrapped(&payload, &rederived.secret, &wrapped)
306                .unwrap()
307                .as_slice(),
308            b"owner-held record"
309        );
310    }
311
312    #[test]
313    fn keypair_hex_round_trips() {
314        let kp = EnvelopeKeypair::generate().unwrap();
315        let back = EnvelopeKeypair::from_hex(&kp.public_hex(), &kp.secret_hex()).unwrap();
316        assert_eq!(kp, back);
317        assert!(EnvelopeKeypair::from_hex("zz", &kp.secret_hex()).is_err());
318    }
319}