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