From ce7a87856e6bcedbb965387611836a51d53d93b8 Mon Sep 17 00:00:00 2001 From: quinn Date: Mon, 17 Aug 2026 19:07:45 +0000 Subject: [PATCH] DIVE-3553: buzz-pair source --nsec - reads the key from stdin, never argv The verb transfers the CUSTOMER's handset key. cli-v0.1.0's only form is --nsec , an argv element that is world-readable in /proc/PID/cmdline for the whole width of a pairing session (up to 120s waiting for the handset). Same rule as _buzz_write_config / _buzz_cli in the CLI repo (DIVE-3509). resolve_payload takes '-' to mean 'first line of stdin'. It is read before any session I/O, which is what keeps the interactive SAS answer working: both reads go through the one buffered io::stdin() handle line-at-a-time, so the y/n answer is simply the next line. The clap doc for --nsec names both '-' and the word stdin, because the 5dive CLI's _buzz_pair_supports_stdin_nsec probes 'source --help' for exactly that and refuses to run against a build that does not say so. Help text and behaviour have to move together, in both directions. scripts/buzz-pair-stdin-nsec.sh grades it: 10 arms, each a measurement rather than an assertion. The key non-vacuity guards are the released cli-v0.1.0 binary failing the help probe, and a positive leak control that passes the key literally and DOES find it in /proc/PID/cmdline -- without that arm, 'no key in argv' proves only that the probe is blind. --- crates/buzz-pairing-cli/src/main.rs | 35 ++++++- scripts/buzz-pair-stdin-nsec.sh | 138 ++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 4 deletions(-) create mode 100755 scripts/buzz-pair-stdin-nsec.sh diff --git a/crates/buzz-pairing-cli/src/main.rs b/crates/buzz-pairing-cli/src/main.rs index 83b74a8fcc2..8eafa728c1b 100644 --- a/crates/buzz-pairing-cli/src/main.rs +++ b/crates/buzz-pairing-cli/src/main.rs @@ -3,12 +3,20 @@ //! # Usage //! //! ```text -//! buzz-pair source --relay wss://relay.example.com [--nsec nsec1...] +//! buzz-pair source --relay wss://relay.example.com [--nsec nsec1...|--nsec -] //! [--envelope-relay https://relay.example.com] //! buzz-pair target [--relay wss://relay.example.com] //! buzz-pair test-vectors //! ``` //! +//! `--nsec -` reads the key from the first line of stdin instead of argv, so it +//! never lands in the world-readable `/proc//cmdline`. Everything after +//! that first line is still the session's stdin (the y/n SAS answer): +//! +//! ```text +//! { printf '%s\n' "$nsec"; cat; } | buzz-pair source --nsec - --relay wss://… +//! ``` +//! //! # Payload shape //! //! By default `source` transfers a bare bech32 `nsec1...` string @@ -63,7 +71,9 @@ enum Cmd { #[arg(long, default_value = "wss://relay.damus.io")] relay: String, - /// nsec (bech32) of the key to transfer. If omitted, generates a test key. + /// nsec (bech32) of the key to transfer, or '-' to read the nsec from + /// the first line of stdin (keeps the key out of argv, which is + /// world-readable in /proc/PID/cmdline). If omitted, generates a test key. #[arg(long)] nsec: Option, @@ -638,6 +648,8 @@ fn build_envelope(relay_url: &str, nsec: &str) -> Result, CliE /// Resolve the payload to send. /// /// If `nsec` is provided, parse it as bech32; otherwise generate a fresh test key. +/// The literal `-` means "read the nsec from the first line of stdin", so the +/// key never appears in argv (DIVE-3553). /// /// With `envelope_relay` set, the payload is the JSON envelope /// ([`PayloadType::Custom`]) the Buzz apps decode. Without it, the payload stays @@ -649,9 +661,24 @@ fn resolve_payload( ) -> Result<(Zeroizing, PayloadType), CliError> { let nsec = match nsec { Some(s) => { + // `--nsec -` takes the key from the first line of stdin. An argv + // element is world-readable in /proc//cmdline for the whole + // life of the process, and a source session waits up to 120s for + // the handset — so the customer's key must never travel there. + // + // ORDERING: this read must happen before any session I/O, because + // the interactive SAS prompt reads stdin too. Both go through the + // one buffered `io::stdin()` handle, so the SAS answer is simply + // the next line; the reads are line-at-a-time and never merged. + let s = if s == "-" { + Zeroizing::new(read_line()?) + } else { + Zeroizing::new(s) + }; // Validate it parses as a secret key. - let _sk = SecretKey::parse(&s).map_err(|e| CliError::InvalidNsec(e.to_string()))?; - Zeroizing::new(s) + let _sk = + SecretKey::parse(s.as_str()).map_err(|e| CliError::InvalidNsec(e.to_string()))?; + s } None => { let keys = Keys::generate(); diff --git a/scripts/buzz-pair-stdin-nsec.sh b/scripts/buzz-pair-stdin-nsec.sh new file mode 100755 index 00000000000..b9f4337a93f --- /dev/null +++ b/scripts/buzz-pair-stdin-nsec.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# DIVE-3553 — grade `buzz-pair source --nsec -` (key on stdin, never argv). +# +# Every arm is a measurement, not an assertion about the source: the binary is +# run, and what it printed / what its /proc/PID/cmdline held is read back. +# +# usage: scripts/buzz-pair-stdin-nsec.sh [buzz-pair-cli-v0.1.0] +# +# The second argument is the REFUSAL CONTROL: the released cli-v0.1.0 binary, +# which must FAIL the help probe the 5dive CLI uses (tests/buzz_pair_unit.sh, +# `_buzz_pair_supports_stdin_nsec`). Without it a probe that matches everything +# would score as a pass. +set -uo pipefail + +BIN="${1:?usage: $0 [buzz-pair-cli-v0.1.0]}" +CONTROL="${2:-}" +pass=0; fail=0 +ok() { printf 'PASS %s\n' "$1"; pass=$((pass+1)); } +bad() { printf 'FAIL %s\n' "$1"; fail=$((fail+1)); } +check(){ if [[ "$2" == "$3" ]]; then ok "$1"; else bad "$1 (want '$3', got '$2')"; fi; } + +# A throwaway key: the NIP-AB spec's own published `source_priv` vector, bech32 +# encoded here so the arms feed exactly the `nsec1…` shape the 5dive CLI feeds. +# No real key is ever used by this harness. +HEX=$("$BIN" test-vectors 2>/dev/null | awk -F'|' '/source_priv/ {gsub(/ /,"",$3); print $3}') +[[ "$HEX" =~ ^[0-9a-f]{64}$ ]] || { echo "setup: no source_priv vector from $BIN"; exit 2; } +NSEC=$(python3 - "$HEX" <<'PY' +import sys +C="qpzry9x8gf2tvdw0s3jn54khce6mua7l" +def poly(v): + g=[0x3b6a57b2,0x26508e6d,0x1ea119fa,0x3d4233dd,0x2a1462b3]; c=1 + for d in v: + b=c>>25; c=((c&0x1ffffff)<<5)^d + for i in range(5): + c^=g[i] if (b>>i)&1 else 0 + return c +def conv(data): + acc=bits=0; out=[] + for b in data: + acc=(acc<<8)|b; bits+=8 + while bits>=5: bits-=5; out.append((acc>>bits)&31) + if bits: out.append((acc<<(5-bits))&31) + return out +hrp="nsec"; data=conv(bytes.fromhex(sys.argv[1])) +exp=[ord(c)>>5 for c in hrp]+[0]+[ord(c)&31 for c in hrp] +chk=poly(exp+data+[0,0,0,0,0,0])^1 +print(hrp+"1"+"".join(C[d] for d in data+[(chk>>5*(5-i))&31 for i in range(6)])) +PY +) || NSEC="$HEX" +[[ -n "$NSEC" ]] || { echo "setup: could not mint a test nsec"; exit 2; } +[[ "$NSEC" == nsec1* ]] || echo "note: falling back to the hex form of the test key" + +# A relay that refuses instantly: the run must die at CONNECT, which is proof +# the key parsed. --nsec is validated before any session I/O. +DEAD="wss://127.0.0.1:1" + +# ---------------------------------------------------------------- help probe +# Verbatim copy of the 5dive CLI's `_buzz_pair_supports_stdin_nsec` predicate. +probe() { + local help; help=$("$1" source --help 2>&1) || return 1 + grep -Eq -- "--nsec.*(stdin|'-'|\"-\")" <<<"$help" \ + || grep -Eqi 'reads? the (key|nsec) from stdin' <<<"$help" +} +if probe "$BIN"; then ok "help names the stdin form (5dive CLI's probe accepts it)" +else bad "help does not name the stdin form — the 5dive CLI will refuse this build"; fi + +if [[ -n "$CONTROL" ]]; then + if probe "$CONTROL"; then bad "CONTROL: cli-v0.1.0 help passed the probe — the probe matches anything" + else ok "CONTROL: cli-v0.1.0 help fails the probe (the refusal it is supposed to fire)"; fi +fi + +# ------------------------------------------------------- key travels on stdin +out=$(printf '%s\n' "$NSEC" | "$BIN" source --nsec - --relay "$DEAD" 2>&1) +if grep -q 'invalid nsec' <<<"$out"; then + bad "stdin key was not consumed: $(grep -m1 'invalid nsec' <<<"$out")" +elif grep -qE 'error: (WebSocket|I/O)' <<<"$out"; then + ok "stdin key parsed; run died at CONNECT, not at parse" +else + bad "unexpected outcome for a good stdin key: $(tail -2 <<<"$out" | tr '\n' ' ')" +fi + +# The QR URI is minted from the resolved payload, so its presence says the +# payload resolved at all. +grep -q '^nostrpair://' <<<"$out" && ok "session minted a QR URI from the stdin key" \ + || bad "no QR URI — payload never resolved" + +# --------------------------------------------- negative controls on that path +# Garbage on stdin must be REJECTED, not silently replaced by a generated key. +out=$(printf 'not-an-nsec\n' | "$BIN" source --nsec - --relay "$DEAD" 2>&1) +grep -q 'invalid nsec' <<<"$out" && ok "CONTROL: junk on stdin is rejected as invalid nsec" \ + || bad "CONTROL: junk on stdin was accepted: $(tail -1 <<<"$out")" + +# Empty stdin is the case that silently pairs the handset to the WRONG key if +# `-` were to fall through to the generate-a-test-key branch. +out=$(printf '' | "$BIN" source --nsec - --relay "$DEAD" 2>&1) +grep -q 'invalid nsec' <<<"$out" && ok "CONTROL: empty stdin is rejected, not swapped for a generated key" \ + || bad "CONTROL: empty stdin did not fail closed: $(tail -1 <<<"$out")" + +# A literal nsec in argv must still work — this patch may not break the old form. +out=$("$BIN" source --nsec "$NSEC" --relay "$DEAD" &1) +grep -qE 'error: (WebSocket|I/O)' <<<"$out" && ok "argv form still works (no regression)" \ + || bad "argv form regressed: $(tail -1 <<<"$out")" + +# No --nsec at all still generates a key. +out=$("$BIN" source --relay "$DEAD" &1) +grep -q 'no --nsec provided' <<<"$out" && ok "absent --nsec still generates a test key" \ + || bad "absent --nsec regressed: $(tail -1 <<<"$out")" + +# --------------------------------------------------- the actual leak, measured +# Stage the documented invocation against a relay that HANGS (unroutable, so the +# connect sits) and read /proc/PID/cmdline while it is alive. A fixture string is +# not evidence; the kernel's copy of argv is. +HANG="wss://10.255.255.1:443" +leakcheck() { #