1use 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
41const PAYLOAD_AAD: &[u8] = b"qualia:accountability:payload:v1";
43const DEK_AAD: &[u8] = b"qualia:accountability:dek:v1";
45
46pub type DataKey = [u8; 32];
48
49#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct EnvelopeKeypair {
53 pub public: [u8; 32],
54 pub secret: [u8; 32],
55}
56
57impl EnvelopeKeypair {
58 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 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 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
100pub 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
111fn 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
121pub 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
138pub 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
144pub 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
154pub 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
164pub 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 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 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 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 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 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 assert!(
240 open_payload_with_wrapped(&payload, &agent.secret, wrapped.as_ref().unwrap()).is_ok()
241 );
242
243 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 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 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 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]; 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 assert_ne!(a, EnvelopeKeypair::derive(&seed, b"other:domain"));
296 assert_ne!(
297 a,
298 EnvelopeKeypair::derive(&[7u8; 32], OWNER_ENVELOPE_DOMAIN)
299 );
300 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}