Skip to main content

qualia_client_core/wellfair/api/
encryption.rs

1//! Envelope encryption + safeguard switches (dead-man, incapacity)
2
3use sha2::{Digest, Sha256};
4
5use super::*;
6
7impl WebizenHostApi {
8    // --- Real envelope encryption over the consent credential (ADR 0011 D1/D2) ---
9    //
10    // Makes "revoke destroys the wrapped key ⇒ no key, no payload" a *fact*: the payload is AEAD-encrypted
11    // under a random DEK; the DEK is sealed (X25519 sealed box) to the recipient's public key — that sealed
12    // DEK is the credential's real `wrapped_key`; revoke destroys it. The owner's envelope keypair is
13    // **derived** from the owner signing-key seed (nothing secret stored at rest). Native-only (the sealed-box
14    // primitives are `not(wasm32)`; the desktop owns keys).
15
16    /// The owner's envelope **public** key (hex) — publishable so others can seal payloads *to* the owner.
17    #[cfg(not(target_arch = "wasm32"))]
18    pub fn owner_envelope_public_hex(&self) -> String {
19        use crate::envelope_encryption::{EnvelopeKeypair, OWNER_ENVELOPE_DOMAIN};
20        EnvelopeKeypair::derive(&self.signing_key.to_bytes(), OWNER_ENVELOPE_DOMAIN).public_hex()
21    }
22
23    /// **Seal a plaintext payload and grant a consent credential over it** — real envelope encryption. If
24    /// `agent_public_hex` is empty, the payload is sealed to the OWNER's derived envelope key (self-custody,
25    /// so the owner can [`open_owner_payload`]); supply an agent's X25519 public key to grant *that* agent
26    /// access (they open it on their own device with their secret — the owner cannot).
27    ///
28    /// [`open_owner_payload`]: WebizenHostApi::open_owner_payload
29    #[cfg(not(target_arch = "wasm32"))]
30    pub fn seal_and_grant_consent_credential(
31        &self,
32        agent_did: &str,
33        agent_public_hex: &str,
34        scope: &str,
35        purpose: &str,
36        plaintext: &str,
37        expiry_unix: Option<u64>,
38    ) -> Result<crate::consent_credential::ConsentCredential, String> {
39        use crate::envelope_encryption::{EnvelopeKeypair, OWNER_ENVELOPE_DOMAIN};
40        let owner = EnvelopeKeypair::derive(&self.signing_key.to_bytes(), OWNER_ENVELOPE_DOMAIN);
41        let recipient_public: [u8; 32] = if agent_public_hex.trim().is_empty() {
42            owner.public
43        } else {
44            let bytes = hex::decode(agent_public_hex.trim())
45                .map_err(|e| format!("agent public key not hex: {e}"))?;
46            bytes
47                .as_slice()
48                .try_into()
49                .map_err(|_| "agent public key must be 32 bytes".to_string())?
50        };
51        let now = Self::now_unix();
52        let id = {
53            let d = Sha256::digest(format!("{agent_did}:{scope}:{now}").as_bytes());
54            format!("cc-{}", hex::encode(&d[..6]))
55        };
56        self.accountability_store()?
57            .seal_and_grant_credential(
58                id,
59                &self.owner_did,
60                agent_did,
61                scope,
62                purpose,
63                plaintext.as_bytes(),
64                &recipient_public,
65                vec![self.owner_did.clone()],
66                expiry_unix,
67                &self.signing_key,
68                now,
69            )
70            .map_err(|e| e.to_string())
71    }
72
73    /// **Open an owner-sealed payload** through a credential — proves the crypto-revoke property end-to-end:
74    /// works while the credential is live, fails once revoked (the wrapped key is gone), though the commons
75    /// ciphertext survives. Only opens payloads sealed to the owner (an agent-sealed payload opens on the
76    /// agent's device).
77    #[cfg(not(target_arch = "wasm32"))]
78    pub fn open_owner_payload(&self, credential_id: &str) -> Result<String, String> {
79        use crate::envelope_encryption::{EnvelopeKeypair, OWNER_ENVELOPE_DOMAIN};
80        let owner = EnvelopeKeypair::derive(&self.signing_key.to_bytes(), OWNER_ENVELOPE_DOMAIN);
81        let bytes = self
82            .accountability_store()?
83            .open_payload_via_credential(credential_id, &owner.secret, Self::now_unix())
84            .map_err(|e| e.to_string())?;
85        String::from_utf8(bytes).map_err(|e| format!("payload not valid utf-8: {e}"))
86    }
87
88    // --- Safeguard switches (ADR 0011 D6/D7): dead-man + incapacity, owner-signed into the ledger ---
89
90    /// Arm a **dead-man switch** over a payload (post-death disposition; gamified + reversible).
91    pub fn arm_dead_mans_switch(
92        &self,
93        switch: crate::dead_mans_switch::DeadMansSwitch,
94    ) -> Result<(), String> {
95        self.accountability_store()?
96            .arm_dead_mans_switch(switch, &self.signing_key, Self::now_unix())
97            .map_err(|e| e.to_string())
98    }
99
100    /// **I'm alive** — touch the heartbeat + un-fire a not-yet-enacted switch (reversibility). The routine
101    /// owner-side action that keeps a dead-man switch from firing.
102    pub fn dead_mans_alive(&self, commitment_hex: &str) -> Result<bool, String> {
103        let c = crate::accountability_store::parse_commitment_hex(commitment_hex)?;
104        self.accountability_store()?
105            .dead_mans_alive(&c, &self.signing_key, Self::now_unix())
106            .map_err(|e| e.to_string())
107    }
108
109    /// Record a **party attestation** toward a dead-man switch's gamified trigger.
110    pub fn attest_dead_mans(
111        &self,
112        commitment_hex: &str,
113        attestation: crate::dead_mans_switch::PartyAttestation,
114    ) -> Result<bool, String> {
115        let c = crate::accountability_store::parse_commitment_hex(commitment_hex)?;
116        self.accountability_store()?
117            .attest_dead_mans(&c, attestation, &self.signing_key, Self::now_unix())
118            .map_err(|e| e.to_string())
119    }
120
121    /// **Enact** a dead-man switch if the gamified rule holds — returns the [`Disposition`] to carry out.
122    ///
123    /// [`Disposition`]: crate::dead_mans_switch::Disposition
124    pub fn enact_dead_mans(
125        &self,
126        commitment_hex: &str,
127    ) -> Result<Option<crate::dead_mans_switch::Disposition>, String> {
128        let c = crate::accountability_store::parse_commitment_hex(commitment_hex)?;
129        self.accountability_store()?
130            .enact_dead_mans(&c, &self.signing_key, Self::now_unix())
131            .map_err(|e| e.to_string())
132    }
133
134    /// List armed dead-man switches (with accumulated attestations).
135    pub fn list_dead_mans_switches(
136        &self,
137    ) -> Result<Vec<crate::accountability_store::DeadMansSwitchRecord>, String> {
138        self.accountability_store()?
139            .list_dead_mans_switches()
140            .map_err(|e| e.to_string())
141    }
142
143    /// **Enact a dead-man switch AND release the keys** (ADR 0011 D6, key-release-on-enact). Recovers the
144    /// payload DEK by unwrapping the owner's own credential, then — for a `ReleaseTo` disposition — re-seals
145    /// the DEK to each supplied party X25519 pubkey and grants them a credential, so the disposition actually
146    /// hands over access. `party_keys` = `(did, pubkey_hex)` pairs. (The owner key is derivable here; the true
147    /// post-death friend-side release without the owner needs Shamir pre-positioning — separate.)
148    #[cfg(not(target_arch = "wasm32"))]
149    pub fn enact_dead_mans_release(
150        &self,
151        commitment_hex: &str,
152        party_keys_hex: Vec<(String, String)>,
153    ) -> Result<serde_json::Value, String> {
154        use crate::envelope_encryption::{unwrap_dek, EnvelopeKeypair, OWNER_ENVELOPE_DOMAIN};
155        let c = crate::accountability_store::parse_commitment_hex(commitment_hex)?;
156        let now = Self::now_unix();
157        let store = self.accountability_store()?;
158        let owner = EnvelopeKeypair::derive(&self.signing_key.to_bytes(), OWNER_ENVELOPE_DOMAIN);
159        // Recover the DEK by unwrapping the owner's own credential for this payload.
160        let wrapped = store
161            .wrapped_key_for(&c, &self.owner_did, now)
162            .map_err(|e| e.to_string())?
163            .ok_or_else(|| {
164                "no owner credential holds the DEK for this payload (seal it to yourself first)"
165                    .to_string()
166            })?;
167        let dek = unwrap_dek(&owner.secret, &wrapped)?;
168        let mut party_keys: Vec<(String, [u8; 32])> = Vec::new();
169        for (did, pk_hex) in party_keys_hex {
170            let bytes =
171                hex::decode(pk_hex.trim()).map_err(|e| format!("party key not hex: {e}"))?;
172            let pk: [u8; 32] = bytes
173                .as_slice()
174                .try_into()
175                .map_err(|_| "party key must be 32 bytes".to_string())?;
176            party_keys.push((did, pk));
177        }
178        let disposition = store
179            .enact_dead_mans_release(
180                &c,
181                &dek,
182                &party_keys,
183                &self.owner_did,
184                &self.signing_key,
185                now,
186            )
187            .map_err(|e| e.to_string())?;
188        Ok(serde_json::json!({ "enacted": disposition.is_some(), "disposition": disposition }))
189    }
190
191    /// **Split a payload's DEK into Shamir social-recovery shares** (`threshold`-of-`parties.len()`), so a
192    /// quorum of friends can later reconstruct the key **without the owner**. Recovers the DEK from the owner's
193    /// own credential, splits it, and returns the shares paired with the parties they should be handed to
194    /// (the caller distributes them off-device — they are NOT stored here). Owner-side, done while alive.
195    #[cfg(not(target_arch = "wasm32"))]
196    pub fn split_dek_recovery(
197        &self,
198        commitment_hex: &str,
199        threshold: usize,
200        parties: Vec<String>,
201    ) -> Result<serde_json::Value, String> {
202        use crate::envelope_encryption::{unwrap_dek, EnvelopeKeypair, OWNER_ENVELOPE_DOMAIN};
203        let c = crate::accountability_store::parse_commitment_hex(commitment_hex)?;
204        let now = Self::now_unix();
205        let store = self.accountability_store()?;
206        let owner = EnvelopeKeypair::derive(&self.signing_key.to_bytes(), OWNER_ENVELOPE_DOMAIN);
207        let wrapped = store
208            .wrapped_key_for(&c, &self.owner_did, now)
209            .map_err(|e| e.to_string())?
210            .ok_or_else(|| "no owner credential holds the DEK for this payload".to_string())?;
211        let dek = unwrap_dek(&owner.secret, &wrapped)?;
212        let shares = crate::shamir_recovery::split(&dek, threshold, parties.len())?;
213        let tagged: Vec<serde_json::Value> = parties
214            .iter()
215            .zip(shares.iter())
216            .map(|(party, share)| serde_json::json!({ "party": party, "share": share }))
217            .collect();
218        Ok(serde_json::json!({ "threshold": threshold, "shares": tagged }))
219    }
220
221    /// **Social-recovery enactment (no owner key):** given a quorum of friends' Shamir shares, reconstruct the
222    /// DEK, enact the dead-man switch, and release to the disposition parties. `party_keys` = `(did, pubkey_hex)`.
223    #[cfg(not(target_arch = "wasm32"))]
224    pub fn reconstruct_and_release(
225        &self,
226        commitment_hex: &str,
227        shares: Vec<crate::shamir_recovery::Share>,
228        party_keys_hex: Vec<(String, String)>,
229    ) -> Result<serde_json::Value, String> {
230        let c = crate::accountability_store::parse_commitment_hex(commitment_hex)?;
231        let now = Self::now_unix();
232        let mut party_keys: Vec<(String, [u8; 32])> = Vec::new();
233        for (did, pk_hex) in party_keys_hex {
234            let bytes =
235                hex::decode(pk_hex.trim()).map_err(|e| format!("party key not hex: {e}"))?;
236            let pk: [u8; 32] = bytes
237                .as_slice()
238                .try_into()
239                .map_err(|_| "party key must be 32 bytes".to_string())?;
240            party_keys.push((did, pk));
241        }
242        let disposition = self
243            .accountability_store()?
244            .reconstruct_and_release(
245                &c,
246                &shares,
247                &party_keys,
248                &self.owner_did,
249                &self.signing_key,
250                now,
251            )
252            .map_err(|e| e.to_string())?;
253        Ok(serde_json::json!({ "enacted": disposition.is_some(), "disposition": disposition }))
254    }
255
256    /// Publish a **peer's envelope (X25519) public key** into their peer record, so releases to that party
257    /// can resolve the key automatically (remote-key distribution). The owner's own publishable key is
258    /// [`owner_envelope_public_hex`](Self::owner_envelope_public_hex).
259    pub fn set_peer_envelope_key(&self, did: &str, pubkey_hex: &str) -> Result<(), String> {
260        crate::social_peers::set_peer_envelope_key(did, pubkey_hex)
261    }
262
263    /// **Enact + release resolving the disposition parties' keys from the peer store** (remote-key
264    /// distribution). Reads the switch's `ReleaseTo` parties, looks up each one's published envelope key from
265    /// `social_peers`, and releases to those with a known key — reporting any parties whose key is still
266    /// missing (so the owner knows to obtain it). No keys pasted by hand.
267    #[cfg(not(target_arch = "wasm32"))]
268    pub fn enact_dead_mans_release_via_peers(
269        &self,
270        commitment_hex: &str,
271    ) -> Result<serde_json::Value, String> {
272        let c = crate::accountability_store::parse_commitment_hex(commitment_hex)?;
273        let switches = self
274            .accountability_store()?
275            .list_dead_mans_switches()
276            .map_err(|e| e.to_string())?;
277        let rec = switches
278            .iter()
279            .find(|r| r.switch.payload_commitment == c)
280            .ok_or_else(|| "no dead-man switch for that commitment".to_string())?;
281        let parties = match &rec.switch.disposition {
282            crate::dead_mans_switch::Disposition::ReleaseTo { parties } => parties.clone(),
283            _ => Vec::new(),
284        };
285        let peers = crate::social_peers::list_peers();
286        let resolved = crate::social_peers::resolve_envelope_keys(&peers, &parties);
287        let have: std::collections::BTreeSet<&str> =
288            resolved.iter().map(|(d, _)| d.as_str()).collect();
289        let missing: Vec<String> = parties
290            .iter()
291            .filter(|d| !have.contains(d.as_str()))
292            .cloned()
293            .collect();
294        let result = self.enact_dead_mans_release(commitment_hex, resolved)?;
295        Ok(serde_json::json!({ "result": result, "missing_keys_for": missing }))
296    }
297
298    /// Arm an **incapacity switch** (advocate activation on validated, reversible incapacity).
299    pub fn arm_incapacity_switch(
300        &self,
301        switch: crate::incapacity_switch::IncapacitySwitch,
302    ) -> Result<(), String> {
303        self.accountability_store()?
304            .arm_incapacity_switch(switch, &self.signing_key, Self::now_unix())
305            .map_err(|e| e.to_string())
306    }
307
308    /// **Activate** advocacy if the corroborated trigger holds (quorum + optional official instrument).
309    pub fn activate_incapacity(
310        &self,
311        principal_did: &str,
312        attesting_parties: Vec<String>,
313        official_instrument: Option<String>,
314    ) -> Result<bool, String> {
315        self.accountability_store()?
316            .activate_incapacity(
317                principal_did,
318                &attesting_parties,
319                official_instrument.as_deref(),
320                &self.signing_key,
321                Self::now_unix(),
322            )
323            .map_err(|e| e.to_string())
324    }
325
326    /// **Regain capacity** — the advocate stands down (reversibility).
327    pub fn regain_capacity(&self, principal_did: &str) -> Result<bool, String> {
328        self.accountability_store()?
329            .regain_capacity(principal_did, &self.signing_key, Self::now_unix())
330            .map_err(|e| e.to_string())
331    }
332
333    /// List armed incapacity switches.
334    pub fn list_incapacity_switches(
335        &self,
336    ) -> Result<Vec<crate::incapacity_switch::IncapacitySwitch>, String> {
337        self.accountability_store()?
338            .list_incapacity_switches()
339            .map_err(|e| e.to_string())
340    }
341}