Skip to main content

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}