localharness/wallet.rs
1//! In-browser secp256k1 keypair — k256 + sha3 directly.
2//!
3//! Tried alloy's `signer-local` first (M6 spike), but alloy's
4//! `TransactionEnvelope` proc-macro currently trips on serde 1.0.228's
5//! `__private` namespace (alloy-consensus 1.0.22). We don't need
6//! transaction envelope encoding for identity-only signing, so this
7//! module uses k256 + sha3 directly — that's what alloy uses under
8//! the hood for the local signer anyway.
9//!
10//! Surface (intentionally small):
11//! - `generate()` — new random keypair
12//! - `from_private_key_hex` — restore from a saved hex string
13//! - `address(signer)` — derive the 20-byte EVM address
14//! - `sign_hash(signer, h)` — produce a 65-byte ECDSA signature
15//! (r ‖ s ‖ v) over a 32-byte prehash
16//! - `recover_address` — recover the signer's address from a
17//! signature + prehash (verification)
18//!
19//! No HTTP, no JS bindings — pure compute. Compiles on every target.
20
21use k256::ecdsa::signature::hazmat::PrehashSigner;
22use k256::ecdsa::{RecoveryId, Signature, SigningKey, VerifyingKey};
23use sha3::{Digest, Keccak256};
24use zeroize::Zeroize;
25
26/// A freshly-generated keypair plus its hex-encoded private key.
27pub struct GeneratedWallet {
28 pub signer: SigningKey,
29 pub address: [u8; 20],
30 pub private_key_hex: String,
31}
32
33impl GeneratedWallet {
34 pub fn address_hex(&self) -> String {
35 format!("0x{}", hex_encode(&self.address))
36 }
37}
38
39impl Drop for GeneratedWallet {
40 fn drop(&mut self) {
41 // `private_key_hex` is a fully-formed, exportable private key in a
42 // heap `String`; wipe it so it doesn't linger in freed memory. The
43 // `SigningKey` zeroizes its own scalar on drop (k256), and a 20-byte
44 // address isn't secret.
45 self.private_key_hex.zeroize();
46 }
47}
48
49/// Generate a new random keypair using the host's CSPRNG.
50/// On wasm32 entropy comes from `crypto.getRandomValues` via the
51/// `getrandom/js` feature already enabled by this crate.
52pub fn generate() -> GeneratedWallet {
53 let signer = SigningKey::random(&mut rand_core::OsRng);
54 finalize(signer)
55}
56
57/// Wrap an EXISTING [`SigningKey`] as a [`GeneratedWallet`] (address + hex
58/// derived from it). The reuse counterpart of [`generate`] — e.g. the CLI's
59/// idempotent `create`, which re-claims a name with the key it already holds
60/// instead of overwriting the key file with a fresh wallet.
61pub fn from_signing_key(signer: SigningKey) -> GeneratedWallet {
62 finalize(signer)
63}
64
65/// Generate a BIP-39 12-word mnemonic (English wordlist) AND the
66/// SigningKey derived from its 32-byte seed. We use the seed
67/// directly as the private key — no HD derivation path — because
68/// this is identity, not a hierarchical wallet. One mnemonic, one
69/// key, one address.
70pub fn generate_with_mnemonic() -> (bip39::Mnemonic, SigningKey) {
71 let mnemonic = bip39::Mnemonic::generate(12).expect("12 is a valid word count");
72 let signer = signer_from_mnemonic(&mnemonic);
73 (mnemonic, signer)
74}
75
76/// Derive the SigningKey from a mnemonic — same path as
77/// `generate_with_mnemonic` so the round-trip is stable.
78pub fn signer_from_mnemonic(mnemonic: &bip39::Mnemonic) -> SigningKey {
79 let mut entropy = mnemonic.to_entropy(); // 16 bytes for 12 words
80 // Stretch the entropy into 32 bytes via keccak256 — a single
81 // hash is enough for "identity from mnemonic"; this isn't HD
82 // derivation territory.
83 let mut hasher = Keccak256::new();
84 hasher.update(b"localharness/v0/identity");
85 hasher.update(&entropy);
86 let mut digest = [0u8; 32];
87 digest.copy_from_slice(&hasher.finalize());
88 let signer = SigningKey::from_slice(&digest)
89 .expect("keccak256 output is 32 bytes; SigningKey is infallible for valid scalars");
90 // Wipe the transient secret-derived material (the raw entropy and the
91 // private-scalar digest) now that the key is built.
92 entropy.zeroize();
93 digest.zeroize();
94 signer
95}
96
97/// Parse a 12-word phrase. Whitespace is normalised, case is
98/// ignored. Returns the underlying Mnemonic so callers can
99/// re-derive the signer.
100pub fn mnemonic_from_phrase(phrase: &str) -> Result<bip39::Mnemonic, String> {
101 let normalised: String = phrase
102 .split_whitespace()
103 .map(|w| w.to_lowercase())
104 .collect::<Vec<_>>()
105 .join(" ");
106 bip39::Mnemonic::parse_in_normalized(bip39::Language::English, &normalised)
107 .map_err(|e| e.to_string())
108}
109
110/// Restore from `0x`-prefixed (or bare) hex.
111pub fn from_private_key_hex(hex: &str) -> Result<SigningKey, String> {
112 let trimmed = hex.trim().trim_start_matches("0x").trim_start_matches("0X");
113 let bytes = hex_decode(trimmed).map_err(|e| format!("invalid hex: {e}"))?;
114 if bytes.len() != 32 {
115 return Err(format!("expected 32-byte private key, got {}", bytes.len()));
116 }
117 SigningKey::from_slice(&bytes).map_err(|e| format!("invalid scalar: {e}"))
118}
119
120/// EVM address = last 20 bytes of keccak256(uncompressed pubkey [1..]).
121pub fn address(signer: &SigningKey) -> [u8; 20] {
122 let verifying = VerifyingKey::from(signer);
123 let encoded = verifying.to_encoded_point(false); // uncompressed (65 bytes, 0x04 prefix)
124 let bytes = encoded.as_bytes();
125 debug_assert_eq!(bytes.len(), 65);
126 let mut hasher = Keccak256::new();
127 hasher.update(&bytes[1..]); // drop the 0x04 SEC1 tag
128 let digest = hasher.finalize();
129 let mut addr = [0u8; 20];
130 addr.copy_from_slice(&digest[12..]);
131 addr
132}
133
134/// Sign a 32-byte prehash, returning the 65-byte Ethereum-style
135/// `r ‖ s ‖ v` signature with `v` recovery id ∈ {27, 28}.
136pub fn sign_hash(signer: &SigningKey, hash: &[u8; 32]) -> [u8; 65] {
137 let (sig, rec): (Signature, RecoveryId) = signer
138 .sign_prehash(hash)
139 .expect("k256 sign_prehash is infallible for a valid SigningKey");
140 let sig_bytes = sig.to_bytes(); // 64 bytes: r ‖ s
141 let mut out = [0u8; 65];
142 out[..64].copy_from_slice(&sig_bytes);
143 out[64] = 27 + u8::from(rec); // Ethereum convention
144 out
145}
146
147/// Compute the Ethereum `personal_sign` digest of a message:
148/// `keccak256("\x19Ethereum Signed Message:\n" || ascii(len) || message)`.
149/// This is the digest any standard `eth_personalSign` verifier (e.g. the
150/// credit proxy's `recoverAddress`) reconstructs.
151pub fn personal_sign_digest(message: &[u8]) -> [u8; 32] {
152 let mut hasher = Keccak256::new();
153 hasher.update(b"\x19Ethereum Signed Message:\n");
154 hasher.update(message.len().to_string().as_bytes());
155 hasher.update(message);
156 let digest = hasher.finalize();
157 let mut out = [0u8; 32];
158 out.copy_from_slice(&digest);
159 out
160}
161
162/// Sign `message` as an Ethereum `personal_sign`: the 65-byte `r‖s‖v`
163/// signature (v ∈ {27,28}) over the prefixed keccak digest. Used to mint
164/// the credit-proxy auth token; verifiable by `recover_address` against
165/// `personal_sign_digest(message)`.
166pub fn personal_sign(signer: &SigningKey, message: &[u8]) -> [u8; 65] {
167 sign_hash(signer, &personal_sign_digest(message))
168}
169
170/// Recover the signer's address from a 65-byte signature + the 32-byte
171/// prehash that was signed. Used to verify "did this address sign this?"
172/// without needing the pubkey shipped alongside.
173pub fn recover_address(signature: &[u8; 65], prehash: &[u8; 32]) -> Result<[u8; 20], String> {
174 let v = signature[64];
175 let rec_id = match v {
176 0 | 27 => 0u8,
177 1 | 28 => 1u8,
178 _ => return Err(format!("invalid v: {v}")),
179 };
180 let rec = RecoveryId::try_from(rec_id).map_err(|e| e.to_string())?;
181 let sig = Signature::from_slice(&signature[..64]).map_err(|e| e.to_string())?;
182 // EIP-2 low-s (anti-malleability, audit I3): k256's `normalize_s` returns
183 // `Some` only when `s` is in the upper half of the curve order — i.e. the
184 // malleable high-s twin of a canonical signature. Reject it so this off-chain
185 // verifier agrees with the on-chain HALF_N gate (X402Facet / MultiSignerAccount)
186 // and the proxy's `_x402.ts`/`_authcore.ts` — otherwise we'd recover a valid
187 // address from a signature the chain (and a payer's intent) would refuse.
188 if sig.normalize_s().is_some() {
189 return Err("malleable (high-s) signature rejected".to_string());
190 }
191 let verifying = VerifyingKey::recover_from_prehash(prehash, &sig, rec)
192 .map_err(|e| e.to_string())?;
193
194 let encoded = verifying.to_encoded_point(false);
195 let bytes = encoded.as_bytes();
196 let mut hasher = Keccak256::new();
197 hasher.update(&bytes[1..]);
198 let digest = hasher.finalize();
199 let mut addr = [0u8; 20];
200 addr.copy_from_slice(&digest[12..]);
201 Ok(addr)
202}
203
204/// Compressed SEC1 public key (33 bytes, 0x02/0x03 prefix) for a signing
205/// key. Used as the recipient identifier in ECIES key-wrapping — the
206/// device announces this so the desktop can encrypt to it.
207pub fn pubkey_compressed(signer: &SigningKey) -> Vec<u8> {
208 let verifying = VerifyingKey::from(signer);
209 verifying.to_encoded_point(true).as_bytes().to_vec()
210}
211
212/// Generate an ephemeral keypair for one ECIES wrap. Returns
213/// `(compressed_pubkey, ephemeral_signer)`.
214pub fn ephemeral_keypair() -> (Vec<u8>, SigningKey) {
215 let signer = SigningKey::random(&mut rand_core::OsRng);
216 (pubkey_compressed(&signer), signer)
217}
218
219/// ECDH → a 32-byte symmetric key shared between `my` private key and
220/// `their` SEC1 public key. Domain-separated through keccak so the raw
221/// curve point never becomes the AES key directly. Both sides derive the
222/// same bytes: sealer uses (ephemeral_priv, recipient_pub); opener uses
223/// (recipient_priv, ephemeral_pub).
224pub fn ecdh_shared_key(my: &SigningKey, their_pubkey_sec1: &[u8]) -> Result<[u8; 32], String> {
225 use k256::{PublicKey, SecretKey};
226 let their = PublicKey::from_sec1_bytes(their_pubkey_sec1)
227 .map_err(|e| format!("bad recipient pubkey: {e}"))?;
228 let secret =
229 SecretKey::from_bytes(&my.to_bytes()).map_err(|e| format!("bad scalar: {e}"))?;
230 let shared = k256::ecdh::diffie_hellman(secret.to_nonzero_scalar(), their.as_affine());
231 let mut hasher = Keccak256::new();
232 hasher.update(b"localharness/v0/ecies");
233 hasher.update(shared.raw_secret_bytes());
234 let mut out = [0u8; 32];
235 out.copy_from_slice(&hasher.finalize());
236 Ok(out)
237}
238
239/// Derive the 32-byte AES key that seals/opens the on-chain Gemini key,
240/// from a master wallet's BIP-39 entropy (tag `localharness/v0/keysync`).
241/// Deterministic from the seed, so any device holding it derives the same
242/// key — a byte-for-byte cross-device contract. SHARED source of truth for
243/// both the apex signer iframe (`app::signer`) and the local-first path in
244/// `app::verify` (a subdomain that pulled the seed in via `seed_pull`);
245/// they MUST agree, hence one impl here (next to the sibling
246/// `localharness/v0/ecies` tag in [`ecdh_shared_key`]) where native tests
247/// can pin it. Re-exported through `app::encryption` for app call sites.
248pub fn keysync_key_from_entropy(entropy: &[u8]) -> [u8; 32] {
249 let mut hasher = Keccak256::new();
250 hasher.update(b"localharness/v0/keysync");
251 hasher.update(entropy);
252 let mut out = [0u8; 32];
253 out.copy_from_slice(&hasher.finalize());
254 out
255}
256
257/// Derive the 32-byte AES key sealing the cross-subdomain **shared folder**
258/// at rest in apex OPFS (`.lh_shared/`, see `app::shared_fs`).
259/// Domain-separated from [`keysync_key_from_entropy`] (tag
260/// `localharness/v0/sharedfs`) so the shared-folder key and the
261/// Gemini-keysync key can never collide. Deterministic from the master
262/// seed, so the apex broker — the only origin that holds the seed — always
263/// derives the same key across devices.
264pub fn sharedfs_key_from_entropy(entropy: &[u8]) -> [u8; 32] {
265 let mut hasher = Keccak256::new();
266 hasher.update(b"localharness/v0/sharedfs");
267 hasher.update(entropy);
268 let mut out = [0u8; 32];
269 out.copy_from_slice(&hasher.finalize());
270 out
271}
272
273/// Derive the 32-byte AES key for **at-rest OPFS encryption** (the
274/// `filesystem::EncryptedFilesystem` wrapper) from a master wallet's
275/// BIP-39 entropy (tag `localharness/v0/opfs-at-rest`). Deterministic
276/// from the seed — every device/origin holding the seed derives the same
277/// key, so files sealed on one device decrypt on a linked one. Domain-
278/// separated from the sibling `keysync` / `sharedfs` tags so the at-rest
279/// key can never collide with the Gemini-key or shared-folder keys.
280/// Byte-for-byte pinned by `at_rest_key_pinned_and_distinct`; changing
281/// the output orphans every sealed OPFS file.
282pub fn at_rest_key_from_entropy(entropy: &[u8]) -> [u8; 32] {
283 let mut hasher = Keccak256::new();
284 hasher.update(b"localharness/v0/opfs-at-rest");
285 hasher.update(entropy);
286 let mut out = [0u8; 32];
287 out.copy_from_slice(&hasher.finalize());
288 out
289}
290
291/// Derive the 32-byte AES key sealing the seed in the QR seed-adoption
292/// flow (`?adopt=1#s=<ct>`), from a one-time pairing CODE (tag
293/// `localharness/v0/adopt`, code uppercased + trimmed). Deterministic on
294/// every device, so the desktop browser can `seal` the mnemonic under it
295/// and a second device — a paired phone OR the `localharness link` CLI —
296/// derives the SAME key from the typed code and decrypts. The SINGLE
297/// source of truth shared by the browser (`app::events::devices`) and the
298/// CLI (`bin/localharness::link`); they MUST agree byte-for-byte, hence one
299/// impl here next to the sibling key derivations where a native test pins it.
300pub fn adopt_code_key(code: &str) -> [u8; 32] {
301 // Stretch the low-entropy human pairing code with an iterated keccak KDF: an
302 // attacker who captures ONLY the sealed `#s=<ct>` blob (a leaked QR / URL) must
303 // pay ADOPT_KDF_ROUNDS keccaks PER candidate code to brute-force it offline.
304 // Combined with the higher-entropy code (`events::devices::generate_pair_code`,
305 // now 8 chars ≈ 2^40) and the receiver no longer persisting the ciphertext in
306 // its history, this puts the offline search out of practical reach. (Keccak is
307 // GPU-parallel; a memory-hard KDF would be stronger still — audit follow-up.)
308 // The output is shared BYTE-FOR-BYTE by the browser and the `localharness link`
309 // CLI (both call this) — changing ADOPT_KDF_ROUNDS or the tags breaks in-flight
310 // links and the pinned `adopt_code_key_pinned_*` test.
311 const ADOPT_KDF_ROUNDS: u32 = 200_000;
312 let mut acc = {
313 let mut hasher = Keccak256::new();
314 hasher.update(b"localharness/v0/adopt");
315 hasher.update(code.trim().to_uppercase().as_bytes());
316 hasher.finalize()
317 };
318 for _ in 0..ADOPT_KDF_ROUNDS {
319 let mut hasher = Keccak256::new();
320 hasher.update(b"localharness/v0/adopt-kdf");
321 hasher.update(&acc[..]);
322 acc = hasher.finalize();
323 }
324 let mut out = [0u8; 32];
325 out.copy_from_slice(&acc);
326 out
327}
328
329fn finalize(signer: SigningKey) -> GeneratedWallet {
330 let address = address(&signer);
331 let private_key_hex = format!("0x{}", hex_encode(&signer.to_bytes()));
332 GeneratedWallet {
333 signer,
334 address,
335 private_key_hex,
336 }
337}
338
339// --- minimal RLP (Ethereum's serialization format for tx envelopes) --
340
341/// RLP-encode a byte string. Used for tx fields and for wrapping the
342/// final encoded list.
343pub fn rlp_bytes(input: &[u8]) -> Vec<u8> {
344 let mut out = Vec::with_capacity(input.len() + 9);
345 if input.len() == 1 && input[0] < 0x80 {
346 out.push(input[0]);
347 } else if input.len() <= 55 {
348 out.push(0x80 + input.len() as u8);
349 out.extend_from_slice(input);
350 } else {
351 let len_bytes = be_bytes_no_leading_zero(input.len() as u128);
352 out.push(0xb7 + len_bytes.len() as u8);
353 out.extend_from_slice(&len_bytes);
354 out.extend_from_slice(input);
355 }
356 out
357}
358
359/// RLP-encode a list. `items` is each item already RLP-encoded.
360pub fn rlp_list(items: &[Vec<u8>]) -> Vec<u8> {
361 let body_len: usize = items.iter().map(|i| i.len()).sum();
362 let mut out = Vec::with_capacity(body_len + 9);
363 if body_len <= 55 {
364 out.push(0xc0 + body_len as u8);
365 } else {
366 let len_bytes = be_bytes_no_leading_zero(body_len as u128);
367 out.push(0xf7 + len_bytes.len() as u8);
368 out.extend_from_slice(&len_bytes);
369 }
370 for item in items {
371 out.extend_from_slice(item);
372 }
373 out
374}
375
376/// Minimal big-endian encoding of a u128: drop leading zero bytes,
377/// but if the value is 0 return a single 0 byte. RLP convention is
378/// "empty" for zero quantities in some contexts; callers usually
379/// wrap via `rlp_uint` which returns `[]` for zero.
380fn be_bytes_no_leading_zero(value: u128) -> Vec<u8> {
381 let bytes = value.to_be_bytes();
382 let first_non_zero = bytes.iter().position(|b| *b != 0).unwrap_or(bytes.len() - 1);
383 bytes[first_non_zero..].to_vec()
384}
385
386/// RLP-encode a uint: empty bytes for zero, minimal big-endian
387/// otherwise. This is the convention legacy/EIP-155 txs use for
388/// quantity fields (nonce, gasPrice, gasLimit, value, v, r, s).
389pub fn rlp_uint(value: u128) -> Vec<u8> {
390 if value == 0 {
391 rlp_bytes(&[])
392 } else {
393 rlp_bytes(&be_bytes_no_leading_zero(value))
394 }
395}
396
397// --- minimal hex helpers ----------------------------------------------
398//
399// Thin aliases over the crate-canonical `crate::encoding` codecs (this
400// module's hand-rolled nibble loops were a byte-identical third copy).
401// `hex_decode` accepts a little MORE than the old local fn did (it also
402// trims whitespace / strips an optional `0x`), but every caller here
403// pre-strips, so behavior on the wallet paths is unchanged.
404
405use crate::encoding::bytes_to_hex as hex_encode;
406use crate::encoding::hex_to_bytes as hex_decode;
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 #[test]
413 fn generate_then_restore_round_trips_the_address() {
414 let w = generate();
415 // 0x + 64 hex chars
416 assert_eq!(w.private_key_hex.len(), 66);
417 assert!(w.private_key_hex.starts_with("0x"));
418 let restored = from_private_key_hex(&w.private_key_hex).unwrap();
419 assert_eq!(address(&restored), w.address);
420 }
421
422 #[test]
423 fn address_is_20_bytes() {
424 let w = generate();
425 assert_eq!(w.address.len(), 20);
426 assert_eq!(w.address_hex().len(), 42); // 0x + 40 hex chars
427 }
428
429 #[test]
430 fn recover_rejects_high_s_malleable_signature() {
431 // EIP-2 low-s gate (audit I3): the malleable high-s twin (r, n-s, v^1)
432 // recovers the same key on a permissive verifier but must be rejected here,
433 // matching the on-chain HALF_N gate. Our own `sign_hash` emits low-s (proven
434 // by the round-trip test below), so the twin is built by replacing s with n-s.
435 fn n_minus_s(s: &[u8; 32]) -> [u8; 32] {
436 // secp256k1 group order n, big-endian.
437 const N: [u8; 32] = [
438 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
439 0xff, 0xff, 0xfe, 0xba, 0xae, 0xdc, 0xe6, 0xaf, 0x48, 0xa0, 0x3b, 0xbf, 0xd2,
440 0x5e, 0x8c, 0xd0, 0x36, 0x41, 0x41,
441 ];
442 let mut out = [0u8; 32];
443 let mut borrow = 0i16;
444 for i in (0..32).rev() {
445 let d = N[i] as i16 - s[i] as i16 - borrow;
446 if d < 0 {
447 out[i] = (d + 256) as u8;
448 borrow = 1;
449 } else {
450 out[i] = d as u8;
451 borrow = 0;
452 }
453 }
454 out
455 }
456 let w = generate();
457 let hash = [0x42u8; 32];
458 let low = sign_hash(&w.signer, &hash);
459 assert_eq!(recover_address(&low, &hash).unwrap(), w.address); // low-s verifies
460 let mut high = low;
461 let s: [u8; 32] = low[32..64].try_into().unwrap();
462 high[32..64].copy_from_slice(&n_minus_s(&s));
463 high[64] ^= 1; // the malleable twin's flipped recovery bit
464 assert!(
465 recover_address(&high, &hash).is_err(),
466 "high-s (malleable) signature must be rejected"
467 );
468 }
469
470 #[test]
471 fn sign_then_recover_returns_signing_address() {
472 let w = generate();
473 let hash = [0x42u8; 32];
474 let sig = sign_hash(&w.signer, &hash);
475 assert_eq!(sig.len(), 65);
476 assert!(matches!(sig[64], 27 | 28));
477 let recovered = recover_address(&sig, &hash).unwrap();
478 assert_eq!(recovered, w.address);
479 }
480
481 #[test]
482 fn personal_sign_roundtrips_through_recover() {
483 // The credit proxy recovers the signer from personal_sign_digest;
484 // this guards that our digest + signature match that verifier.
485 let w = generate();
486 let msg = b"localharness-proxy:0xabc:1717200000";
487 let sig = personal_sign(&w.signer, msg);
488 assert!(matches!(sig[64], 27 | 28));
489 let recovered = recover_address(&sig, &personal_sign_digest(msg)).unwrap();
490 assert_eq!(recovered, w.address);
491 }
492
493 #[test]
494 fn recover_rejects_invalid_v() {
495 let w = generate();
496 let hash = [0x99u8; 32];
497 let mut sig = sign_hash(&w.signer, &hash);
498 sig[64] = 99; // bogus recovery id
499 assert!(recover_address(&sig, &hash).is_err());
500 }
501
502 #[test]
503 fn mnemonic_round_trips_through_phrase_to_address() {
504 let (m, k1) = generate_with_mnemonic();
505 let phrase = m.to_string();
506 // 12 space-separated words
507 assert_eq!(phrase.split_whitespace().count(), 12);
508 let restored = mnemonic_from_phrase(&phrase).unwrap();
509 let k2 = signer_from_mnemonic(&restored);
510 assert_eq!(address(&k1), address(&k2));
511 }
512
513 #[test]
514 fn rlp_short_string_round_trip() {
515 // Known vectors from the RLP spec.
516 // empty string -> 0x80
517 assert_eq!(rlp_bytes(&[]), vec![0x80]);
518 // single byte < 0x80 -> itself
519 assert_eq!(rlp_bytes(&[0x7f]), vec![0x7f]);
520 // "dog" -> 0x83 'd' 'o' 'g'
521 assert_eq!(rlp_bytes(b"dog"), vec![0x83, b'd', b'o', b'g']);
522 }
523
524 #[test]
525 fn rlp_long_string_uses_length_prefix() {
526 let s = vec![0xaa; 100];
527 let enc = rlp_bytes(&s);
528 assert_eq!(enc[0], 0xb8); // 0xb7 + 1 byte for length
529 assert_eq!(enc[1], 100);
530 assert_eq!(&enc[2..], &s[..]);
531 }
532
533 #[test]
534 fn rlp_uint_zero_is_empty_string() {
535 assert_eq!(rlp_uint(0), vec![0x80]);
536 }
537
538 #[test]
539 fn rlp_uint_small_minimal() {
540 // 15 -> single byte
541 assert_eq!(rlp_uint(15), vec![0x0f]);
542 // 256 -> 0x82 0x01 0x00
543 assert_eq!(rlp_uint(256), vec![0x82, 0x01, 0x00]);
544 }
545
546 #[test]
547 fn rlp_list_known_vector() {
548 // ["cat", "dog"] -> 0xc8 0x83 'c' 'a' 't' 0x83 'd' 'o' 'g'
549 let cat = rlp_bytes(b"cat");
550 let dog = rlp_bytes(b"dog");
551 let enc = rlp_list(&[cat, dog]);
552 assert_eq!(
553 enc,
554 vec![0xc8, 0x83, b'c', b'a', b't', 0x83, b'd', b'o', b'g']
555 );
556 }
557
558 /// PINNED derivation vectors. `signer_from_mnemonic` is a CUSTOM
559 /// stretch — `keccak256("localharness/v0/identity" || entropy)` — and
560 /// the seed IS the identity: `wallet_store` re-derives the key from the
561 /// mnemonic on every load, so ANY change to the tag, the hash, or the
562 /// entropy handling silently re-keys every returning user (new address,
563 /// orphaned names, lost $LH). The round-trip tests above can't catch
564 /// that — a changed derivation still round-trips. Do NOT regenerate
565 /// these constants to make the test pass; a mismatch means the identity
566 /// derivation CHANGED and existing users would be locked out.
567 #[test]
568 fn mnemonic_known_vector_pins_identity_derivation() {
569 // The standard BIP-39 zero-entropy phrase (entropy = [0u8; 16]).
570 let phrase = "abandon abandon abandon abandon abandon abandon \
571 abandon abandon abandon abandon abandon about";
572 let m = mnemonic_from_phrase(phrase).unwrap();
573 assert_eq!(m.to_entropy(), vec![0u8; 16]);
574 let signer = signer_from_mnemonic(&m);
575 // Generated ONCE from the live implementation (2026-06-10) — pins
576 // the localharness/v0/identity tag + keccak stretch + entropy input.
577 assert_eq!(
578 format!("0x{}", hex_encode(&address(&signer))),
579 "0x4800ae69a4855281a1251f8c3beab064eb7da012",
580 "identity derivation changed — this re-keys EVERY returning user"
581 );
582
583 // Independent check of the keccak-of-pubkey ADDRESS path: private
584 // key 0x…01 has an externally-known address (any EVM tool agrees:
585 // 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf), so this leg doesn't
586 // depend on our own impl having been correct when pinned.
587 let k1 = from_private_key_hex(
588 "0x0000000000000000000000000000000000000000000000000000000000000001",
589 )
590 .unwrap();
591 assert_eq!(
592 format!("0x{}", hex_encode(&address(&k1))),
593 "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf",
594 "address derivation no longer matches the EVM standard"
595 );
596 }
597
598 /// PINNED ECDH contract — the ECIES core under mobile seed transport
599 /// (`seed_pull`), sealed SDP, and the Gemini-key handoff. Pins: (1)
600 /// ECDH symmetry (sealer and opener derive the same bytes), (2) the
601 /// exact output (the `localharness/v0/ecies` tag + keccak + input
602 /// order — change any and every existing ECIES blob becomes
603 /// undecryptable), (3) the 33-byte compressed-pubkey framing that
604 /// `ecies_open`'s `split_at(33)` relies on, (4) graceful Err (not a
605 /// panic) on garbage pubkey bytes.
606 #[test]
607 fn ecdh_shared_key_is_symmetric_and_pinned() {
608 let a = from_private_key_hex(
609 "0x000000000000000000000000000000000000000000000000000000000000000a",
610 )
611 .unwrap();
612 let b = from_private_key_hex(
613 "0x000000000000000000000000000000000000000000000000000000000000000b",
614 )
615 .unwrap();
616 let pub_a = pubkey_compressed(&a);
617 let pub_b = pubkey_compressed(&b);
618
619 // (1) Symmetry: ECDH(a, pub_b) == ECDH(b, pub_a).
620 let k_ab = ecdh_shared_key(&a, &pub_b).unwrap();
621 let k_ba = ecdh_shared_key(&b, &pub_a).unwrap();
622 assert_eq!(k_ab, k_ba);
623
624 // (2) Generated ONCE from the live implementation (2026-06-10).
625 // A mismatch means existing sealed blobs can no longer be opened.
626 assert_eq!(
627 hex_encode(&k_ab),
628 "3225f3c45abcb834b362af592bdcb9b999380d22521627ae7e71f9bbce614e47",
629 "ECIES shared-key derivation changed"
630 );
631
632 // (3) Compressed SEC1 framing: 33 bytes, 0x02/0x03 prefix.
633 assert_eq!(pub_a.len(), 33);
634 assert!(matches!(pub_a[0], 0x02 | 0x03));
635
636 // (4) Garbage pubkey is an Err, never a panic.
637 assert!(ecdh_shared_key(&a, &[0u8; 33]).is_err());
638 }
639
640 /// PINNED AES key derivations with a byte-for-byte CROSS-DEVICE
641 /// contract: `keysync` seals the on-chain Gemini key (apex signer
642 /// iframe + the subdomain local-first path must agree) and `sharedfs`
643 /// seals `.lh_shared/` at rest. A changed output orphans everything
644 /// already sealed under the old key. Also pins that the two tags
645 /// actually domain-separate (distinct outputs for the same entropy).
646 #[test]
647 fn keysync_and_sharedfs_keys_pinned_and_distinct() {
648 let entropy = [0u8; 16];
649 let keysync = keysync_key_from_entropy(&entropy);
650 let sharedfs = sharedfs_key_from_entropy(&entropy);
651 // Generated ONCE from the live implementation (2026-06-10).
652 assert_eq!(
653 hex_encode(&keysync),
654 "d3ddc0e89ef28726b10fa9aed5fdb086d9dd79aad14b37c9b8fb7b49c9cf77f5",
655 "keysync key derivation changed — sealed Gemini keys orphaned"
656 );
657 assert_eq!(
658 hex_encode(&sharedfs),
659 "5d0d6e8c644245c728b0248c30ab02f0a2492f982c99c572ce54210592ca739b",
660 "sharedfs key derivation changed — sealed shared folders orphaned"
661 );
662 assert_ne!(keysync, sharedfs);
663 }
664
665 /// PINNED at-rest OPFS key derivation (tag `localharness/v0/opfs-at-rest`,
666 /// the `filesystem::EncryptedFilesystem` key). Same cross-device contract
667 /// as its siblings: a changed output orphans every OPFS file sealed under
668 /// the old key — conversation history, system prompt, working files all
669 /// become unreadable ciphertext. Also pins domain separation from the
670 /// keysync and sharedfs tags.
671 #[test]
672 fn at_rest_key_pinned_and_distinct() {
673 let entropy = [0u8; 16];
674 let at_rest = at_rest_key_from_entropy(&entropy);
675 // Generated ONCE from the live implementation (2026-06-12).
676 assert_eq!(
677 hex_encode(&at_rest),
678 "a0c9c69ced27af86580487d0e3f487ef7143ecfbf69045335e9ea53809a92ced",
679 "at-rest key derivation changed — every sealed OPFS file orphaned"
680 );
681 assert_ne!(at_rest, keysync_key_from_entropy(&entropy));
682 assert_ne!(at_rest, sharedfs_key_from_entropy(&entropy));
683 }
684
685 /// PINNED adopt-code key derivation (tag `localharness/v0/adopt`) — the
686 /// QR seed-adoption transport key the browser seals the seed under and the
687 /// `localharness link` CLI re-derives to open it. The two MUST agree, and
688 /// a changed output silently breaks every in-flight adopt link, so pin it.
689 /// Also pins the code is normalized (uppercased + trimmed) before hashing,
690 /// so a phone typing `abc123` and the CLI passing ` ABC123 ` agree.
691 #[test]
692 fn adopt_code_key_pinned_and_case_insensitive() {
693 // Generated ONCE from the live implementation (2026-06-26, after the
694 // KDF-stretch hardening — audit H1: 200k iterated keccak rounds).
695 assert_eq!(
696 hex_encode(&adopt_code_key("ABC234")),
697 "b43a133dacf72b743f1451cdaaf0134a96e6e73ef93d28286ea15916073b6e31",
698 "adopt-code key derivation changed — in-flight adopt links break + CLI/browser drift"
699 );
700 // Case + surrounding whitespace are normalized away (same key).
701 assert_eq!(adopt_code_key("abc234"), adopt_code_key("ABC234"));
702 assert_eq!(adopt_code_key(" abc234 \n"), adopt_code_key("ABC234"));
703 // A different code derives a different key (no collision).
704 assert_ne!(adopt_code_key("ABC234"), adopt_code_key("ABC235"));
705 }
706
707 #[test]
708 fn mnemonic_phrase_is_case_and_whitespace_tolerant() {
709 let (m, k1) = generate_with_mnemonic();
710 let messy = m
711 .to_string()
712 .split_whitespace()
713 .map(|w| if w.len() > 3 { w.to_uppercase() } else { w.to_string() })
714 .collect::<Vec<_>>()
715 .join(" ");
716 let restored = mnemonic_from_phrase(&messy).unwrap();
717 let k2 = signer_from_mnemonic(&restored);
718 assert_eq!(address(&k1), address(&k2));
719 }
720}