1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
//! `ms verify` — exit-code-only validity (and optional --phrase round-trip).
//!
//! Realizes SPEC §2.4 (full command), §2.4.1 (locked validation order:
//! decode -> exit on failure -> parse phrase -> compare -> exit), §6 exit
//! codes 0 (valid) / 1 (user-input) / 2 (format) / 3 (future format) /
//! 4 (round-trip mismatch).
use bip39::{Language, Mnemonic};
use clap::Args;
use serde_json::to_string;
use crate::error::{CliError, Result};
use crate::format::VerifySuccessJson;
use crate::language::CliLanguage;
use crate::parse::{is_stdin_arg, read_input, read_phrase_input};
/// `ms verify` arguments.
#[derive(Args, Debug)]
pub struct VerifyArgs {
/// ms1 string to verify. Use `-` or omit to read from stdin.
pub ms1: Option<String>,
/// Original BIP-39 phrase to round-trip-check against the decoded entropy.
/// When supplied, exit 4 on mismatch. Use `-` to read phrase from stdin.
#[arg(long)]
pub phrase: Option<String>,
/// BIP-39 wordlist for --phrase. For a mnem ms1 the wire language byte is
/// authoritative; --language is advisory-only (a disagreement prints a
/// stderr note). Omitted ⇒ english default for entr cards.
#[arg(long)]
pub language: Option<CliLanguage>,
/// Emit success JSON on stdout (mirrors the §5 schema-versioned form).
#[arg(long)]
pub json: bool,
}
/// Run `ms verify` per SPEC §2.4.1 validation order.
pub fn run(mut args: VerifyArgs) -> Result<u8> {
use zeroize::Zeroizing;
// SPEC v0.9.0 §1 item 2 — consume + immediately wrap the clap-owned
// secret-bearing `phrase` field at `run()` entry. clap-derive does not
// natively emit `Zeroizing<String>`; `mem::take` empties the slot and
// moves the captured String into a scrub-on-drop wrapper.
let phrase_arg: Option<Zeroizing<String>> =
std::mem::take(&mut args.phrase).map(Zeroizing::new);
// Step 1: read ms1 input. Concurrent-stdin guard: if both ms1 and --phrase
// resolve to stdin, exit immediately (clap can't catch this).
if is_stdin_arg(args.ms1.as_deref()) && phrase_arg.as_deref().map(|s| s.as_str()) == Some("-") {
return Err(CliError::BadInput(
"cannot read both ms1 and --phrase from stdin".into(),
));
}
let ms1: Zeroizing<String> = Zeroizing::new(read_input(args.ms1.as_deref())?);
// --language is advisory-only for a mnem ms1 (the wire byte wins). Option
// type ⇒ omission is distinguishable from explicit `--language english`, so a
// bare verify of a non-English card emits NO spurious disagreement note.
let (cli_lang, defaulted) = match args.language {
Some(l) => (l, false),
None => (CliLanguage::English, true),
};
// Step 2: decode the ms1 string. On failure, dispatch per §6.1.1 — phrase
// is NEVER parsed in this branch. The two `Err` arms (exit-3 future-format +
// generic) are preserved verbatim; ONLY the `Ok((tag,payload))` arm is routed
// through the helper, which honors the WIRE language byte for Payload::Mnem
// (H5) and carries the #[non_exhaustive] guard + disagreement note.
let mut stderr = std::io::stderr().lock();
let (entropy_recovered, effective_lang, _effective_lang_defaulted): (
Zeroizing<Vec<u8>>,
CliLanguage,
bool,
) = match ms_codec::decode(&ms1) {
Ok((_tag, payload)) => crate::cmd::payload_lang::payload_entropy_and_language(
payload, cli_lang, defaulted, &mut stderr,
),
Err(ms_codec::Error::ReservedTagNotEmittedInV01 { got }) => {
// Exit 3 path: print the success-shaped "valid future format" message.
// emit_future_format always returns Err(FutureFormat), so propagate
// via `?`; the Ok arm is unreachable but the compiler can't prove that.
emit_future_format(&got, args.json)?;
return Ok(0);
}
Err(e) => return Err(e.into()),
};
drop(stderr);
// Typed rebind = the `lint_zeroize_discipline` evidence anchor for verify's
// entropy local (every_canonical_zeroize_row_has_evidence_anchor).
let entropy: Zeroizing<Vec<u8>> = entropy_recovered;
// Step 3: parse --phrase if present.
let phrase_supplied: Option<Zeroizing<String>> = match phrase_arg.as_ref() {
Some(p) => Some(read_phrase_input(Some(p.as_str()))?),
None => None,
};
// Step 4: compare or exit-0 quick.
// SAFETY: third-party-blocked — `bip39::Mnemonic` has no Drop+Zeroize;
// FOLLOWUP `rust-bip39-mnemonic-zeroize-upstream`. Lifetimes are bounded
// here: supplied_mnemonic + derived_mnemonic both drop at end of arm.
if let Some(supplied) = phrase_supplied {
// H5: both the supplied phrase and the derived mnemonic are parsed/built
// under the EFFECTIVE (wire) language — verify's job is "does this phrase
// reproduce this card?", and the card's language is ground truth. A
// different-language phrase fails `parse_in` (Bip39) or mismatches
// (VerifyPhraseMismatch) — a true negative, never a false GREEN.
let lang: Language = effective_lang.into();
let supplied_mnemonic = Mnemonic::parse_in(lang, supplied.as_str())?;
let derived_mnemonic = Mnemonic::from_entropy_in(lang, &entropy[..])
.expect("ms-codec validates entropy length");
// The success-log path wraps the derived phrase before passing to
// emit_round_trip_ok; the comparison itself uses String temporaries.
let supplied_str: Zeroizing<String> = Zeroizing::new(supplied_mnemonic.to_string());
let derived_str: Zeroizing<String> = Zeroizing::new(derived_mnemonic.to_string());
if *supplied_str == *derived_str {
emit_round_trip_ok(&derived_mnemonic, effective_lang.as_str(), args.json)?;
return Ok(0);
} else {
return Err(CliError::VerifyPhraseMismatch);
}
}
// No --phrase: simple validity OK.
let word_count = entropy.len() * 3 / 4;
let str_len = ms1.len();
emit_simple_ok(word_count, str_len, args.json)?;
Ok(0)
}
fn emit_simple_ok(word_count: usize, str_len: usize, json: bool) -> Result<()> {
if json {
let j = VerifySuccessJson {
schema_version: "1",
status: "valid",
message: &format!("valid v0.1 entr ({} words, {} chars)", word_count, str_len),
};
println!("{}", to_string(&j).expect("verify json"));
} else {
println!(
"OK: valid v0.1 entr ({} words, {} chars)",
word_count, str_len
);
}
Ok(())
}
fn emit_future_format(tag: &[u8; 4], json: bool) -> Result<()> {
let tag_str = std::str::from_utf8(tag).unwrap_or("<non-utf8>");
// Text mode: print success-shaped OK line. JSON mode: do NOT print here —
// main.rs's ExitCode dispatch invokes emit_error which prints the error
// envelope; printing a success line here would yield two outputs on stdout.
if !json {
println!("OK: valid future format (v0.2+, tag {})", tag_str);
}
// Either way, return Err(FutureFormat) so main.rs lands exit 3. In JSON
// mode the error envelope (with kind="FutureFormat", exit_code=3) becomes
// the sole stdout output; text-mode users see the OK line above + (since
// ExitCode != 0) any stderr emit_error would write — but main.rs's
// emit_error writes to stdout in --json mode and to stderr in text mode,
// so text mode emits "error: ..." stderr alongside the OK stdout line.
// That's intentionally redundant — exit-3 is "OK semantically" but the
// err-path-with-stderr-display flags it for users who only watch stderr.
Err(CliError::FutureFormat { tag: *tag })
}
fn emit_round_trip_ok(_mnemonic: &Mnemonic, language: &str, json: bool) -> Result<()> {
let word_count = _mnemonic.to_string().split_whitespace().count();
if json {
let j = VerifySuccessJson {
schema_version: "1",
status: "round-trip-ok",
message: &format!(
"round-trip valid ({} words, language={})",
word_count, language
),
};
println!("{}", to_string(&j).expect("verify json"));
} else {
println!(
"OK: round-trip valid ({} words, language={})",
word_count, language
);
}
Ok(())
}