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}