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

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
+25
View File
@@ -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 }
+128
View File
@@ -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.
+623
View File
@@ -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}'")))
}