Skip to main content

ma_core/config/
secrets.rs

1//! Secret bundle: four standard 32-byte keys plus optional user-defined keys,
2//! all stored encrypted on disk in a single file.
3//!
4//! # Extensibility
5//!
6//! [`SecretBundle`] exposes [`add_key`](SecretBundle::add_key),
7//! [`get_key`](SecretBundle::get_key), and
8//! [`generate_key`](SecretBundle::generate_key) so that daemons can persist
9//! any number of additional named 32-byte keys in the same bundle. All keys
10//! survive restart cycles through the normal [`SecretBundle::save`] /
11//! [`SecretBundle::load`] cycle.
12//!
13//! Key names are arbitrary UTF-8 strings; the four standard names (`iroh`,
14//! `ipns`, `did_signing`, `did_encryption`) are reserved.
15//!
16//! # On-disk format
17//!
18//! ```text
19//! [16 bytes  Argon2id salt]
20//! [12 bytes  ChaCha20-Poly1305 nonce]
21//! [ciphertext (JSON plaintext below) + 16 bytes Poly1305 auth tag]
22//! ```
23//!
24//! The plaintext is a UTF-8 JSON object where every value is a standard
25//! base64-encoded 32-byte key. The four standard keys use fixed field names;
26//! all extra keys live under a nested `"extra"` object:
27//!
28//! ```json
29//! {
30//!   "iroh":           "<base64>",
31//!   "ipns":           "<base64>",
32//!   "did_signing":    "<base64>",
33//!   "did_encryption": "<base64>",
34//!   "extra": {
35//!     "my_service": "<base64>",
36//!     "other_key":  "<base64>"
37//!   }
38//! }
39//! ```
40//!
41//! Key derivation uses Argon2id with default OWASP-minimum parameters
42//! (m=19456, t=2, p=1), producing a 32-byte ChaCha20-Poly1305 encryption key.
43//!
44//! On `wasm32`, use [`SecretBundle::encrypt`] / [`SecretBundle::decrypt`] with
45//! application-managed storage (e.g. IndexedDB/localStorage). File-based
46//! [`SecretBundle::load`] / [`SecretBundle::save`] are native-only.
47
48use std::collections::HashMap;
49
50use argon2::Argon2;
51use base64::engine::general_purpose::STANDARD as B64;
52use base64::Engine;
53use chacha20poly1305::{aead::Aead, ChaCha20Poly1305, KeyInit};
54use rand::RngCore;
55use serde::{Deserialize, Serialize};
56use zeroize::Zeroize;
57
58use crate::error::{Error, Result};
59
60// Reserved key names – may not be used as extra key names.
61const RESERVED: &[&str] = &["iroh", "ipns", "did_signing", "did_encryption"];
62
63// ─── Wire format (JSON) ──────────────────────────────────────────────────────
64
65#[derive(Serialize, Deserialize)]
66struct BundleJson {
67    iroh: String,
68    ipns: String,
69    did_signing: String,
70    did_encryption: String,
71    #[serde(default)]
72    extra: HashMap<String, String>,
73}
74
75// ─── Public struct ───────────────────────────────────────────────────────────
76
77/// Standard and user-defined 32-byte secret keys for a ma daemon identity.
78///
79/// All key material is zeroed from memory when this struct is dropped.
80///
81/// # Adding custom keys
82///
83/// ```
84/// # #[cfg(all(feature = "config", not(target_arch = "wasm32")))]
85/// # {
86/// use ma_core::config::SecretBundle;
87///
88/// // Generate a fresh bundle.
89/// let mut bundle = SecretBundle::generate();
90///
91/// // Generate and store a new random key:
92/// bundle.generate_key("my_service_key")?;
93///
94/// // Or store an existing 32-byte key:
95/// let key_bytes = [0u8; 32];
96/// bundle.add_key("other_key", key_bytes)?;
97///
98/// // Retrieve it:
99/// let key = bundle.get_key("my_service_key").expect("key not found");
100///
101/// // Encrypt in-memory and decrypt again:
102/// let encrypted = bundle.encrypt("passphrase")?;
103/// let restored = SecretBundle::decrypt(&encrypted, "passphrase")?;
104/// assert_eq!(bundle.iroh_secret_key, restored.iroh_secret_key);
105/// # }
106/// # Ok::<(), ma_core::Error>(())
107/// ```
108pub struct SecretBundle {
109    /// iroh QUIC transport secret key.
110    pub iroh_secret_key: [u8; 32],
111    /// IPNS publishing secret key.
112    pub ipns_secret_key: [u8; 32],
113    /// DID document signing key (Ed25519).
114    pub did_signing_key: [u8; 32],
115    /// DID document encryption key (X25519).
116    pub did_encryption_key: [u8; 32],
117
118    /// User-defined extra keys. Names must not collide with the four reserved
119    /// standard key names.
120    extra_keys: HashMap<String, [u8; 32]>,
121}
122
123impl Drop for SecretBundle {
124    fn drop(&mut self) {
125        self.iroh_secret_key.zeroize();
126        self.ipns_secret_key.zeroize();
127        self.did_signing_key.zeroize();
128        self.did_encryption_key.zeroize();
129        for v in self.extra_keys.values_mut() {
130            v.zeroize();
131        }
132    }
133}
134
135impl Clone for SecretBundle {
136    fn clone(&self) -> Self {
137        Self {
138            iroh_secret_key: self.iroh_secret_key,
139            ipns_secret_key: self.ipns_secret_key,
140            did_signing_key: self.did_signing_key,
141            did_encryption_key: self.did_encryption_key,
142            extra_keys: self.extra_keys.clone(),
143        }
144    }
145}
146
147impl SecretBundle {
148    /// Generate a new bundle with four random standard keys and no extra keys.
149    pub fn generate() -> Self {
150        let mut rng = rand::rngs::OsRng;
151        let mut b = Self {
152            iroh_secret_key: [0u8; 32],
153            ipns_secret_key: [0u8; 32],
154            did_signing_key: [0u8; 32],
155            did_encryption_key: [0u8; 32],
156            extra_keys: HashMap::new(),
157        };
158        rng.fill_bytes(&mut b.iroh_secret_key);
159        rng.fill_bytes(&mut b.ipns_secret_key);
160        rng.fill_bytes(&mut b.did_signing_key);
161        rng.fill_bytes(&mut b.did_encryption_key);
162        b
163    }
164
165    // ─── Extra key management ────────────────────────────────────────────────
166
167    /// Store a named 32-byte key in this bundle.
168    ///
169    /// Returns an error if `name` collides with a reserved standard key name
170    /// or is empty.
171    pub fn add_key(&mut self, name: &str, key: [u8; 32]) -> Result<()> {
172        validate_key_name(name)?;
173        self.extra_keys.insert(name.to_string(), key);
174        Ok(())
175    }
176
177    /// Generate a random 32-byte key, store it under `name`, and return it.
178    ///
179    /// Returns an error if `name` is invalid (see [`add_key`](Self::add_key)).
180    pub fn generate_key(&mut self, name: &str) -> Result<[u8; 32]> {
181        validate_key_name(name)?;
182        let mut key = [0u8; 32];
183        rand::rngs::OsRng.fill_bytes(&mut key);
184        self.extra_keys.insert(name.to_string(), key);
185        Ok(key)
186    }
187
188    /// Retrieve a named extra key, or `None` if it does not exist.
189    pub fn get_key(&self, name: &str) -> Option<&[u8; 32]> {
190        self.extra_keys.get(name)
191    }
192
193    /// Remove a named extra key from the bundle.
194    pub fn remove_key(&mut self, name: &str) -> Option<[u8; 32]> {
195        self.extra_keys.remove(name)
196    }
197
198    /// Iterate over all extra key names.
199    pub fn extra_key_names(&self) -> impl Iterator<Item = &str> {
200        self.extra_keys.keys().map(String::as_str)
201    }
202
203    // ─── JSON serialization ──────────────────────────────────────────────────
204
205    fn to_json_bytes(&self) -> Result<Vec<u8>> {
206        let wire = BundleJson {
207            iroh: B64.encode(self.iroh_secret_key),
208            ipns: B64.encode(self.ipns_secret_key),
209            did_signing: B64.encode(self.did_signing_key),
210            did_encryption: B64.encode(self.did_encryption_key),
211            extra: self
212                .extra_keys
213                .iter()
214                .map(|(k, v)| (k.clone(), B64.encode(v)))
215                .collect(),
216        };
217        serde_json::to_vec(&wire).map_err(|e| Error::Secrets(e.to_string()))
218    }
219
220    fn from_json_bytes(mut data: Vec<u8>) -> Result<Self> {
221        let wire: BundleJson = serde_json::from_slice(&data)
222            .map_err(|e| Error::Secrets(format!("failed to parse bundle JSON: {e}")))?;
223
224        data.zeroize();
225
226        let decode = |s: &str, field: &str| -> Result<[u8; 32]> {
227            let bytes = B64
228                .decode(s)
229                .map_err(|e| Error::Secrets(format!("base64 decode error in '{field}': {e}")))?;
230            bytes
231                .as_slice()
232                .try_into()
233                .map_err(|_| Error::Secrets(format!("'{field}' must be exactly 32 bytes")))
234        };
235
236        let mut extra_keys = HashMap::with_capacity(wire.extra.len());
237        for (name, val) in &wire.extra {
238            extra_keys.insert(name.clone(), decode(val, name)?);
239        }
240
241        Ok(Self {
242            iroh_secret_key: decode(&wire.iroh, "iroh")?,
243            ipns_secret_key: decode(&wire.ipns, "ipns")?,
244            did_signing_key: decode(&wire.did_signing, "did_signing")?,
245            did_encryption_key: decode(&wire.did_encryption, "did_encryption")?,
246            extra_keys,
247        })
248    }
249
250    // ─── Encryption / decryption ─────────────────────────────────────────────
251
252    /// Encrypt this bundle with `passphrase` and return the binary blob.
253    ///
254    /// A fresh random salt and nonce are generated for each call.
255    pub fn encrypt(&self, passphrase: &str) -> Result<Vec<u8>> {
256        let mut salt = [0u8; 16];
257        rand::rngs::OsRng.fill_bytes(&mut salt);
258
259        let mut key_bytes = [0u8; 32];
260        Argon2::default()
261            .hash_password_into(passphrase.as_bytes(), &salt, &mut key_bytes)
262            .map_err(|e| Error::Secrets(e.to_string()))?;
263
264        let mut nonce_bytes = [0u8; 12];
265        rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
266        let nonce = *chacha20poly1305::Nonce::from_slice(&nonce_bytes);
267
268        let cipher = ChaCha20Poly1305::new_from_slice(&key_bytes)
269            .map_err(|e| Error::Secrets(e.to_string()))?;
270
271        let mut plaintext = self.to_json_bytes()?;
272        let ciphertext = cipher
273            .encrypt(&nonce, plaintext.as_slice())
274            .map_err(|e| Error::Secrets(e.to_string()))?;
275
276        plaintext.zeroize();
277        key_bytes.zeroize();
278
279        let mut out = Vec::with_capacity(16 + 12 + ciphertext.len());
280        out.extend_from_slice(&salt);
281        out.extend_from_slice(&nonce_bytes);
282        out.extend_from_slice(&ciphertext);
283        Ok(out)
284    }
285
286    /// Decrypt a bundle from the on-disk binary format.
287    ///
288    /// Returns `Err(Error::Secrets)` on authentication failure (wrong
289    /// passphrase or corrupted data) without revealing which it was.
290    pub fn decrypt(data: &[u8], passphrase: &str) -> Result<Self> {
291        if data.len() < 28 {
292            return Err(Error::Secrets("secret bundle too short".to_string()));
293        }
294
295        let salt = &data[0..16];
296        let nonce_bytes: [u8; 12] = data[16..28]
297            .try_into()
298            .map_err(|_| Error::Secrets("malformed bundle nonce".to_string()))?;
299        let ciphertext = &data[28..];
300
301        let mut key_bytes = [0u8; 32];
302        Argon2::default()
303            .hash_password_into(passphrase.as_bytes(), salt, &mut key_bytes)
304            .map_err(|e| Error::Secrets(e.to_string()))?;
305
306        let nonce = *chacha20poly1305::Nonce::from_slice(&nonce_bytes);
307        let cipher = ChaCha20Poly1305::new_from_slice(&key_bytes)
308            .map_err(|e| Error::Secrets(e.to_string()))?;
309        let plaintext = cipher
310            .decrypt(&nonce, ciphertext)
311            .map_err(|_| Error::Secrets("decryption failed (wrong passphrase?)".to_string()))?;
312
313        key_bytes.zeroize();
314
315        Self::from_json_bytes(plaintext)
316    }
317
318    /// Load and decrypt a bundle from a file.
319    #[cfg(not(target_arch = "wasm32"))]
320    pub fn load(path: &std::path::Path, passphrase: &str) -> Result<Self> {
321        let data = std::fs::read(path)
322            .map_err(|e| Error::Secrets(format!("failed to read {}: {e}", path.display())))?;
323        Self::decrypt(&data, passphrase)
324    }
325
326    /// Encrypt this bundle and write it to `path` with 0600 permissions.
327    #[cfg(not(target_arch = "wasm32"))]
328    pub fn save(&self, path: &std::path::Path, passphrase: &str) -> Result<()> {
329        let encrypted = self.encrypt(passphrase)?;
330        super::write_secure(path, &encrypted)
331    }
332
333    /// Generate a random alphanumeric passphrase (43 characters ≈ 256 bits entropy).
334    pub fn generate_passphrase() -> String {
335        use rand::distributions::{Alphanumeric, DistString};
336        Alphanumeric.sample_string(&mut rand::rngs::OsRng, 43)
337    }
338
339    /// Derive the DID identity deterministically from all four bundle keys.
340    ///
341    /// Unlike [`crate::generate_identity_from_secret`] this method uses the
342    /// bundle's own `did_signing_key` and `did_encryption_key` instead of
343    /// generating fresh random keys, so the resulting document is identical
344    /// on every call with the same bundle — safe to use across daemon restarts.
345    ///
346    /// Verification method IDs use fixed fragments `#sign` and `#enc`.
347    pub fn generate_identity(&self) -> Result<crate::GeneratedIdentity> {
348        use crate::{
349            identity::build_identity_from_keys, ipns_from_secret, Did, EncryptionKey, SigningKey,
350        };
351        let ipns = ipns_from_secret(self.ipns_secret_key)
352            .map_err(|e| Error::Secrets(format!("ipns derivation failed: {e}")))?;
353        let sign_did = Did::new_url(&ipns, Some("sign"))
354            .map_err(|e| Error::Secrets(format!("sign did: {e}")))?;
355        let enc_did = Did::new_url(&ipns, Some("enc"))
356            .map_err(|e| Error::Secrets(format!("enc did: {e}")))?;
357        let signing_key = SigningKey::from_private_key_bytes(sign_did, self.did_signing_key)
358            .map_err(|e| Error::Secrets(format!("signing key: {e}")))?;
359        let encryption_key =
360            EncryptionKey::from_private_key_bytes(enc_did, self.did_encryption_key)
361                .map_err(|e| Error::Secrets(format!("encryption key: {e}")))?;
362        build_identity_from_keys(&ipns, &signing_key, &encryption_key)
363            .map_err(|e| Error::Secrets(format!("identity generation failed: {e}")))
364    }
365
366    /// Build a complete, signed [`crate::Document`] from this bundle and a
367    /// [`crate::MaExtension`].
368    ///
369    /// This is the recommended single entry point for constructing a
370    /// ready-to-publish DID document:
371    ///
372    /// 1. Generates the deterministic base identity from the bundle keys.
373    /// 2. Applies the caller-supplied extension (services, type, custom fields).
374    /// 3. Re-signs the document so the proof covers the extension data.
375    ///
376    /// # Example
377    ///
378    /// ```ignore
379    /// let ma = endpoint.ma_extension().kind("world");
380    /// let document = bundle.build_document(ma)?;
381    /// ```
382    pub fn build_document(&self, ext: crate::doc::MaExtension) -> Result<crate::Document> {
383        use crate::{ipns_from_secret, Did, SigningKey};
384        let identity = self.generate_identity()?;
385        let mut document = identity.document;
386        let ipns = ipns_from_secret(self.ipns_secret_key)
387            .map_err(|e| Error::Secrets(format!("ipns derivation: {e}")))?;
388        let sign_did = Did::new_url(&ipns, Some("sign"))
389            .map_err(|e| Error::Secrets(format!("sign did: {e}")))?;
390        let signing_key = SigningKey::from_private_key_bytes(sign_did, self.did_signing_key)
391            .map_err(|e| Error::Secrets(format!("signing key: {e}")))?;
392        let vm = document
393            .get_verification_method_by_id(&document.assertion_method[0].clone())
394            .map_err(|e| Error::Secrets(format!("assertion vm: {e}")))?;
395        let vm = vm.clone();
396        document.set_ma_extension(ext);
397        document
398            .sign(&signing_key, &vm)
399            .map_err(|e| Error::Secrets(format!("sign: {e}")))?;
400        Ok(document)
401    }
402
403    /// Derive the [`crate::SigningKey`] for this bundle.
404    ///
405    /// The returned key matches the `#sign` verification method in any document
406    /// produced by [`Self::build_document`] or [`Self::generate_identity`].
407    /// Use it to sign [`crate::Message`] objects after the document is built.
408    pub fn signing_key(&self) -> Result<crate::SigningKey> {
409        use crate::{ipns_from_secret, Did, SigningKey};
410        let ipns = ipns_from_secret(self.ipns_secret_key)
411            .map_err(|e| Error::Secrets(format!("ipns derivation: {e}")))?;
412        let sign_did = Did::new_url(&ipns, Some("sign"))
413            .map_err(|e| Error::Secrets(format!("sign did: {e}")))?;
414        SigningKey::from_private_key_bytes(sign_did, self.did_signing_key)
415            .map_err(|e| Error::Secrets(format!("signing key: {e}")))
416    }
417}
418
419// ─── Helpers ─────────────────────────────────────────────────────────────────
420
421fn validate_key_name(name: &str) -> Result<()> {
422    if name.is_empty() {
423        return Err(Error::Secrets("key name must not be empty".to_string()));
424    }
425    if RESERVED.contains(&name) {
426        return Err(Error::Secrets(format!(
427            "key name '{name}' is reserved for a standard key"
428        )));
429    }
430    Ok(())
431}
432
433// ─── Tests ───────────────────────────────────────────────────────────────────
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn roundtrip_standard_keys() {
441        let bundle = SecretBundle::generate();
442        let passphrase = "test-passphrase-1234";
443        let encrypted = bundle.encrypt(passphrase).unwrap();
444        let restored = SecretBundle::decrypt(&encrypted, passphrase).unwrap();
445        assert_eq!(bundle.iroh_secret_key, restored.iroh_secret_key);
446        assert_eq!(bundle.ipns_secret_key, restored.ipns_secret_key);
447        assert_eq!(bundle.did_signing_key, restored.did_signing_key);
448        assert_eq!(bundle.did_encryption_key, restored.did_encryption_key);
449    }
450
451    #[test]
452    fn roundtrip_with_extra_keys() {
453        let mut bundle = SecretBundle::generate();
454        bundle.generate_key("my_service").unwrap();
455        bundle.generate_key("another_key").unwrap();
456
457        let passphrase = "extra-keys-test";
458        let encrypted = bundle.encrypt(passphrase).unwrap();
459        let restored = SecretBundle::decrypt(&encrypted, passphrase).unwrap();
460
461        assert_eq!(bundle.get_key("my_service"), restored.get_key("my_service"));
462        assert_eq!(
463            bundle.get_key("another_key"),
464            restored.get_key("another_key")
465        );
466    }
467
468    #[test]
469    fn reserved_name_rejected() {
470        let mut bundle = SecretBundle::generate();
471        assert!(bundle.add_key("iroh", [0u8; 32]).is_err());
472        assert!(bundle.add_key("did_signing", [0u8; 32]).is_err());
473    }
474
475    #[test]
476    fn empty_name_rejected() {
477        let mut bundle = SecretBundle::generate();
478        assert!(bundle.add_key("", [0u8; 32]).is_err());
479    }
480
481    #[test]
482    fn wrong_passphrase_fails() {
483        let bundle = SecretBundle::generate();
484        let encrypted = bundle.encrypt("correct").unwrap();
485        assert!(SecretBundle::decrypt(&encrypted, "wrong").is_err());
486    }
487}