Skip to main content

macula_rust/
identity.rs

1//! Ed25519 identity and the S/Kademlia crypto puzzle, matching macula's
2//! own `macula_identity.erl` (`macula-io/macula`).
3//!
4//! Uses `ed25519-dalek` (with the `rand_core` feature) — the same crate
5//! macula's own `macula_crypto_nif` Rust NIF already wraps in production,
6//! not a separate crypto implementation, though the two are not required
7//! to track the same `ed25519-dalek` version: Ed25519 signing is
8//! deterministic per RFC 8032, so a byte-identical seed/message pair must
9//! produce a byte-identical signature across any correct implementation,
10//! any version. Every keypair/sign/verify test in this module is checked
11//! against fixtures captured directly from the real `crypto:generate_key/2`
12//! and `crypto:sign/4` in `macula-io/macula`'s own `rebar3 shell`, not just
13//! hand-derived expectations — which is exactly what lets this crate move
14//! ahead of the NIF's own `ed25519-dalek` pin without losing that proof.
15//!
16//! A macula NodeId **is** an Ed25519 public key (32 bytes) — there is no
17//! separate account/identity layer underneath it. Identities are
18//! optionally "puzzle-hardened": ground until `SHA-256(pubkey)` has at
19//! least `N` leading zero bits (S/Kademlia Sybil defense — this raises
20//! the cost of *minting* identities in bulk, not of connecting with one
21//! that already exists). Grinding is a one-time cost paid once per
22//! identity, not per connection: `puzzle_evidence` is a cheap,
23//! deterministic hash computed fresh on every `CONNECT` frame, and
24//! `puzzle_valid` is a cheap check, not a proof-of-work re-verification.
25//!
26//! **Every station checks this on every CONNECT/HELLO, for every kind of
27//! dialer — this is not a station-to-station-only concern.** Skipping it
28//! produces a real, previously-observed failure mode: the QUIC/TLS
29//! connection reports healthy, but the station silently rejects the
30//! application-layer HELLO, so the link looks connected while delivering
31//! nothing. Always use [`KeyPair::generate_with_puzzle`], never
32//! [`KeyPair::generate`], for any identity that will actually dial a
33//! station.
34
35use std::fmt;
36use std::fs;
37use std::io;
38use std::path::Path;
39
40use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
41use sha2::{Digest, Sha256};
42
43use crate::keystore::{KeyStore, KeyStoreError};
44
45/// Matches `?DEFAULT_PUZZLE_DIFFICULTY` in `macula_identity.erl`. Grinding
46/// at this difficulty is sub-millisecond — see the module doc.
47pub const DEFAULT_PUZZLE_DIFFICULTY: u32 = 8;
48
49const KEY_FILE_MAGIC: &[u8] = b"macula-v2-key\0";
50
51/// An Ed25519 keypair. The public half **is** the macula NodeId.
52pub struct KeyPair {
53    signing_key: SigningKey,
54}
55
56impl KeyPair {
57    /// Generate a fresh keypair. Does **not** grind a puzzle — the
58    /// resulting identity will be silently rejected by any station that
59    /// enforces puzzle admission (which is every station in practice).
60    /// Prefer [`generate_with_puzzle`](Self::generate_with_puzzle) unless
61    /// you specifically need an unhardened identity (e.g. a unit test
62    /// that never dials a real station).
63    ///
64    /// Seeded directly from the OS RNG (`rand::rngs::SysRng`), unwrapped
65    /// via [`rand::rand_core::UnwrapErr`] to make it panic rather than
66    /// return a `Result` on the rare case the OS entropy syscall itself
67    /// fails — the exact same fail-fast behavior `rand` 0.8's `OsRng` had
68    /// implicitly, since `rand` 0.9 split `SysRng` into a fallible-only
69    /// type that no longer satisfies `SigningKey::generate`'s infallible
70    /// `CryptoRng` bound on its own. This is the pattern
71    /// `ed25519-dalek` 3.0's own docs use for this exact call
72    /// (`ed25519_dalek::SigningKey::generate`'s doc example), not
73    /// `rand::rng()`/`ThreadRng` — a userspace CSPRNG that, since rand
74    /// 0.9, is explicitly documented as **not** reseeding on `fork()`,
75    /// which would be a real (if narrow) identity-collision risk for a
76    /// long-lived process that forks after generating a key. Direct OS
77    /// randomness has no such state to reuse across a fork.
78    pub fn generate() -> Self {
79        let signing_key = SigningKey::generate(&mut rand::rand_core::UnwrapErr(rand::rngs::SysRng));
80        Self { signing_key }
81    }
82
83    /// Generate a keypair, grinding fresh candidates until
84    /// `puzzle_valid(pubkey, difficulty)` holds. This is the one-time
85    /// cost described in the module doc — not something to redo per
86    /// connection.
87    pub fn generate_with_puzzle(difficulty: u32) -> Self {
88        loop {
89            let candidate = Self::generate();
90            if puzzle_valid(&candidate.public_bytes(), difficulty) {
91                return candidate;
92            }
93        }
94    }
95
96    /// As [`generate_with_puzzle`](Self::generate_with_puzzle), at
97    /// [`DEFAULT_PUZZLE_DIFFICULTY`].
98    pub fn generate_with_default_puzzle() -> Self {
99        Self::generate_with_puzzle(DEFAULT_PUZZLE_DIFFICULTY)
100    }
101
102    /// Reconstruct a keypair from its 32-byte seed. Deterministic — the
103    /// same seed always yields the same public key and, for a given
104    /// message, the same signature (Ed25519 per RFC 8032 has no signing
105    /// randomness).
106    pub fn from_seed_bytes(seed: [u8; 32]) -> Self {
107        Self {
108            signing_key: SigningKey::from_bytes(&seed),
109        }
110    }
111
112    /// The public key — also this identity's macula NodeId.
113    pub fn public_bytes(&self) -> [u8; 32] {
114        self.signing_key.verifying_key().to_bytes()
115    }
116
117    /// The 32-byte seed. Matches `macula_identity:private/1`.
118    pub fn private_bytes(&self) -> [u8; 32] {
119        self.signing_key.to_bytes()
120    }
121
122    /// Alias for [`public_bytes`](Self::public_bytes) — NodeId == public
123    /// key, matching `macula_identity:node_id/1`'s own doc ("Phase 1:
124    /// NodeId == public key").
125    pub fn node_id(&self) -> [u8; 32] {
126        self.public_bytes()
127    }
128
129    /// Sign `msg` with this identity. Callers add their own domain
130    /// separation by prefixing `msg` (see the frame-signing domains in
131    /// `plans/PLAN_WIRE_PROTOCOL.md` §4) — this function itself is raw
132    /// Ed25519, matching `macula_identity:sign/2` exactly.
133    pub fn sign(&self, msg: &[u8]) -> [u8; 64] {
134        self.signing_key.sign(msg).to_bytes()
135    }
136
137    /// This identity's puzzle evidence — see [`puzzle_evidence`].
138    pub fn puzzle_evidence(&self) -> [u8; 32] {
139        puzzle_evidence(&self.public_bytes())
140    }
141
142    /// Save this keypair to `path`, atomically (write to a `.tmp`
143    /// sibling, then rename) with `0600` permissions on Unix — matching
144    /// `macula_identity:save/2`'s own file format and discipline exactly:
145    /// a 14-byte magic header (`"macula-v2-key\0"`), then the 32-byte
146    /// public key, then the 32-byte private seed.
147    ///
148    /// This raw-file format is a testing/parity convenience, matching the
149    /// Erlang reference. A real mobile binding should use platform
150    /// secure storage (Keychain on iOS, Keystore on Android) instead of
151    /// this file format directly — see
152    /// `plans/PLAN_WIRE_PROTOCOL.md`'s puzzle_evidence lifecycle note.
153    pub fn save(&self, path: impl AsRef<Path>) -> io::Result<()> {
154        let path = path.as_ref();
155        let mut blob = Vec::with_capacity(KEY_FILE_MAGIC.len() + 64);
156        blob.extend_from_slice(KEY_FILE_MAGIC);
157        blob.extend_from_slice(&self.public_bytes());
158        blob.extend_from_slice(&self.private_bytes());
159
160        let tmp_path = path.with_extension("tmp");
161        fs::write(&tmp_path, &blob)?;
162        set_owner_only_permissions(&tmp_path)?;
163        fs::rename(&tmp_path, path)
164    }
165
166    /// Load a keypair previously written by [`save`](Self::save).
167    /// Returns [`LoadKeyError::PubkeyMismatch`] if the file's stored
168    /// public key doesn't match the one derived from its stored private
169    /// key — a corrupted or hand-edited key file would otherwise
170    /// silently produce a keypair that can never complete a real
171    /// handshake, which is a much harder failure to diagnose than a
172    /// load-time error.
173    pub fn load(path: impl AsRef<Path>) -> Result<Self, LoadKeyError> {
174        let blob = fs::read(path.as_ref())?;
175        let expected_len = KEY_FILE_MAGIC.len() + 64;
176        if blob.len() != expected_len || !blob.starts_with(KEY_FILE_MAGIC) {
177            return Err(LoadKeyError::BadKeyFile);
178        }
179        let rest = &blob[KEY_FILE_MAGIC.len()..];
180        let stored_pub: [u8; 32] = rest[..32].try_into().expect("checked length");
181        let stored_priv: [u8; 32] = rest[32..64].try_into().expect("checked length");
182
183        let keypair = Self::from_seed_bytes(stored_priv);
184        if keypair.public_bytes() != stored_pub {
185            return Err(LoadKeyError::PubkeyMismatch);
186        }
187        Ok(keypair)
188    }
189
190    /// Persist this keypair's seed to `store` — see `crate::keystore`'s
191    /// module doc for why this, not [`save`](Self::save), is what a real
192    /// mobile (or otherwise security-sensitive) binding should use.
193    pub fn save_to_keystore(&self, store: &dyn KeyStore) -> Result<(), KeyStoreError> {
194        store.save_seed(&self.private_bytes())
195    }
196
197    /// Reconstruct a keypair from a seed previously written by
198    /// [`save_to_keystore`](Self::save_to_keystore). Unlike
199    /// [`load`](Self::load), there is no separately-stored public key to
200    /// cross-check — a keystore-backed secret is either exactly the seed
201    /// this method wrote or [`KeyStoreError::InvalidSeedLength`], and the
202    /// public key a seed derives is always internally consistent by
203    /// construction (see [`from_seed_bytes`](Self::from_seed_bytes)).
204    pub fn load_from_keystore(store: &dyn KeyStore) -> Result<Self, KeyStoreError> {
205        Ok(Self::from_seed_bytes(store.load_seed()?))
206    }
207}
208
209#[cfg(unix)]
210fn set_owner_only_permissions(path: &Path) -> io::Result<()> {
211    use std::os::unix::fs::PermissionsExt;
212    fs::set_permissions(path, fs::Permissions::from_mode(0o600))
213}
214
215#[cfg(not(unix))]
216fn set_owner_only_permissions(_path: &Path) -> io::Result<()> {
217    // No POSIX permission bits off Unix; the platform's own file ACLs
218    // apply. Real mobile builds should not be using this raw-file format
219    // at all — see `KeyPair::save`'s doc.
220    Ok(())
221}
222
223#[derive(Debug)]
224pub enum LoadKeyError {
225    Io(io::Error),
226    BadKeyFile,
227    PubkeyMismatch,
228}
229
230impl fmt::Display for LoadKeyError {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        match self {
233            LoadKeyError::Io(e) => write!(f, "I/O error reading key file: {e}"),
234            LoadKeyError::BadKeyFile => write!(f, "key file has the wrong magic header or length"),
235            LoadKeyError::PubkeyMismatch => {
236                write!(
237                    f,
238                    "stored public key does not match the one derived from the stored private key"
239                )
240            }
241        }
242    }
243}
244
245impl std::error::Error for LoadKeyError {}
246
247impl From<io::Error> for LoadKeyError {
248    fn from(e: io::Error) -> Self {
249        LoadKeyError::Io(e)
250    }
251}
252
253/// Verify `sig` over `msg` against `pubkey`. Matches
254/// `macula_identity:verify/3`'s contract exactly: a structurally invalid
255/// public key (not a valid Ed25519 point) is treated as "verification
256/// failed" (`false`), not a separate error — it could not have produced
257/// a valid signature either way.
258pub fn verify(msg: &[u8], sig: &[u8; 64], pubkey: &[u8; 32]) -> bool {
259    let Ok(verifying_key) = VerifyingKey::from_bytes(pubkey) else {
260        return false;
261    };
262    let signature = ed25519_dalek::Signature::from_bytes(sig);
263    verifying_key.verify(msg, &signature).is_ok()
264}
265
266/// `SHA-256(pubkey)` — the proof-of-work output measured by the puzzle.
267/// Cheap; not itself the expensive step (see the module doc).
268pub fn puzzle_evidence(pubkey: &[u8; 32]) -> [u8; 32] {
269    Sha256::digest(pubkey).into()
270}
271
272/// Whether `pubkey` satisfies the puzzle at `difficulty` (leading zero
273/// bits of its [`puzzle_evidence`]).
274pub fn puzzle_valid(pubkey: &[u8; 32], difficulty: u32) -> bool {
275    count_leading_zero_bits(&puzzle_evidence(pubkey)) >= difficulty
276}
277
278fn count_leading_zero_bits(bytes: &[u8]) -> u32 {
279    let mut count = 0u32;
280    for &b in bytes {
281        if b == 0 {
282            count += 8;
283        } else {
284            count += b.leading_zeros();
285            break;
286        }
287    }
288    count
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    /// Captured directly from a real, random `crypto:generate_key(eddsa,
296    /// ed25519)` / `crypto:sign/4` / `crypto:hash(sha256, Pub)` in
297    /// `macula-io/macula`'s own `rebar3 shell` — see this module's doc
298    /// comment. Not a synthetic fixture.
299    const VECTOR_PUB: &str = "B966A9812649C3D5542FF54954FE090C43FDA6574FE48A0DD326626CFAD29A83";
300    const VECTOR_PRIV: &str = "457F45FF5A09E172ED15CB20D6CB26B51AD15ED7308C12D478E8631F9CA03D4F";
301    const VECTOR_MSG: &str = "6D6163756C612D76322D6672616D650068656C6C6F20776F726C64";
302    const VECTOR_SIG: &str = "E8605CF0387CDFCDD88308A0E40A1DCB83402864C335A64D44431DC8ABC5E7E4FF16CA0C56231B32EEB312C4F89F20B6BA76280AFD622983E9D8BC5F4456AC0B";
303    const VECTOR_PUZZLE_EVIDENCE: &str =
304        "09D48C91CB46513ED2580BDCEA87C40DA508D4E50EC3DF2F701AFC55D1C5C0B2";
305    const VECTOR_LEADING_ZERO_BITS: u32 = 4;
306
307    fn fixed_array(hex_str: &str) -> [u8; 32] {
308        hex::decode(hex_str)
309            .expect("valid hex fixture")
310            .try_into()
311            .expect("32-byte fixture")
312    }
313
314    fn fixed_array64(hex_str: &str) -> [u8; 64] {
315        hex::decode(hex_str)
316            .expect("valid hex fixture")
317            .try_into()
318            .expect("64-byte fixture")
319    }
320
321    #[test]
322    fn seed_derives_the_reference_pubkey() {
323        let kp = KeyPair::from_seed_bytes(fixed_array(VECTOR_PRIV));
324        assert_eq!(
325            kp.public_bytes(),
326            fixed_array(VECTOR_PUB),
327            "ed25519-dalek's public-key derivation diverged from Erlang's crypto module"
328        );
329    }
330
331    #[test]
332    fn signature_matches_the_reference_byte_for_byte() {
333        let kp = KeyPair::from_seed_bytes(fixed_array(VECTOR_PRIV));
334        let msg = hex::decode(VECTOR_MSG).unwrap();
335        let sig = kp.sign(&msg);
336        assert_eq!(
337            sig,
338            fixed_array64(VECTOR_SIG),
339            "Ed25519 is deterministic (RFC 8032) — a mismatch here means \
340             the two implementations disagree on the signing algorithm \
341             itself, not just on random input"
342        );
343    }
344
345    #[test]
346    fn verify_accepts_the_reference_signature() {
347        let pubkey = fixed_array(VECTOR_PUB);
348        let msg = hex::decode(VECTOR_MSG).unwrap();
349        let sig = fixed_array64(VECTOR_SIG);
350        assert!(verify(&msg, &sig, &pubkey));
351    }
352
353    #[test]
354    fn verify_rejects_a_tampered_message() {
355        let pubkey = fixed_array(VECTOR_PUB);
356        let sig = fixed_array64(VECTOR_SIG);
357        assert!(!verify(b"not the original message", &sig, &pubkey));
358    }
359
360    #[test]
361    fn verify_rejects_a_structurally_invalid_pubkey_without_panicking() {
362        // All-0xFF is not a valid Ed25519 point.
363        let bogus_pubkey = [0xFFu8; 32];
364        let msg = hex::decode(VECTOR_MSG).unwrap();
365        let sig = fixed_array64(VECTOR_SIG);
366        assert!(!verify(&msg, &sig, &bogus_pubkey));
367    }
368
369    #[test]
370    fn puzzle_evidence_matches_the_reference() {
371        let pubkey = fixed_array(VECTOR_PUB);
372        assert_eq!(
373            puzzle_evidence(&pubkey),
374            fixed_array(VECTOR_PUZZLE_EVIDENCE)
375        );
376    }
377
378    #[test]
379    fn puzzle_valid_matches_the_reference_leading_zero_count() {
380        let pubkey = fixed_array(VECTOR_PUB);
381        assert!(puzzle_valid(&pubkey, VECTOR_LEADING_ZERO_BITS));
382        assert!(!puzzle_valid(&pubkey, VECTOR_LEADING_ZERO_BITS + 1));
383        assert!(puzzle_valid(&pubkey, 0)); // 0 is always satisfied
384    }
385
386    #[test]
387    fn generate_with_default_puzzle_produces_a_valid_identity() {
388        // A real grind, not a fixture — proves the loop terminates and
389        // its result actually satisfies the check it's grinding for.
390        // Sub-millisecond at the default difficulty per the Erlang
391        // reference's own comment; this test should be fast.
392        let kp = KeyPair::generate_with_default_puzzle();
393        assert!(puzzle_valid(&kp.public_bytes(), DEFAULT_PUZZLE_DIFFICULTY));
394    }
395
396    #[test]
397    fn save_and_load_roundtrip() {
398        let dir = tempfile::tempdir().expect("tempdir");
399        let path = dir.path().join("identity.key");
400
401        let original = KeyPair::from_seed_bytes(fixed_array(VECTOR_PRIV));
402        original.save(&path).expect("save");
403
404        let loaded = KeyPair::load(&path).expect("load");
405        assert_eq!(loaded.public_bytes(), original.public_bytes());
406        assert_eq!(loaded.private_bytes(), original.private_bytes());
407    }
408
409    #[cfg(unix)]
410    #[test]
411    fn saved_key_file_is_owner_only() {
412        use std::os::unix::fs::PermissionsExt;
413
414        let dir = tempfile::tempdir().expect("tempdir");
415        let path = dir.path().join("identity.key");
416        KeyPair::generate().save(&path).expect("save");
417
418        let mode = fs::metadata(&path).expect("metadata").permissions().mode();
419        assert_eq!(mode & 0o777, 0o600);
420    }
421
422    #[test]
423    fn load_rejects_a_corrupted_file() {
424        let dir = tempfile::tempdir().expect("tempdir");
425        let path = dir.path().join("identity.key");
426        fs::write(&path, b"not a key file").expect("write");
427
428        assert!(matches!(
429            KeyPair::load(&path),
430            Err(LoadKeyError::BadKeyFile)
431        ));
432    }
433
434    #[test]
435    fn load_rejects_a_tampered_pubkey() {
436        let dir = tempfile::tempdir().expect("tempdir");
437        let path = dir.path().join("identity.key");
438        KeyPair::generate().save(&path).expect("save");
439
440        // Flip a byte inside the stored public key.
441        let mut blob = fs::read(&path).expect("read");
442        let pub_offset = KEY_FILE_MAGIC.len();
443        blob[pub_offset] ^= 0xFF;
444        fs::write(&path, &blob).expect("write tampered");
445
446        assert!(matches!(
447            KeyPair::load(&path),
448            Err(LoadKeyError::PubkeyMismatch)
449        ));
450    }
451}