feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "buzz-pairing-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "CLI tool for NIP-AB device pairing interop testing"
|
||||
|
||||
[[bin]]
|
||||
name = "buzz-pair"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
buzz-core = { workspace = true }
|
||||
nostr = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
url = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
thiserror = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
@@ -0,0 +1,128 @@
|
||||
# buzz-pair
|
||||
|
||||
CLI tool for testing the [NIP-AB device pairing protocol](../buzz-core/src/pairing/NIP-AB.md) end-to-end. Exercises the full protocol over a live Nostr relay — designed for interop testing and NIP submission, not production use.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cargo build --release -p buzz-pairing-cli
|
||||
|
||||
# Terminal 1 — source (holds the secret)
|
||||
./target/release/buzz-pair source --relay wss://relay.damus.io
|
||||
|
||||
# Terminal 2 — target (receives the secret)
|
||||
./target/release/buzz-pair target --show-secret
|
||||
# paste the QR URI from terminal 1 when prompted
|
||||
```
|
||||
|
||||
Both sides display a 6-digit SAS code. Confirm they match on each side, and the key transfers.
|
||||
|
||||
## Subcommands
|
||||
|
||||
### `source`
|
||||
|
||||
Acts as the device holding the secret. Generates an ephemeral keypair and session secret, displays a `nostrpair://` QR URI, waits for a target to connect, performs SAS verification, and sends the payload.
|
||||
|
||||
```
|
||||
buzz-pair source --relay <RELAY_URL> [--nsec <BECH32_NSEC>]
|
||||
```
|
||||
|
||||
- `--relay` — WebSocket relay URL (default: `wss://relay.damus.io`)
|
||||
- `--nsec` — bech32 nsec to transfer. If omitted, generates a throwaway test key.
|
||||
|
||||
### `target`
|
||||
|
||||
Acts as the receiving device. Reads a `nostrpair://` URI from stdin, connects to the relay encoded in the URI, sends an offer, verifies SAS, and receives the payload.
|
||||
|
||||
```
|
||||
buzz-pair target [--relay <OVERRIDE_URL>] [--show-secret]
|
||||
```
|
||||
|
||||
- `--relay` — Override the relay URL from the QR code
|
||||
- `--show-secret` — Print the received secret to stdout (off by default for safety)
|
||||
|
||||
### `test-vectors`
|
||||
|
||||
Prints all derived cryptographic values from the NIP-AB spec's fixed test keys. Useful for verifying implementations against the spec.
|
||||
|
||||
```
|
||||
buzz-pair test-vectors
|
||||
```
|
||||
|
||||
## Testing Against a Local Buzz Relay
|
||||
|
||||
The CLI supports NIP-42 authentication, so it works with Buzz relays out of the box.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker running (for Postgres, Redis, etc.)
|
||||
- Buzz relay built: `cargo build --release -p buzz-relay`
|
||||
|
||||
### Start the relay
|
||||
|
||||
```bash
|
||||
just setup # Docker services + schema
|
||||
cargo build --release --workspace
|
||||
screen -dmS relay bash -c "./target/release/buzz-relay 2>&1 | tee /tmp/buzz-relay.log"
|
||||
sleep 3 && curl -s http://localhost:3000/health # → "ok"
|
||||
```
|
||||
|
||||
### Run the E2E test
|
||||
|
||||
An automated test script using `expect` is provided:
|
||||
|
||||
```bash
|
||||
.scratch/e2e-pair-local.sh
|
||||
```
|
||||
|
||||
This spawns source and target as PTY-driven subprocesses, feeds the QR URI between them, waits for both SAS codes to appear, delays to ensure relay subscriptions are registered, then confirms SAS on both sides. Prints `PASS` or `FAIL` with the SAS codes.
|
||||
|
||||
**Requirements:** `expect` (macOS: built-in at `/usr/bin/expect`)
|
||||
|
||||
**Environment variables:**
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `RELAY_URL` | `ws://localhost:3000` | Relay to test against |
|
||||
| `TEST_TIMEOUT` | `45` | Per-step timeout in seconds |
|
||||
| `SOURCE_CONFIRM_DELAY_MS` | `3000` | Delay after SAS display before confirming (lets relay register subscriptions) |
|
||||
|
||||
### Manual two-terminal test
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
./target/release/buzz-pair source --relay ws://localhost:3000
|
||||
|
||||
# Terminal 2
|
||||
./target/release/buzz-pair target --show-secret
|
||||
# paste the nostrpair:// URI, confirm SAS on both sides
|
||||
```
|
||||
|
||||
## Protocol Overview
|
||||
|
||||
```
|
||||
Source Relay Target
|
||||
────── ───── ──────
|
||||
Generate ephemeral keys
|
||||
Display QR (pubkey+secret+relay)
|
||||
Subscribe kind:24134 Scan QR
|
||||
Generate ephemeral keys
|
||||
Subscribe kind:24134
|
||||
Wait for EOSE
|
||||
◄─────────────────────── Send offer
|
||||
Verify session_id
|
||||
Compute SAS ◄──────────────────────────────────────────► Compute SAS
|
||||
Display: "047291" Display: "047291"
|
||||
|
||||
[User confirms codes match]
|
||||
|
||||
Send sas-confirm ──────────────►─────────────────────►
|
||||
Verify transcript_hash
|
||||
[User confirms]
|
||||
Send payload ──────────────────►─────────────────────►
|
||||
Decrypt + import
|
||||
◄─────────────────────── Send complete
|
||||
Done Done
|
||||
```
|
||||
|
||||
All events are NIP-44 encrypted, signed with ephemeral keys, and addressed via `p` tags. The relay sees only opaque ciphertext between throwaway pubkeys.
|
||||
@@ -0,0 +1,623 @@
|
||||
//! `buzz-pair` — NIP-AB device pairing interop testing CLI.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```text
|
||||
//! buzz-pair source --relay wss://relay.example.com [--nsec nsec1...]
|
||||
//! buzz-pair target [--relay wss://relay.example.com]
|
||||
//! buzz-pair test-vectors
|
||||
//! ```
|
||||
//!
|
||||
//! The `source` subcommand acts as the secret-holding device; `target` acts
|
||||
//! as the receiving device. Together they exercise the full NIP-AB protocol
|
||||
//! over a live Nostr relay.
|
||||
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
use buzz_core::kind::KIND_PAIRING;
|
||||
use buzz_core::pairing::session::PairingSession;
|
||||
use buzz_core::pairing::{
|
||||
crypto::{derive_sas, derive_session_id, derive_transcript_hash, format_sas},
|
||||
qr::{decode_qr, encode_qr},
|
||||
types::PayloadType,
|
||||
PairingError,
|
||||
};
|
||||
use clap::{Parser, Subcommand};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use nostr::{Event, EventBuilder, Keys, RelayUrl, SecretKey, ToBech32};
|
||||
use tokio::time::timeout;
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "buzz-pair",
|
||||
about = "NIP-AB device pairing interop testing tool",
|
||||
long_about = "Test the NIP-AB device pairing protocol end-to-end.\n\
|
||||
Run 'source' on one terminal and 'target' on another."
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Act as the source device (holds the secret, displays QR code).
|
||||
Source {
|
||||
/// Relay WebSocket URL to use for pairing.
|
||||
#[arg(long, default_value = "wss://relay.damus.io")]
|
||||
relay: String,
|
||||
|
||||
/// nsec (bech32) of the key to transfer. If omitted, generates a test key.
|
||||
#[arg(long)]
|
||||
nsec: Option<String>,
|
||||
},
|
||||
|
||||
/// Act as the target device (scans QR code, receives the secret).
|
||||
Target {
|
||||
/// Override relay URL (default: read from QR URI).
|
||||
#[arg(long)]
|
||||
relay: Option<String>,
|
||||
|
||||
/// Print received secrets to stdout. Off by default.
|
||||
#[arg(long, default_value_t = false)]
|
||||
show_secret: bool,
|
||||
},
|
||||
|
||||
/// Print NIP-AB test vectors derived from the spec's fixed keys.
|
||||
TestVectors,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum CliError {
|
||||
#[error("pairing error: {0}")]
|
||||
Pairing(#[from] PairingError),
|
||||
|
||||
#[error("WebSocket error: {0}")]
|
||||
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
|
||||
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("invalid nsec: {0}")]
|
||||
InvalidNsec(String),
|
||||
|
||||
#[error("timeout waiting for peer")]
|
||||
Timeout,
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let cli = Cli::parse();
|
||||
if let Err(e) = run(cli.command).await {
|
||||
eprintln!("error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(cmd: Cmd) -> Result<(), CliError> {
|
||||
match cmd {
|
||||
Cmd::Source { relay, nsec } => cmd_source(relay, nsec).await,
|
||||
Cmd::Target { relay, show_secret } => cmd_target(relay, show_secret).await,
|
||||
Cmd::TestVectors => cmd_test_vectors(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_source(relay_url: String, nsec: Option<String>) -> Result<(), CliError> {
|
||||
// Resolve the payload to transfer.
|
||||
let (payload_str, payload_type) = resolve_payload(nsec)?;
|
||||
|
||||
// Create pairing session.
|
||||
let (mut session, qr) = PairingSession::new_source(relay_url.clone());
|
||||
let qr_uri = encode_qr(&qr);
|
||||
|
||||
println!("QR URI (contains session secret — do not share beyond the target device):");
|
||||
println!("{qr_uri}");
|
||||
println!("Waiting for target to scan QR code...");
|
||||
|
||||
// Connect to relay and handle NIP-42 auth if required.
|
||||
// Auth uses the session's ephemeral keys so the relay accepts our events.
|
||||
let (ws, _) = connect_async(&relay_url).await?;
|
||||
let (mut write, mut read) = ws.split();
|
||||
handle_nip42_auth(&mut read, &mut write, &session, &relay_url).await?;
|
||||
|
||||
// Subscribe for events tagged to our ephemeral pubkey.
|
||||
let our_pk = session.pubkey().to_hex();
|
||||
let sub_msg = serde_json::json!([
|
||||
"REQ",
|
||||
"pair",
|
||||
{ "kinds": [KIND_PAIRING], "#p": [our_pk] }
|
||||
]);
|
||||
write
|
||||
.send(Message::Text(sub_msg.to_string().into()))
|
||||
.await?;
|
||||
|
||||
// Wait for EOSE to confirm the subscription is registered on the relay
|
||||
// before the target can race us with an offer we'd miss.
|
||||
wait_for_eose(&mut read, "pair", Duration::from_secs(10)).await?;
|
||||
|
||||
// Wait for a valid offer event (silently discard junk per NIP-AB §Event Validation).
|
||||
let sas = loop {
|
||||
let event = wait_for_event(&mut read, "pair", Duration::from_secs(120)).await?;
|
||||
check_for_abort(&mut session, &event)?;
|
||||
match session.handle_offer(&event) {
|
||||
Ok(sas) => break sas,
|
||||
Err(_) => continue, // silently discard per NIP-AB §Event Validation item 7
|
||||
}
|
||||
};
|
||||
println!("Offer received from target.");
|
||||
println!("SAS code: {sas}");
|
||||
print!("Does your other device show {sas}? [y/n]: ");
|
||||
io::stdout().flush()?;
|
||||
|
||||
let confirmed = read_yes_no()?;
|
||||
if !confirmed {
|
||||
// Send abort and exit.
|
||||
if let Some(abort_event) =
|
||||
session.abort(buzz_core::pairing::types::AbortReason::SasMismatch)?
|
||||
{
|
||||
publish_event(&mut write, &abort_event).await?;
|
||||
}
|
||||
return Err(CliError::Other("SAS mismatch — session aborted".into()));
|
||||
}
|
||||
|
||||
// Send sas-confirm.
|
||||
let sas_confirm_event = session.confirm_sas()?;
|
||||
publish_event(&mut write, &sas_confirm_event).await?;
|
||||
println!("Sending identity...");
|
||||
|
||||
// Send payload.
|
||||
let payload_event = session.send_payload(payload_type, payload_str)?;
|
||||
publish_event(&mut write, &payload_event).await?;
|
||||
|
||||
// Wait for a valid complete event (skip junk; exit on peer abort).
|
||||
// Surface complete(success=false) explicitly instead of swallowing it.
|
||||
loop {
|
||||
let event = wait_for_event(&mut read, "pair", Duration::from_secs(60)).await?;
|
||||
check_for_abort(&mut session, &event)?;
|
||||
match session.handle_complete(&event) {
|
||||
Ok(()) => break,
|
||||
Err(PairingError::UnexpectedMessage { ref got, .. })
|
||||
if got.contains("success=false") =>
|
||||
{
|
||||
return Err(CliError::Other(
|
||||
"target reported failure importing the key — check the other device".into(),
|
||||
));
|
||||
}
|
||||
Err(_) => continue, // silently discard per NIP-AB §Event Validation item 7
|
||||
}
|
||||
}
|
||||
|
||||
println!("Transfer complete! ✓");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_target(relay_override: Option<String>, show_secret: bool) -> Result<(), CliError> {
|
||||
// Read QR URI from stdin.
|
||||
print!("Paste the QR URI: ");
|
||||
io::stdout().flush()?;
|
||||
let qr_uri = read_line()?;
|
||||
let qr_uri = qr_uri.trim();
|
||||
|
||||
// Decode QR.
|
||||
let mut qr = decode_qr(qr_uri)?;
|
||||
|
||||
// Apply relay override if provided.
|
||||
if let Some(relay) = relay_override {
|
||||
qr.relays = vec![relay];
|
||||
}
|
||||
|
||||
let relay_url = qr
|
||||
.relays
|
||||
.first()
|
||||
.cloned()
|
||||
.ok_or_else(|| CliError::Other("QR URI contains no relay URL".into()))?;
|
||||
|
||||
println!("Connecting to {relay_url}...");
|
||||
|
||||
// Create target session + offer event.
|
||||
let (mut session, offer_event) = PairingSession::new_target(&qr)?;
|
||||
|
||||
// Connect to relay and handle NIP-42 auth if required.
|
||||
let (ws, _) = connect_async(&relay_url).await?;
|
||||
let (mut write, mut read) = ws.split();
|
||||
handle_nip42_auth(&mut read, &mut write, &session, &relay_url).await?;
|
||||
|
||||
// Subscribe BEFORE publishing the offer so we don't miss a fast
|
||||
// sas-confirm from the source (fixes a race condition).
|
||||
let our_pk = session.pubkey().to_hex();
|
||||
let sub_msg = serde_json::json!([
|
||||
"REQ",
|
||||
"pair",
|
||||
{ "kinds": [KIND_PAIRING], "#p": [our_pk] }
|
||||
]);
|
||||
write
|
||||
.send(Message::Text(sub_msg.to_string().into()))
|
||||
.await?;
|
||||
|
||||
// Wait for EOSE to confirm the subscription is registered on the relay
|
||||
// before publishing the offer. Without this, the relay may process our
|
||||
// EVENT before our REQ, causing us to miss the source's response.
|
||||
wait_for_eose(&mut read, "pair", Duration::from_secs(10)).await?;
|
||||
|
||||
// Now publish the offer event.
|
||||
publish_event(&mut write, &offer_event).await?;
|
||||
|
||||
// Target already knows the SAS from the QR scan — display it now so
|
||||
// the user can compare while the source is also displaying its code.
|
||||
let sas = session
|
||||
.sas_code()
|
||||
.ok_or_else(|| CliError::Other("no SAS code".into()))?;
|
||||
println!("SAS code: {sas}");
|
||||
println!("Verify this matches your source device.");
|
||||
println!("Offer sent. Waiting for source to confirm SAS...");
|
||||
|
||||
// Wait for a valid sas-confirm event (skip junk; exit on peer abort).
|
||||
// TranscriptMismatch is a hard security failure (possible MITM) —
|
||||
// surface it immediately rather than swallowing it in the generic handler.
|
||||
loop {
|
||||
let event = wait_for_event(&mut read, "pair", Duration::from_secs(120)).await?;
|
||||
check_for_abort(&mut session, &event)?;
|
||||
match session.handle_sas_confirm(&event) {
|
||||
Ok(_) => break,
|
||||
Err(PairingError::TranscriptMismatch) => {
|
||||
// NIP-AB §Step 3: target MUST send abort with reason
|
||||
// "sas_mismatch" on transcript hash mismatch.
|
||||
if let Ok(Some(abort_event)) =
|
||||
session.abort(buzz_core::pairing::types::AbortReason::SasMismatch)
|
||||
{
|
||||
let _ = publish_event(&mut write, &abort_event).await;
|
||||
}
|
||||
return Err(CliError::Other(
|
||||
"SECURITY: transcript hash mismatch — possible MITM attack. Session aborted."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
Err(_) => continue, // silently discard per NIP-AB §Event Validation item 7
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit target-side confirmation: the user must approve.
|
||||
print!("Does your source device show {sas}? [y/n]: ");
|
||||
io::stdout().flush()?;
|
||||
let confirmed = read_yes_no()?;
|
||||
if !confirmed {
|
||||
if let Some(abort_event) =
|
||||
session.abort(buzz_core::pairing::types::AbortReason::SasMismatch)?
|
||||
{
|
||||
publish_event(&mut write, &abort_event).await?;
|
||||
}
|
||||
return Err(CliError::Other("SAS mismatch — session aborted".into()));
|
||||
}
|
||||
session.confirm_target_sas()?;
|
||||
println!("SAS confirmed. Waiting for payload...");
|
||||
|
||||
// Wait for a valid payload event (silently discard junk; exit on peer abort).
|
||||
let (payload_type, payload) = loop {
|
||||
let event = wait_for_event(&mut read, "pair", Duration::from_secs(60)).await?;
|
||||
check_for_abort(&mut session, &event)?;
|
||||
match session.handle_payload(&event) {
|
||||
Ok(result) => break result,
|
||||
Err(_) => continue, // silently discard per NIP-AB §Event Validation item 7
|
||||
}
|
||||
};
|
||||
|
||||
// Display received payload (secrets gated behind --show-secret).
|
||||
let kind_label = match payload_type {
|
||||
PayloadType::Nsec => "nsec",
|
||||
PayloadType::Bunker => "bunker",
|
||||
PayloadType::Connect => "nostrconnect",
|
||||
PayloadType::Custom => "custom",
|
||||
};
|
||||
println!("Received {kind_label} payload!");
|
||||
if show_secret {
|
||||
println!("{kind_label}: {}", &*payload);
|
||||
} else {
|
||||
println!("(use --show-secret to display the received secret)");
|
||||
}
|
||||
|
||||
// Send complete event.
|
||||
let complete_event = session.send_complete()?;
|
||||
publish_event(&mut write, &complete_event).await?;
|
||||
|
||||
println!("Transfer complete! ✓");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_test_vectors() -> Result<(), CliError> {
|
||||
// Fixed test keys from the NIP-AB spec.
|
||||
let session_secret: [u8; 32] =
|
||||
hex_to_32("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2")?;
|
||||
let source_priv: [u8; 32] =
|
||||
hex_to_32("7f4c11a9c9d1e3b5a7f2e4d6c8b0a2f4e6d8c0b2a4f6e8d0c2b4a6f8e0d2c4b5")?;
|
||||
let target_priv: [u8; 32] =
|
||||
hex_to_32("3a5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b3c5d7e9f1a3b5c7d9e1f3a5b")?;
|
||||
|
||||
// Derive keys.
|
||||
let src_sk =
|
||||
SecretKey::from_slice(&source_priv).map_err(|e| CliError::InvalidNsec(e.to_string()))?;
|
||||
let tgt_sk =
|
||||
SecretKey::from_slice(&target_priv).map_err(|e| CliError::InvalidNsec(e.to_string()))?;
|
||||
let src_keys = Keys::new(src_sk);
|
||||
let tgt_keys = Keys::new(tgt_sk);
|
||||
|
||||
let source_pubkey: [u8; 32] = src_keys.public_key().to_bytes();
|
||||
let target_pubkey: [u8; 32] = tgt_keys.public_key().to_bytes();
|
||||
|
||||
// Derive all values.
|
||||
let session_id = derive_session_id(&session_secret);
|
||||
let ecdh_shared =
|
||||
nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key())
|
||||
.map_err(|e| CliError::Other(e.to_string()))?;
|
||||
let (sas_code_u32, sas_input) = derive_sas(&ecdh_shared, &session_secret);
|
||||
let sas_code = format_sas(sas_code_u32);
|
||||
let transcript_hash = derive_transcript_hash(
|
||||
&session_id,
|
||||
&source_pubkey,
|
||||
&target_pubkey,
|
||||
&sas_input,
|
||||
&session_secret,
|
||||
);
|
||||
|
||||
// Print as a table suitable for pasting into the NIP spec.
|
||||
let col_w = 20usize;
|
||||
let val_w = 66usize;
|
||||
let sep = format!("+-{:-<col_w$}-+-{:-<val_w$}-+", "", "");
|
||||
|
||||
println!("{sep}");
|
||||
println!("| {:<col_w$} | {:<val_w$} |", "Field", "Value");
|
||||
println!("{sep}");
|
||||
|
||||
let rows: &[(&str, String)] = &[
|
||||
("session_secret", hex::encode(session_secret)),
|
||||
("source_priv", hex::encode(source_priv)),
|
||||
("target_priv", hex::encode(target_priv)),
|
||||
("source_pubkey", hex::encode(source_pubkey)),
|
||||
("target_pubkey", hex::encode(target_pubkey)),
|
||||
("ecdh_shared", hex::encode(ecdh_shared)),
|
||||
("session_id", hex::encode(session_id)),
|
||||
("sas_input", hex::encode(sas_input)),
|
||||
("sas_code", sas_code),
|
||||
("transcript_hash", hex::encode(transcript_hash)),
|
||||
];
|
||||
|
||||
for (field, value) in rows {
|
||||
println!("| {field:<col_w$} | {value:<val_w$} |");
|
||||
}
|
||||
println!("{sep}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check whether `event` is an abort from the peer. If so, transition the
|
||||
/// session and return an error the caller can propagate. Otherwise return
|
||||
/// `Ok(())` so the caller can proceed with its own handler.
|
||||
fn check_for_abort(session: &mut PairingSession, event: &Event) -> Result<(), CliError> {
|
||||
match session.handle_abort(event) {
|
||||
Ok(reason) => Err(CliError::Other(format!(
|
||||
"peer aborted the session: {reason:?}"
|
||||
))),
|
||||
Err(_) => Ok(()), // not an abort — caller should try its own handler
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle NIP-42 authentication if the relay requires it.
|
||||
///
|
||||
/// Uses the pairing session's ephemeral keys to authenticate, ensuring the
|
||||
/// relay accepts events signed by those same keys.
|
||||
async fn handle_nip42_auth<R, W>(
|
||||
read: &mut R,
|
||||
write: &mut W,
|
||||
session: &PairingSession,
|
||||
relay_url: &str,
|
||||
) -> Result<(), CliError>
|
||||
where
|
||||
R: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin,
|
||||
W: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin,
|
||||
{
|
||||
// Wait up to 3 seconds for an AUTH challenge. Many relays don't require
|
||||
// auth at all, so a timeout here is normal (not an error).
|
||||
let auth_result = timeout(Duration::from_secs(3), async {
|
||||
loop {
|
||||
let msg = read
|
||||
.next()
|
||||
.await
|
||||
.ok_or_else(|| CliError::Other("relay closed during auth".into()))??;
|
||||
|
||||
if let Message::Text(text) = msg {
|
||||
if let Some(challenge) = parse_auth_challenge(text.as_str()) {
|
||||
return Ok(challenge);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let challenge = match auth_result {
|
||||
Ok(Ok(c)) => c,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => return Ok(()), // No AUTH challenge — relay doesn't require it
|
||||
};
|
||||
|
||||
// Build and send the NIP-42 auth response using the session's ephemeral keys.
|
||||
let relay_url_parsed = RelayUrl::parse(relay_url)
|
||||
.map_err(|e| CliError::Other(format!("invalid relay URL: {e}")))?;
|
||||
let auth_event = session
|
||||
.sign_event(EventBuilder::auth(challenge, relay_url_parsed))
|
||||
.map_err(|e| CliError::Other(format!("failed to sign auth event: {e}")))?;
|
||||
|
||||
let msg = serde_json::json!(["AUTH", auth_event]);
|
||||
write.send(Message::Text(msg.to_string().into())).await?;
|
||||
|
||||
// Wait for OK response (up to 5 seconds).
|
||||
let _ = timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let msg = read
|
||||
.next()
|
||||
.await
|
||||
.ok_or_else(|| CliError::Other("relay closed during auth".into()))??;
|
||||
if let Message::Text(text) = msg {
|
||||
if text.contains("\"OK\"") || text.contains("[\"OK\"") {
|
||||
return Ok::<(), CliError>(());
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse an `["AUTH", "<challenge>"]` relay message.
|
||||
fn parse_auth_challenge(text: &str) -> Option<String> {
|
||||
let arr: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
let arr = arr.as_array()?;
|
||||
if arr.len() >= 2 && arr[0].as_str()? == "AUTH" {
|
||||
return arr[1].as_str().map(|s| s.to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Publish a Nostr event to the relay.
|
||||
async fn publish_event<S>(write: &mut S, event: &Event) -> Result<(), CliError>
|
||||
where
|
||||
S: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin,
|
||||
{
|
||||
let msg = serde_json::json!(["EVENT", event]);
|
||||
write.send(Message::Text(msg.to_string().into())).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait for the next [`Event`] from the relay on a given subscription ID.
|
||||
///
|
||||
/// Skips `OK`, `EOSE`, and non-EVENT messages. Returns [`CliError::Timeout`]
|
||||
/// if no event arrives within `dur`.
|
||||
async fn wait_for_event<S>(read: &mut S, sub_id: &str, dur: Duration) -> Result<Event, CliError>
|
||||
where
|
||||
S: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin,
|
||||
{
|
||||
timeout(dur, async {
|
||||
loop {
|
||||
let msg = read
|
||||
.next()
|
||||
.await
|
||||
.ok_or_else(|| CliError::Other("relay connection closed".into()))??;
|
||||
|
||||
if let Message::Text(text) = msg {
|
||||
if let Some(event) = parse_relay_event(text.as_str(), sub_id) {
|
||||
return Ok(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CliError::Timeout)?
|
||||
}
|
||||
|
||||
/// Wait for an EOSE message from the relay for the given subscription ID.
|
||||
///
|
||||
/// EOSE (`["EOSE", "<sub_id>"]`) confirms the subscription is registered and
|
||||
/// all historical events have been delivered. Skips non-EOSE messages.
|
||||
async fn wait_for_eose<S>(read: &mut S, sub_id: &str, dur: Duration) -> Result<(), CliError>
|
||||
where
|
||||
S: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin,
|
||||
{
|
||||
timeout(dur, async {
|
||||
loop {
|
||||
let msg = read
|
||||
.next()
|
||||
.await
|
||||
.ok_or_else(|| CliError::Other("relay closed while waiting for EOSE".into()))??;
|
||||
if let Message::Text(text) = msg {
|
||||
if let Ok(arr) = serde_json::from_str::<serde_json::Value>(text.as_str()) {
|
||||
if let Some(arr) = arr.as_array() {
|
||||
if arr.len() >= 2
|
||||
&& arr[0].as_str() == Some("EOSE")
|
||||
&& arr[1].as_str() == Some(sub_id)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CliError::Timeout)?
|
||||
}
|
||||
|
||||
/// Parse a relay message of the form `["EVENT", "<sub_id>", <event_json>]`.
|
||||
///
|
||||
/// Returns `None` for any other message type.
|
||||
fn parse_relay_event(text: &str, sub_id: &str) -> Option<Event> {
|
||||
let arr: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
let arr = arr.as_array()?;
|
||||
|
||||
if arr.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
if arr[0].as_str()? != "EVENT" {
|
||||
return None;
|
||||
}
|
||||
if arr[1].as_str()? != sub_id {
|
||||
return None;
|
||||
}
|
||||
|
||||
serde_json::from_value(arr[2].clone()).ok()
|
||||
}
|
||||
|
||||
/// Resolve the payload to send.
|
||||
///
|
||||
/// If `nsec` is provided, parse it as bech32 and return the raw nsec string.
|
||||
/// Otherwise generate a fresh test key and return its nsec.
|
||||
fn resolve_payload(nsec: Option<String>) -> Result<(Zeroizing<String>, PayloadType), CliError> {
|
||||
match nsec {
|
||||
Some(s) => {
|
||||
// Validate it parses as a secret key.
|
||||
let _sk = SecretKey::parse(&s).map_err(|e| CliError::InvalidNsec(e.to_string()))?;
|
||||
Ok((Zeroizing::new(s), PayloadType::Nsec))
|
||||
}
|
||||
None => {
|
||||
let keys = Keys::generate();
|
||||
let nsec_str = keys
|
||||
.secret_key()
|
||||
.to_bech32()
|
||||
.map_err(|e| CliError::InvalidNsec(e.to_string()))?;
|
||||
println!("(no --nsec provided; using generated test key)");
|
||||
Ok((Zeroizing::new(nsec_str), PayloadType::Nsec))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a single line from stdin (trims trailing newline).
|
||||
fn read_line() -> Result<String, CliError> {
|
||||
let stdin = io::stdin();
|
||||
let mut line = String::new();
|
||||
stdin.lock().read_line(&mut line)?;
|
||||
Ok(line
|
||||
.trim_end_matches('\n')
|
||||
.trim_end_matches('\r')
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// Prompt for y/n and return true for 'y'/'Y'.
|
||||
fn read_yes_no() -> Result<bool, CliError> {
|
||||
let line = read_line()?;
|
||||
Ok(matches!(line.trim(), "y" | "Y" | "yes" | "Yes" | "YES"))
|
||||
}
|
||||
|
||||
/// Decode a 64-char hex string into a `[u8; 32]`.
|
||||
fn hex_to_32(s: &str) -> Result<[u8; 32], CliError> {
|
||||
let bytes = hex::decode(s).map_err(|e| CliError::Other(format!("invalid hex '{s}': {e}")))?;
|
||||
bytes
|
||||
.try_into()
|
||||
.map_err(|_| CliError::Other(format!("expected 32 bytes, got wrong length for '{s}'")))
|
||||
}
|
||||
Reference in New Issue
Block a user