Skip to main content

ma_core/
identity.rs

1//! Identity bootstrap helpers.
2//!
3//! - DID generation from secrets (via `generate_identity`, `generate_identity_from_secret`)
4//! - Persisted 32-byte secret key management for endpoint identity across restarts
5
6use std::fs;
7use std::net::{IpAddr, SocketAddr};
8use std::path::Path;
9
10use libp2p_identity::PeerId;
11
12use crate::error::{Error, Result};
13use crate::{Did, Document, EncryptionKey, MaError, SigningKey, VerificationMethod};
14
15// ─── DID identity generation (from ma-did) ──────────────────────────────────
16
17/// A generated DID identity with keys and a signed document.
18///
19/// Private keys are hex-encoded for storage. Use [`SigningKey::from_private_key_bytes`]
20/// and [`EncryptionKey::from_private_key_bytes`] to reconstruct key objects.
21#[derive(Debug, Clone)]
22pub struct GeneratedIdentity {
23    pub subject_url: Did,
24    pub document: Document,
25    pub signing_private_key_hex: String,
26    pub encryption_private_key_hex: String,
27}
28
29fn build_identity(ipns: &str) -> Result<GeneratedIdentity> {
30    let sign_url = Did::new_url(ipns, None::<String>).map_err(Error::Validation)?;
31    let enc_url = Did::new_url(ipns, None::<String>).map_err(Error::Validation)?;
32
33    let signing_key = SigningKey::generate(sign_url).map_err(Error::Validation)?;
34    let encryption_key = EncryptionKey::generate(enc_url).map_err(Error::Validation)?;
35
36    build_identity_from_keys(ipns, &signing_key, &encryption_key)
37}
38
39/// Build a [`GeneratedIdentity`] from caller-supplied signing and encryption keys.
40///
41/// Uses fixed well-known fragments (`"sign"` / `"enc"`) for the verification
42/// method IDs so that the resulting document is identical on every call with
43/// the same inputs — no random nanoids, no per-call divergence.
44///
45/// This is the correct building block when restoring an identity from a
46/// [`SecretBundle`](crate::config::SecretBundle): pass
47/// `bundle.did_signing_key` and `bundle.did_encryption_key` and get back the
48/// same document every time.
49pub(crate) fn build_identity_from_keys(
50    ipns: &str,
51    signing_key: &SigningKey,
52    encryption_key: &EncryptionKey,
53) -> Result<GeneratedIdentity> {
54    let subject_url = Did::new_identity(ipns).map_err(Error::Validation)?;
55
56    let mut document = Document::new(&subject_url, &subject_url);
57
58    // Use fixed fragments so the VM IDs are stable across restarts.
59    let assertion_vm = VerificationMethod::new(
60        subject_url.base_id(),
61        subject_url.base_id(),
62        signing_key.key_type.clone(),
63        "sign",
64        signing_key.public_key_multibase.clone(),
65    )
66    .map_err(Error::Validation)?;
67
68    let key_agreement_vm = VerificationMethod::new(
69        subject_url.base_id(),
70        subject_url.base_id(),
71        encryption_key.key_type.clone(),
72        "enc",
73        encryption_key.public_key_multibase.clone(),
74    )
75    .map_err(Error::Validation)?;
76
77    let assertion_vm_id = assertion_vm.id.clone();
78    document
79        .add_verification_method(assertion_vm.clone())
80        .map_err(Error::Validation)?;
81    document
82        .add_verification_method(key_agreement_vm.clone())
83        .map_err(Error::Validation)?;
84    document.assertion_method = vec![assertion_vm_id];
85    document.key_agreement = vec![key_agreement_vm.id.clone()];
86    document
87        .sign(signing_key, &assertion_vm)
88        .map_err(Error::Validation)?;
89
90    Ok(GeneratedIdentity {
91        subject_url,
92        document,
93        signing_private_key_hex: hex::encode(signing_key.private_key_bytes()),
94        encryption_private_key_hex: hex::encode(encryption_key.private_key_bytes()),
95    })
96}
97
98/// Derive the `did:ma` IPNS identifier from a caller-managed Ed25519 secret.
99pub fn ipns_from_secret(secret: [u8; 32]) -> Result<String> {
100    let keypair = libp2p_identity::Keypair::ed25519_from_bytes(secret)
101        .map_err(|_| Error::Validation(MaError::InvalidIdentitySecret))?;
102    let peer_id = PeerId::from_public_key(&keypair.public());
103    Ok(peer_id.to_string())
104}
105
106/// Generate a base DID identity with keys and a signed document.
107pub fn generate_identity(ipns: &str) -> Result<GeneratedIdentity> {
108    build_identity(ipns)
109}
110
111/// Generate a base DID identity where the `did:ma` IPNS identifier is derived
112/// from a caller-managed Ed25519 secret.
113pub fn generate_identity_from_secret(secret: [u8; 32]) -> Result<GeneratedIdentity> {
114    let ipns = ipns_from_secret(secret)?;
115    build_identity(&ipns)
116}
117
118// ─── Secret key file helpers ─────────────────────────────────────────────────
119
120/// Load a secret key from a 32-byte file on disk.
121///
122/// Returns `Ok(None)` if the file does not exist.
123pub fn load_secret_key_bytes(path: &Path) -> Result<Option<[u8; 32]>> {
124    if !path.exists() {
125        return Ok(None);
126    }
127
128    let bytes = fs::read(path).map_err(|e| Error::SecretKey(e.to_string()))?;
129    let key_bytes: [u8; 32] = bytes
130        .as_slice()
131        .try_into()
132        .map_err(|_| Error::SecretKey(format!("invalid key file length in {}", path.display())))?;
133
134    Ok(Some(key_bytes))
135}
136
137/// Generate a new random 32-byte secret key and write it to disk.
138///
139/// Fails if the file already exists (to prevent accidental overwrites).
140/// Uses OS-level secure file permissions via `crate::secure_fs` when
141/// compiled as part of a crate that provides it, otherwise writes directly.
142pub fn generate_secret_key_file(path: &Path) -> Result<[u8; 32]> {
143    if path.exists() {
144        return Err(Error::SecretKey(format!(
145            "secret key already exists at {}",
146            path.display()
147        )));
148    }
149
150    let mut key_bytes = [0u8; 32];
151    use rand::RngCore;
152    rand::rngs::OsRng.fill_bytes(&mut key_bytes);
153
154    // Ensure parent directory exists
155    if let Some(parent) = path.parent() {
156        fs::create_dir_all(parent).map_err(|e| {
157            Error::SecretKey(format!("failed to create dir {}: {}", parent.display(), e))
158        })?;
159    }
160
161    fs::write(path, key_bytes)
162        .map_err(|e| Error::SecretKey(format!("failed to write {}: {}", path.display(), e)))?;
163
164    // Best-effort permission hardening on Unix
165    #[cfg(unix)]
166    {
167        use std::os::unix::fs::PermissionsExt;
168        let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o400));
169    }
170
171    Ok(key_bytes)
172}
173
174/// Convert a socket address to a multiaddr string (QUIC-v1 over UDP).
175pub fn socket_addr_to_multiaddr(addr: &SocketAddr) -> String {
176    match addr.ip() {
177        IpAddr::V4(ip) => format!("/ip4/{}/udp/{}/quic-v1", ip, addr.port()),
178        IpAddr::V6(ip) => format!("/ip6/{}/udp/{}/quic-v1", ip, addr.port()),
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use std::net::{Ipv4Addr, Ipv6Addr};
186    use std::path::PathBuf;
187
188    fn test_tmp_file(name: &str) -> PathBuf {
189        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
190            .join("tmp")
191            .join("identity-tests");
192        fs::create_dir_all(&root).expect("failed creating test tmp directory");
193        root.join(name)
194    }
195
196    #[test]
197    fn multiaddr_ipv4() {
198        let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4433);
199        assert_eq!(
200            socket_addr_to_multiaddr(&addr),
201            "/ip4/127.0.0.1/udp/4433/quic-v1"
202        );
203    }
204
205    #[test]
206    fn multiaddr_ipv6() {
207        let addr = SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 5555);
208        assert_eq!(socket_addr_to_multiaddr(&addr), "/ip6/::1/udp/5555/quic-v1");
209    }
210
211    #[test]
212    fn load_missing_returns_none() {
213        let path = test_tmp_file("nonexistent-key");
214        let _ = fs::remove_file(&path);
215        assert!(load_secret_key_bytes(&path).unwrap().is_none());
216    }
217
218    #[test]
219    fn generate_and_load_round_trip() {
220        let path = test_tmp_file("round-trip-key");
221        let _ = fs::remove_file(&path);
222
223        let generated = generate_secret_key_file(&path).unwrap();
224        let loaded = load_secret_key_bytes(&path).unwrap().unwrap();
225        assert_eq!(generated, loaded);
226
227        // Cleanup
228        let _ = fs::remove_file(&path);
229    }
230
231    #[test]
232    fn generate_refuses_overwrite() {
233        let path = test_tmp_file("no-overwrite-key");
234        let _ = fs::remove_file(&path);
235
236        generate_secret_key_file(&path).unwrap();
237        let err = generate_secret_key_file(&path).unwrap_err();
238        assert!(matches!(err, crate::error::Error::SecretKey(_)));
239
240        // Cleanup
241        let _ = fs::remove_file(&path);
242    }
243}