Skip to main content

qualia_core_db/identity/
key_vault.rs

1use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
2use sha2::{Digest, Sha256};
3use std::fs;
4use std::path::Path;
5
6fn generate_master_secret() -> Result<[u8; 32], String> {
7    let mut secret = [0u8; 32];
8    getrandom::fill(&mut secret).map_err(|e| format!("OS RNG failed: {e}"))?;
9    Ok(secret)
10}
11
12#[cfg(not(target_arch = "wasm32"))]
13fn keyring_entry_for(storage_dir: &str) -> Result<keyring::Entry, String> {
14    let mut hasher = Sha256::new();
15    hasher.update(storage_dir.as_bytes());
16    let digest = hasher.finalize();
17    let username = format!("master_{}", hex::encode(&digest[..8]));
18    keyring::Entry::new("qualia_db", &username).map_err(|e| format!("Keyring error: {e}"))
19}
20
21/// High-level Key Management module for the Qualia Node.
22pub struct KeyVault {
23    master_key: Option<SigningKey>,
24    storage_dir: Option<String>,
25}
26
27impl KeyVault {
28    /// Creates an in-memory KeyVault with a fresh ephemeral key (for tests/stubs only).
29    pub fn new() -> Self {
30        use sha2::{Digest, Sha256};
31        let mut hasher = Sha256::new();
32        hasher.update(b"ephemeral");
33        let result = hasher.finalize();
34        let mut secret = [0u8; 32];
35        secret.copy_from_slice(&result);
36        Self {
37            master_key: Some(SigningKey::from_bytes(&secret)),
38            storage_dir: None,
39        }
40    }
41
42    pub fn is_locked(&self) -> bool {
43        self.master_key.is_none()
44    }
45
46    pub fn lock(&mut self) {
47        self.master_key = None;
48    }
49
50    #[cfg(not(target_arch = "wasm32"))]
51    pub fn unlock(&mut self) -> Result<(), String> {
52        let dir = self
53            .storage_dir
54            .as_ref()
55            .ok_or("No storage dir configured")?;
56        let temp = Self::load_or_generate(dir)?;
57        self.master_key = temp.master_key;
58        Ok(())
59    }
60
61    #[cfg(target_arch = "wasm32")]
62    pub fn unlock(&mut self) -> Result<(), String> {
63        let temp = Self::load_or_generate("")?;
64        self.master_key = temp.master_key;
65        Ok(())
66    }
67
68    #[cfg(not(target_arch = "wasm32"))]
69    pub fn load_or_generate(storage_dir: &str) -> Result<Self, String> {
70        let vault_path = Path::new(storage_dir).join("keystore.bin");
71        let entry = keyring_entry_for(storage_dir)?;
72
73        let master_key = match entry.get_password() {
74            Ok(secret_hex) => {
75                let bytes =
76                    hex::decode(secret_hex).map_err(|e| format!("Invalid hex in keyring: {e}"))?;
77                if bytes.len() != 32 {
78                    return Err("Corrupted master key length in keyring".into());
79                }
80                let mut secret = [0u8; 32];
81                secret.copy_from_slice(&bytes[0..32]);
82                SigningKey::from_bytes(&secret)
83            }
84            Err(_) => {
85                if vault_path.exists() {
86                    let bytes = fs::read(&vault_path)
87                        .map_err(|e| format!("Failed to read keystore: {e}"))?;
88                    if bytes.len() != 32 {
89                        return Err("Corrupted master key length".into());
90                    }
91                    let secret_hex = hex::encode(&bytes[0..32]);
92                    let _ = entry.set_password(&secret_hex);
93                    let mut secret = [0u8; 32];
94                    secret.copy_from_slice(&bytes[0..32]);
95                    SigningKey::from_bytes(&secret)
96                } else {
97                    let secret = generate_master_secret()?;
98                    let new_key = SigningKey::from_bytes(&secret);
99                    let secret_hex = hex::encode(secret);
100                    let _ = entry.set_password(&secret_hex);
101                    if let Some(parent) = vault_path.parent() {
102                        fs::create_dir_all(parent)
103                            .map_err(|e| format!("Failed to create keystore dir: {e}"))?;
104                    }
105                    fs::write(&vault_path, secret)
106                        .map_err(|e| format!("Failed to write keystore: {e}"))?;
107                    new_key
108                }
109            }
110        };
111
112        Ok(Self {
113            master_key: Some(master_key),
114            storage_dir: Some(storage_dir.to_string()),
115        })
116    }
117
118    #[cfg(target_arch = "wasm32")]
119    pub fn load_or_generate(storage_dir: &str) -> Result<Self, String> {
120        let vault_path = Path::new(storage_dir).join("keystore.bin");
121
122        let master_key = if vault_path.exists() {
123            let bytes =
124                fs::read(&vault_path).map_err(|e| format!("Failed to read keystore: {}", e))?;
125            if bytes.len() != 32 {
126                return Err("Corrupted master key length".into());
127            }
128            let mut secret = [0u8; 32];
129            secret.copy_from_slice(&bytes[0..32]);
130            SigningKey::from_bytes(&secret)
131        } else {
132            let secret = generate_master_secret()?;
133            if let Some(parent) = vault_path.parent() {
134                if !parent.as_os_str().is_empty() {
135                    fs::create_dir_all(parent)
136                        .map_err(|e| format!("Failed to create keystore dir: {e}"))?;
137                }
138            }
139            if !storage_dir.is_empty() {
140                fs::write(&vault_path, secret)
141                    .map_err(|e| format!("Failed to write keystore: {e}"))?;
142            }
143            SigningKey::from_bytes(&secret)
144        };
145
146        Ok(Self {
147            master_key: Some(master_key),
148            storage_dir: Some(storage_dir.to_string()),
149        })
150    }
151
152    /// Derives a deterministic Pairwise or Front Door key from the Master Key.
153    /// This ensures we can recover all DIDs from the single master root.
154    pub fn derive_key(&self, context_id: &str) -> SigningKey {
155        let master_key = self.master_key.as_ref().expect("Vault is locked");
156        let mut hasher = Sha256::new();
157        hasher.update(master_key.to_bytes());
158        hasher.update(context_id.as_bytes());
159        let result = hasher.finalize();
160
161        let mut child_secret = [0u8; 32];
162        child_secret.copy_from_slice(&result);
163        SigningKey::from_bytes(&child_secret)
164    }
165
166    /// Computes an Ed25519 signature over a generic byte payload
167    pub fn sign_payload(&self, signing_key: &SigningKey, payload: &[u8]) -> Signature {
168        signing_key.sign(payload)
169    }
170
171    /// Exposes the raw bytes of the master key for libp2p identity bindings
172    pub fn get_master_key_bytes(&self) -> [u8; 32] {
173        self.master_key
174            .as_ref()
175            .expect("Vault is locked")
176            .to_bytes()
177    }
178
179    /// Ed25519 verifying key bytes for a context-derived pairwise key.
180    pub fn public_key_bytes_for_context(&self, context_id: &str) -> [u8; 32] {
181        VerifyingKey::from(&self.derive_key(context_id)).to_bytes()
182    }
183
184    /// Verifies a payload against a given public key bytes
185    pub fn verify_signature(
186        public_key_bytes: &[u8; 32],
187        payload: &[u8],
188        signature_bytes: &[u8; 64],
189    ) -> Result<(), String> {
190        let verifying_key =
191            VerifyingKey::from_bytes(public_key_bytes).map_err(|_| "Invalid public key")?;
192        let signature = Signature::from_bytes(signature_bytes);
193
194        verifying_key
195            .verify(payload, &signature)
196            .map_err(|_| "Invalid signature".to_string())
197    }
198
199    /// Generates a WebID-TLS / mTLS compatible self-signed X.509 certificate.
200    ///
201    /// The DID URI is placed in the Subject Alternative Name URI extension so
202    /// transport identity can be bound to the Qualia principal. Uses real `rcgen`
203    /// PEM output (not a mock). Private key material is the provided Ed25519 seed
204    /// encoded as PKCS#8 PEM.
205    pub fn generate_webid_tls_cert(
206        &self,
207        key: &SigningKey,
208        did_uri: &str,
209    ) -> Result<(String, String), String> {
210        generate_webid_tls_cert_for_seed(&key.to_bytes(), did_uri)
211    }
212}
213
214/// PKCS#8 PEM + self-signed cert for an Ed25519 seed and DID SAN URI.
215pub fn generate_webid_tls_cert_for_seed(
216    seed: &[u8; 32],
217    did_uri: &str,
218) -> Result<(String, String), String> {
219    use rcgen::{
220        CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, SanType,
221    };
222
223    if did_uri.trim().is_empty() {
224        return Err("did_uri must not be empty".into());
225    }
226
227    let key_pem = format_ed25519_pkcs8_pem(seed);
228    let key_pair =
229        KeyPair::from_pem(&key_pem).map_err(|e| format!("rcgen KeyPair::from_pem: {e}"))?;
230
231    let mut params =
232        CertificateParams::new(Vec::<String>::new()).map_err(|e| format!("cert params: {e}"))?;
233    params.distinguished_name.push(DnType::CommonName, did_uri);
234    params.subject_alt_names = vec![SanType::URI(
235        did_uri.try_into().map_err(|e| format!("SAN URI: {e}"))?,
236    )];
237    params.is_ca = IsCa::NoCa;
238    params.key_usages = vec![
239        KeyUsagePurpose::DigitalSignature,
240        KeyUsagePurpose::KeyEncipherment,
241    ];
242    params.extended_key_usages = vec![
243        ExtendedKeyUsagePurpose::ClientAuth,
244        ExtendedKeyUsagePurpose::ServerAuth,
245    ];
246
247    let cert = params
248        .self_signed(&key_pair)
249        .map_err(|e| format!("self_signed: {e}"))?;
250    Ok((cert.pem(), key_pair.serialize_pem()))
251}
252
253#[cfg(test)]
254mod webid_tls_tests {
255    use super::*;
256
257    #[test]
258    fn webid_tls_cert_is_real_pem_with_did_san() {
259        let seed = [9u8; 32];
260        let did = "did:q42:person:aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899";
261        let (cert, key) = generate_webid_tls_cert_for_seed(&seed, did).expect("cert");
262        assert!(cert.contains("BEGIN CERTIFICATE"));
263        assert!(key.contains("BEGIN PRIVATE KEY") || key.contains("BEGIN"));
264        assert!(!cert.contains("MIIMOCK"));
265    }
266}
267
268/// RFC 8410 PKCS#8 for an Ed25519 private key seed (32 bytes).
269fn format_ed25519_pkcs8_pem(seed: &[u8; 32]) -> String {
270    // PrivateKeyInfo ::= SEQUENCE {
271    //   version                   Version, -- 0
272    //   privateKeyAlgorithm       AlgorithmIdentifier, -- id-Ed25519
273    //   privateKey                OCTET STRING, -- OCTET STRING of 32-byte seed
274    // }
275    let mut inner_octet = Vec::with_capacity(2 + 32);
276    inner_octet.push(0x04); // OCTET STRING
277    inner_octet.push(32);
278    inner_octet.extend_from_slice(seed);
279
280    let mut body = Vec::new();
281    // version INTEGER 0
282    body.extend_from_slice(&[0x02, 0x01, 0x00]);
283    // AlgorithmIdentifier SEQUENCE { OID 1.3.101.112 }
284    body.extend_from_slice(&[0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70]);
285    // privateKey OCTET STRING wrapping inner OCTET STRING
286    body.push(0x04);
287    body.push(inner_octet.len() as u8);
288    body.extend_from_slice(&inner_octet);
289
290    let mut der = Vec::new();
291    der.push(0x30);
292    der.push(body.len() as u8);
293    der.extend_from_slice(&body);
294
295    use base64::Engine as _;
296    let b64 = base64::engine::general_purpose::STANDARD.encode(&der);
297    let mut pem = String::from("-----BEGIN PRIVATE KEY-----\n");
298    for chunk in b64.as_bytes().chunks(64) {
299        pem.push_str(std::str::from_utf8(chunk).unwrap_or(""));
300        pem.push('\n');
301    }
302    pem.push_str("-----END PRIVATE KEY-----\n");
303    pem
304}
305
306impl KeyVault {
307    /// Issues a cryptographically signed Semantic Token for an installed qapp.
308    /// The token enforces gatekeeper boundary policies (which shapes the qapp can access).
309    pub fn issue_qapp_token(
310        &self,
311        qapp_did: &str,
312        audience: &str,
313        expiry_epoch: u64,
314        nonce: &str,
315        capabilities: Vec<String>,
316        sensitivity_clearance: SubgraphLayer,
317    ) -> Result<String, String> {
318        let payload = QappSessionTokenV2 {
319            qapp_did: qapp_did.to_string(),
320            expiry_epoch,
321            audience: audience.to_string(),
322            nonce: nonce.to_string(),
323            capabilities,
324            sensitivity_clearance,
325        };
326        let payload_json =
327            serde_json::to_string(&payload).map_err(|e| format!("Serialization error: {}", e))?;
328
329        let master_key = self.master_key.as_ref().expect("Vault is locked");
330        let signature = self.sign_payload(master_key, payload_json.as_bytes());
331        let signature_hex = hex::encode(signature.to_bytes());
332        let payload_hex = hex::encode(payload_json.as_bytes());
333
334        // Token format: payload_hex.signature_hex
335        Ok(format!("{}.{}", payload_hex, signature_hex))
336    }
337
338    /// Verifies a qapp token's signature using the Master Key, and checks expiry and audience.
339    pub fn verify_qapp_token(
340        &self,
341        token: &str,
342        expected_audience: &str,
343    ) -> Result<QappSessionTokenV2, String> {
344        let parts: Vec<&str> = token.split('.').collect();
345        if parts.len() != 2 {
346            return Err("Invalid semantic token format".into());
347        }
348
349        let payload_bytes =
350            hex::decode(parts[0]).map_err(|_| "Invalid payload hex representation".to_string())?;
351        let signature_bytes = hex::decode(parts[1])
352            .map_err(|_| "Invalid signature hex representation".to_string())?;
353
354        if signature_bytes.len() != 64 {
355            return Err("Invalid signature byte length".into());
356        }
357        let mut sig_array = [0u8; 64];
358        sig_array.copy_from_slice(&signature_bytes);
359
360        let master_key = self.master_key.as_ref().expect("Vault is locked");
361        let verifying_key = VerifyingKey::from(master_key);
362        let signature = Signature::from_bytes(&sig_array);
363        verifying_key
364            .verify(&payload_bytes, &signature)
365            .map_err(|_| "Invalid token signature".to_string())?;
366
367        let payload: QappSessionTokenV2 = serde_json::from_slice(&payload_bytes)
368            .map_err(|e| format!("Failed to parse token payload: {}", e))?;
369
370        let now = std::time::SystemTime::now()
371            .duration_since(std::time::UNIX_EPOCH)
372            .map_err(|_| "Time went backwards")?
373            .as_secs();
374
375        if payload.expiry_epoch < now {
376            return Err("Token expired".into());
377        }
378
379        if payload.audience != expected_audience {
380            return Err("Token audience mismatch".into());
381        }
382
383        Ok(payload)
384    }
385}
386
387#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
388pub struct QappSessionTokenV2 {
389    pub qapp_did: String,
390    pub expiry_epoch: u64,
391    pub audience: String,
392    pub nonce: String,
393    pub capabilities: Vec<String>,
394    pub sensitivity_clearance: SubgraphLayer,
395}
396
397// ── Credential-gated subgraph layer encryption ───────────────────────────────
398
399/// Named sensitivity tiers for credential-gated subgraph views.
400///
401/// Each layer has a dedicated AES-256-GCM key derived from the node's master key.
402/// Access is gated by the deontic engine evaluating the agent's VCs against the
403/// layer's ODRL policy before releasing the key.
404#[derive(
405    serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord,
406)]
407#[repr(u8)]
408pub enum SubgraphLayer {
409    Public = 0,
410    Professional = 1,
411    Legal = 2,
412    Medical = 3,
413    Fiduciary = 4,
414}
415
416impl SubgraphLayer {
417    /// HKDF info label used for key derivation — must stay stable across versions.
418    pub fn label(self) -> &'static str {
419        match self {
420            Self::Public => "qualia:subgraph:layer:public",
421            Self::Professional => "qualia:subgraph:layer:professional",
422            Self::Legal => "qualia:subgraph:layer:legal",
423            Self::Medical => "qualia:subgraph:layer:medical",
424            Self::Fiduciary => "qualia:subgraph:layer:fiduciary",
425        }
426    }
427
428    /// Minimum sensitivity metadata bits[59:56] required to reach this layer.
429    pub fn sensitivity_tier(self) -> u8 {
430        self as u8
431    }
432}
433
434/// A 32-byte AES-256-GCM subgraph key bound to a specific `SubgraphLayer`.
435pub struct SubgraphKey {
436    layer: SubgraphLayer,
437    key_bytes: [u8; 32],
438}
439
440impl std::fmt::Debug for SubgraphKey {
441    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
442        f.debug_struct("SubgraphKey")
443            .field("layer", &self.layer)
444            .field("key_bytes", &"[REDACTED]")
445            .finish()
446    }
447}
448
449impl zeroize::Zeroize for SubgraphKey {
450    fn zeroize(&mut self) {
451        self.key_bytes.zeroize();
452    }
453}
454
455impl Drop for SubgraphKey {
456    fn drop(&mut self) {
457        use zeroize::Zeroize;
458        self.key_bytes.zeroize();
459    }
460}
461
462impl SubgraphKey {
463    /// Raw key bytes — use only to pass to an AES-GCM cipher; do not persist unencrypted.
464    #[inline]
465    pub fn raw(&self) -> &[u8; 32] {
466        &self.key_bytes
467    }
468
469    pub fn layer(&self) -> SubgraphLayer {
470        self.layer
471    }
472}
473
474/// An X25519 ECDH-encapsulated subgraph key (key wrapped for a specific recipient DID).
475///
476/// `ephemeral_public` is the sender's ephemeral X25519 public key (32 bytes).
477/// `ciphertext` is the 32-byte layer key XOR-masked with the ECDH shared secret.
478///
479/// The recipient computes `shared = X25519(their_static_private, ephemeral_public)`,
480/// then `layer_key = ciphertext XOR shared`, then verifies with `mac`.
481#[derive(Debug, Clone)]
482pub struct EncapsulatedKey {
483    pub layer: SubgraphLayer,
484    pub ephemeral_public: [u8; 32],
485    /// `AES-256-GCM ciphertext` of the 32-byte layer key (32 + 16-byte tag = 48 bytes).
486    pub ciphertext: [u8; 48],
487    /// Nonce used for the AES-GCM wrap (12 bytes).
488    pub nonce: [u8; 12],
489}
490
491impl KeyVault {
492    /// Derive a deterministic AES-256-GCM key for `layer` using HKDF-SHA-256.
493    ///
494    /// IKM  = master ed25519 secret key bytes (32 bytes)
495    /// Salt = b"qualia:subgraph:salt:v1"
496    /// Info = `layer.label()` bytes
497    pub fn generate_layer_key(&self, layer: SubgraphLayer) -> SubgraphKey {
498        use hkdf::Hkdf;
499        use sha2::Sha256;
500
501        let master_key = self.master_key.as_ref().expect("Vault is locked");
502        let ikm = master_key.to_bytes();
503        let salt: &[u8] = b"qualia:subgraph:salt:v1";
504        let hk = Hkdf::<Sha256>::new(Some(salt), &ikm);
505
506        let mut key_bytes = [0u8; 32];
507        hk.expand(layer.label().as_bytes(), &mut key_bytes)
508            .expect("HKDF expand: 32 bytes always fits");
509
510        SubgraphKey { layer, key_bytes }
511    }
512
513    /// Encapsulate `layer_key` for a recipient identified by their X25519 public key bytes.
514    ///
515    /// Uses ephemeral X25519 ECDH + AES-256-GCM to wrap the 32-byte layer key.
516    /// The `recipient_x25519_pub` is typically derived from the recipient's DID key material.
517    ///
518    /// # Errors
519    /// Returns `Err` if the recipient public key bytes are invalid.
520    pub fn encapsulate_for_recipient(
521        &self,
522        layer_key: &SubgraphKey,
523        recipient_x25519_pub: &[u8; 32],
524        nonce_entropy: &[u8; 32],
525    ) -> Result<EncapsulatedKey, String> {
526        use aes_gcm::{aead::Aead, Aes256Gcm, KeyInit};
527        use x25519_dalek::PublicKey;
528
529        // Derive ephemeral X25519 keypair from nonce_entropy (deterministic for tests).
530        let ephemeral_scalar = {
531            let mut h = Sha256::new();
532            h.update(b"qualia:ecdh:ephemeral:");
533            h.update(nonce_entropy);
534            h.update(layer_key.layer().label().as_bytes());
535            let digest = h.finalize();
536            let mut scalar = [0u8; 32];
537            scalar.copy_from_slice(&digest);
538            scalar
539        };
540
541        // Build ephemeral static secret from the scalar bytes.
542        let ephemeral_secret = x25519_dalek::StaticSecret::from(ephemeral_scalar);
543        let ephemeral_public = PublicKey::from(&ephemeral_secret);
544
545        // ECDH shared secret.
546        let recipient_pub = PublicKey::from(*recipient_x25519_pub);
547        let shared_secret = ephemeral_secret.diffie_hellman(&recipient_pub);
548
549        // Derive AES-GCM key from ECDH shared secret via SHA-256.
550        let wrap_key_bytes = {
551            let mut h = Sha256::new();
552            h.update(shared_secret.as_bytes());
553            h.update(b":qualia:wrap:");
554            h.update(layer_key.layer().label().as_bytes());
555            h.finalize()
556        };
557
558        // Derive 12-byte AES-GCM nonce from nonce_entropy.
559        let nonce_bytes: [u8; 12] = {
560            let mut h = Sha256::new();
561            h.update(b"qualia:nonce:");
562            h.update(nonce_entropy);
563            let d = h.finalize();
564            let mut n = [0u8; 12];
565            n.copy_from_slice(&d[..12]);
566            n
567        };
568
569        let cipher =
570            Aes256Gcm::new_from_slice(&wrap_key_bytes).map_err(|_| "AES-GCM key init failed")?;
571        let aes_nonce = aes_gcm::Nonce::try_from(nonce_bytes.as_slice()).unwrap();
572
573        let ct_vec = cipher
574            .encrypt(&aes_nonce, layer_key.raw().as_ref())
575            .map_err(|_| "AES-GCM encryption failed")?;
576
577        let mut ciphertext = [0u8; 48];
578        if ct_vec.len() != 48 {
579            return Err(format!("unexpected ciphertext length {}", ct_vec.len()));
580        }
581        ciphertext.copy_from_slice(&ct_vec);
582
583        Ok(EncapsulatedKey {
584            layer: layer_key.layer(),
585            ephemeral_public: *ephemeral_public.as_bytes(),
586            ciphertext,
587            nonce: nonce_bytes,
588        })
589    }
590
591    /// Decapsulate an `EncapsulatedKey` using the recipient's X25519 static secret key bytes.
592    ///
593    /// Returns the 32-byte layer key on success.
594    pub fn decapsulate(
595        &self,
596        encapsulated: &EncapsulatedKey,
597        recipient_x25519_secret: &[u8; 32],
598    ) -> Result<SubgraphKey, String> {
599        use aes_gcm::{aead::Aead, Aes256Gcm, KeyInit};
600        use x25519_dalek::{PublicKey, StaticSecret};
601
602        let secret = StaticSecret::from(*recipient_x25519_secret);
603        let ephemeral_pub = PublicKey::from(encapsulated.ephemeral_public);
604        let shared_secret = secret.diffie_hellman(&ephemeral_pub);
605
606        let wrap_key_bytes = {
607            let mut h = Sha256::new();
608            h.update(shared_secret.as_bytes());
609            h.update(b":qualia:wrap:");
610            h.update(encapsulated.layer.label().as_bytes());
611            h.finalize()
612        };
613
614        let cipher =
615            Aes256Gcm::new_from_slice(&wrap_key_bytes).map_err(|_| "AES-GCM key init failed")?;
616        let nonce = aes_gcm::Nonce::try_from(encapsulated.nonce.as_slice()).unwrap();
617
618        let plaintext = cipher
619            .decrypt(&nonce, encapsulated.ciphertext.as_ref())
620            .map_err(|_| "AES-GCM decryption failed — wrong key or tampered ciphertext")?;
621
622        if plaintext.len() != 32 {
623            return Err(format!("unexpected plaintext length {}", plaintext.len()));
624        }
625        let mut key_bytes = [0u8; 32];
626        key_bytes.copy_from_slice(&plaintext);
627
628        Ok(SubgraphKey {
629            layer: encapsulated.layer,
630            key_bytes,
631        })
632    }
633
634    /// Derive the X25519 static secret for this node from the master Ed25519 key.
635    ///
636    /// Used when the node itself is a VC recipient.
637    pub fn derive_x25519_secret(&self) -> [u8; 32] {
638        let master_key = self.master_key.as_ref().expect("Vault is locked");
639        let mut h = Sha256::new();
640        h.update(master_key.to_bytes());
641        h.update(b"qualia:x25519:static");
642        h.finalize().into()
643    }
644}
645
646#[cfg(test)]
647mod subgraph_key_tests {
648    use super::*;
649
650    fn test_vault() -> KeyVault {
651        let tmp = tempfile::tempdir().expect("tmpdir");
652        KeyVault::load_or_generate(tmp.path().to_str().unwrap()).expect("vault")
653    }
654
655    #[test]
656    fn layer_key_derivation_is_deterministic() {
657        let vault = test_vault();
658        let k1 = vault.generate_layer_key(SubgraphLayer::Medical);
659        let k2 = vault.generate_layer_key(SubgraphLayer::Medical);
660        assert_eq!(k1.raw(), k2.raw());
661    }
662
663    #[test]
664    fn different_layers_produce_different_keys() {
665        let vault = test_vault();
666        let med = vault.generate_layer_key(SubgraphLayer::Medical);
667        let leg = vault.generate_layer_key(SubgraphLayer::Legal);
668        let fid = vault.generate_layer_key(SubgraphLayer::Fiduciary);
669        assert_ne!(med.raw(), leg.raw());
670        assert_ne!(leg.raw(), fid.raw());
671        assert_ne!(med.raw(), fid.raw());
672    }
673
674    #[test]
675    fn encapsulate_decapsulate_roundtrip() {
676        let vault = test_vault();
677        let layer_key = vault.generate_layer_key(SubgraphLayer::Fiduciary);
678
679        // Recipient's X25519 keys.
680        let recipient_secret = vault.derive_x25519_secret();
681        let recipient_pub = {
682            use x25519_dalek::{PublicKey, StaticSecret};
683            let s = StaticSecret::from(recipient_secret);
684            *PublicKey::from(&s).as_bytes()
685        };
686
687        let nonce_entropy = [0x42u8; 32];
688        let encapsulated = vault
689            .encapsulate_for_recipient(&layer_key, &recipient_pub, &nonce_entropy)
690            .expect("encapsulate");
691
692        let recovered = vault
693            .decapsulate(&encapsulated, &recipient_secret)
694            .expect("decapsulate");
695
696        assert_eq!(recovered.raw(), layer_key.raw());
697        assert_eq!(recovered.layer(), SubgraphLayer::Fiduciary);
698    }
699
700    #[test]
701    fn lock_and_unlock_roundtrip() {
702        let tmp = tempfile::tempdir().expect("tmpdir");
703        let path = tmp.path().to_str().unwrap();
704        let mut vault = KeyVault::load_or_generate(path).expect("vault");
705        assert!(!vault.is_locked());
706
707        let before = vault.get_master_key_bytes();
708        vault.lock();
709        assert!(vault.is_locked());
710
711        vault.unlock().expect("unlock");
712        assert!(!vault.is_locked());
713        assert_eq!(vault.get_master_key_bytes(), before);
714    }
715
716    #[test]
717    fn decapsulate_wrong_key_fails() {
718        let vault = test_vault();
719        let layer_key = vault.generate_layer_key(SubgraphLayer::Legal);
720
721        let recipient_secret = vault.derive_x25519_secret();
722        let recipient_pub = {
723            use x25519_dalek::{PublicKey, StaticSecret};
724            let s = StaticSecret::from(recipient_secret);
725            *PublicKey::from(&s).as_bytes()
726        };
727
728        let nonce_entropy = [0x99u8; 32];
729        let encapsulated = vault
730            .encapsulate_for_recipient(&layer_key, &recipient_pub, &nonce_entropy)
731            .expect("encapsulate");
732
733        let wrong_secret = [0xFFu8; 32];
734        let result = vault.decapsulate(&encapsulated, &wrong_secret);
735        assert!(result.is_err());
736    }
737}