Skip to main content

ms_codec/
hashlock.rs

1//! The hashlock preimage derivation (SPEC_ms_hashlock §2).
2//!
3//! THE RULE LIVES HERE, in the codec, beside the kind that carries its
4//! output: one crate, one corpus, one SHA pin, one provenance pin for the Go
5//! port. `ms hashlock` is a thin verb over these four functions.
6//!
7//! Two methods, the operator's choice (brainstorm L5): `preimage_hardened`
8//! is PBKDF2-HMAC-SHA256 with a fixed salt, 100,000 iterations and dkLen 32
9//! (L4); `preimage_sha256` is one SHA-256 of the phrase bytes. Both take the
10//! phrase as BYTES, exactly as given -- no trimming, folding or normalising
11//! happens here or in any caller (§4.3). `digest` is SHA-256 of X, the value
12//! the policy carries; it is public the moment the policy is engraved and is
13//! therefore NOT zeroized.
14//!
15//! THE SALT IS FIXED AND HAS NO PARAMETER (L13). Changing it after any vector
16//! ships is a new method, not a tweak: every engraved policy's preimage was
17//! derived under this exact byte string.
18
19use pbkdf2::pbkdf2_hmac;
20use sha2::{Digest, Sha256};
21use zeroize::Zeroizing;
22
23use crate::error::{Error, Result};
24
25/// The fixed salt (ASCII, copyable by hand, domain-separated from BIP-39's
26/// `"mnemonic"` and from `me`'s 16-byte random seal salt).
27pub const HASHLOCK_SALT: &[u8] = b"ms-hashlock-v1";
28/// PBKDF2 iteration count -- the operator's cap, chosen so a signer at a
29/// tenth of the SH2's measured rate still derives in reasonable time.
30pub const HASHLOCK_ITERATIONS: u32 = 100_000;
31/// Derived-key length: a miniscript `sha256(H)` preimage is exactly 32 bytes.
32pub const HASHLOCK_DKLEN: usize = 32;
33
34/// X = PBKDF2-HMAC-SHA256(phrase, HASHLOCK_SALT, HASHLOCK_ITERATIONS, 32).
35pub fn preimage_hardened(phrase: &[u8]) -> Zeroizing<[u8; 32]> {
36    let mut x = Zeroizing::new([0u8; HASHLOCK_DKLEN]);
37    pbkdf2_hmac::<Sha256>(phrase, HASHLOCK_SALT, HASHLOCK_ITERATIONS, &mut *x);
38    x
39}
40
41/// X = SHA-256(phrase). The brainwallet construction; the CLI warns on it at
42/// every length (L12) and this function does not judge.
43pub fn preimage_sha256(phrase: &[u8]) -> Zeroizing<[u8; 32]> {
44    let mut x = Zeroizing::new([0u8; 32]);
45    x.copy_from_slice(&Sha256::digest(phrase));
46    x
47}
48
49/// X from the OS CSPRNG, failing closed: an error, never a zeroed buffer.
50/// Lives here rather than in the CLI so the whole preimage surface -- and its
51/// randomness contract -- is one crate's (R0 r0 correctness I-2).
52pub fn preimage_random() -> Result<Zeroizing<[u8; 32]>> {
53    let mut x = Zeroizing::new([0u8; 32]);
54    getrandom::fill(&mut *x).map_err(|_| Error::RandomnessUnavailable)?;
55    Ok(x)
56}
57
58/// H = SHA-256(X): what the policy carries and the plate shows. Public.
59pub fn digest(preimage: &[u8; 32]) -> [u8; 32] {
60    let mut h = [0u8; 32];
61    h.copy_from_slice(&Sha256::digest(preimage));
62    h
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    #[test]
70    fn hardened_output_is_zeroizing_and_32() {
71        let x = preimage_hardened(b"x");
72        assert_eq!(x.len(), 32);
73        // Two calls agree: the salt and count are constants, not state.
74        assert_eq!(&preimage_hardened(b"x")[..], &x[..]);
75    }
76}
77
78// ─── The PHRASE RULE (SPEC_ms_hashlock §4.3) ────────────────────────────────
79//
80// IT LIVES HERE, IN THE CODEC, for the reason the module header already gives
81// for the derivation: one crate, one corpus, one SHA pin, one provenance pin
82// for the Go port. Until H6 the rule was `ms-cli`'s `validate_phrase`, private
83// to that binary; `me sysw pack`'s `phrase:` record must apply the SAME rule
84// byte for byte (SPEC_hashlock_H6 §3.1) and `me` depends on `ms-codec`, not on
85// `ms-cli`. Leaving it where it was would have produced a THIRD copy of a rule
86// whose whole point is that the host and the device cannot disagree about what
87// a phrase is.
88//
89// `ms-cli`'s `validate_phrase` now delegates here and keeps only its own
90// message rendering, so there is still exactly one implementation.
91
92/// The phrase cap. Its own constant on each side, lockstep-pinned; NOT the
93/// device's plate-legibility `passphrase.MaxLen`.
94pub const HASHLOCK_PHRASE_MAX_CHARS: usize = 100;
95
96/// The shortest string `looks_like_ms1` will call ms1-shaped.
97const MIN_MS1_LEN: usize = 48;
98
99const BECH32_CHARSET: &str = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
100
101/// Why a phrase was refused. One variant per rule, in the order the rule
102/// checks them; the CALLER renders the sentence.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum PhraseRefusal {
105    /// No bytes at all.
106    Empty,
107    /// A byte outside `0x20..=0x7E`, with the byte and its position.
108    NotPrintableAscii {
109        /// The offending byte.
110        byte: u8,
111        /// Its zero-based position.
112        at: usize,
113    },
114    /// An ms1 string — a preimage plate, not a phrase.
115    Ms1Shaped,
116    /// Over `HASHLOCK_PHRASE_MAX_CHARS`.
117    TooLong {
118        /// The length that was measured.
119        chars: usize,
120    },
121    /// Exactly 64 hex characters — a preimage in hex, not a phrase.
122    Hex64,
123}
124
125/// `looks_like_ms1` over the NORMALISED token: trimmed, ASCII-lowercased,
126/// display separators (whitespace, `-`, `,`) stripped, then at least 48
127/// characters, an `ms1` prefix and only bech32 characters.
128///
129/// NO CHECKSUM, deliberately. A GROUPED plate is what `ms hashlock`'s
130/// engraving card prints and therefore what an operator retypes, and a
131/// checksum test would answer false for it — so the guard would miss the one
132/// spelling it exists to catch.
133pub fn looks_like_ms1(raw: &str) -> bool {
134    let t: String = raw
135        .trim()
136        .to_ascii_lowercase()
137        .chars()
138        .filter(|c| !c.is_whitespace() && *c != '-' && *c != ',')
139        .collect();
140    t.len() >= MIN_MS1_LEN
141        && t.starts_with("ms1")
142        && t[3..].chars().all(|c| BECH32_CHARSET.contains(c))
143}
144
145/// The rule. ORDER MATTERS and is the spec's: empty, printable ASCII,
146/// ms1-shape (BEFORE the cap, so a grouped plate string gets the `--in`
147/// remedy and not "too long"), the cap, 64-hex.
148///
149/// It changes nothing: no trim, no case fold, no normalisation. The shape test
150/// works on a copy.
151pub fn validate_phrase(bytes: &[u8]) -> core::result::Result<(), PhraseRefusal> {
152    if bytes.is_empty() {
153        return Err(PhraseRefusal::Empty);
154    }
155    if let Some((at, &byte)) = bytes
156        .iter()
157        .enumerate()
158        .find(|(_, b)| !(0x20..=0x7e).contains(*b))
159    {
160        return Err(PhraseRefusal::NotPrintableAscii { byte, at });
161    }
162    // All bytes are printable ASCII now, so this is a &str.
163    let s = core::str::from_utf8(bytes).expect("printable ASCII is UTF-8");
164    if looks_like_ms1(s) {
165        return Err(PhraseRefusal::Ms1Shaped);
166    }
167    if s.len() > HASHLOCK_PHRASE_MAX_CHARS {
168        return Err(PhraseRefusal::TooLong { chars: s.len() });
169    }
170    if s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) {
171        return Err(PhraseRefusal::Hex64);
172    }
173    Ok(())
174}
175
176/// The QR text a hashlock PHRASE plate carries (SPEC_hashlock_H6 §8.6), byte
177/// for byte: three labelled lines, LF-separated, NO trailing newline, the
178/// phrase LAST.
179///
180/// The phrase is last so a reader knows where it ends: it may itself contain
181/// `:` and spaces, and everything after `phrase: ` on the final line is the
182/// phrase, verbatim, with real `0x20` spaces.
183///
184/// The method line names the ALGORITHM in full — not the `--method` selector —
185/// so a reader with the plate and no tool can reproduce the derivation. Its
186/// parameters are read from `HASHLOCK_SALT`, `HASHLOCK_ITERATIONS` and
187/// `HASHLOCK_DKLEN` and never from a literal, so a parameter change cannot
188/// leave the plate lying.
189///
190/// `hashlock v1` is the VERSION TAG of this TEXT, not of the derivation. A
191/// future parameter set gets `hashlock v2`.
192///
193/// IT RETURNS `Zeroizing<String>` BECAUSE THE PHRASE IS IN IT. Every other
194/// phrase-bearing value in this workspace is protected -- `read_phrase_from`
195/// and `read_phrase_stdin` return `Zeroizing<Vec<u8>>`, `preimage_hardened`
196/// and `preimage_sha256` return `Zeroizing<[u8; 32]>`, and the kind carries
197/// `Payload::Preimage(Zeroizing<[u8; 32]>)` -- and a plain `String` here would
198/// have been the one hole in that surface, holding the phrase in the clear on
199/// the heap until the allocator happened to reuse the page.
200///
201/// **The buffer is `Zeroizing` from the FIRST byte, and it is allocated once.**
202/// Wrapping a finished `format!` would be no protection at all: the `format!`
203/// would build an unprotected `String` containing the phrase and the wrap would
204/// only guard the copy. The exact capacity is reserved up front so no `push_str`
205/// can reallocate and abandon an unwiped buffer part-way through. The `method`
206/// line is deliberately NOT protected -- it is three compile-time constants and
207/// carries nothing of the phrase.
208pub fn qr_text(hardened: bool, phrase: &str) -> Zeroizing<String> {
209    const HEAD: &str = "hashlock v1\n";
210    const LABEL: &str = "\nphrase: ";
211    let method = if hardened {
212        format!(
213            "method: pbkdf2-hmac-sha256 iterations={HASHLOCK_ITERATIONS} salt={} dklen={HASHLOCK_DKLEN}",
214            core::str::from_utf8(HASHLOCK_SALT).expect("the salt is ASCII"),
215        )
216    } else {
217        "method: sha256".to_string()
218    };
219    let mut out: Zeroizing<String> = Zeroizing::new(String::with_capacity(
220        HEAD.len() + method.len() + LABEL.len() + phrase.len(),
221    ));
222    out.push_str(HEAD);
223    out.push_str(&method);
224    out.push_str(LABEL);
225    out.push_str(phrase);
226    out
227}