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