mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
## Summary
Bump the pinned Rust toolchain from 1.95.0 to 1.98.1 so release builds
work on macOS 27 hosts.
Rust ≤ 1.97 strips Apple-target artifacts with its bundled
`llvm-objcopy`, which leaves the Mach-O `LC_SYMTAB` string pool
(`__LINKEDIT`) only 4-byte aligned. macOS 27's dyld refuses to `dlopen`
such images (`mis-aligned LINKEDIT string pool`). Because cargo applies
the release profile's default `strip = "debuginfo"` to proc-macro dylibs
too, `cargo build --release` of any crate that uses sqlx macros
(`buzz-db`, `buzz-relay`, `buzz-admin`, …) fails on macOS 27: rustc
cannot load `libsqlx_macros-*.dylib`. Dev builds pass because the dev
profile does not strip, which is why this only shows up as a
release/packaging failure.
The fix (llvm/llvm-project#203680, pulled into rustc by
rust-lang/rust#158410 for rust-lang/rust#157750) first shipped in Rust
1.98.0 and was never backported to 1.97.x. `block/buzz-app` pinned
1.98.1 for the same failure in block/buzz-app#529. With this bump the
`CARGO_PROFILE_RELEASE_BUILD_OVERRIDE_STRIP=none` / custom-linker
workarounds people have been using locally are no longer needed.
### What changes
Two commits: the toolchain bump (with the desktop clippy fixes), then
the workspace clippy fixes.
| Location | Change |
|---|---|
| `rust-toolchain.toml` | `channel = "1.95.0"` → `"1.98.1"` (profile
unchanged) |
| `Dockerfile`, `Dockerfile.push-gateway` | `ARG RUST_VERSION=1.95` →
`1.98` |
| `Dockerfile.sprig` | `rust:1.98-alpine3.22` with the manifest-list
digest verified on Docker Hub (shared by the `1.98` and `1.98.1` tags) |
| `.github/workflows/release.yml` | `dtolnay/rust-toolchain` pinned to
the tip of its `1.98.1` branch (`18f04874`) for the Windows desktop
release job |
| `benchmarks/harbor-buzz-orchestra` | `RUST_IMAGE = rust:1.98-alpine` |
| `_ci-rust.yml`, `windows-canary.yml`, `app_state_tests.rs` | stale
"1.95" comments reworded; the `unknown_lints` allow stays with a
version-agnostic rationale |
| `desktop/src-tauri` | lints clippy 1.98 newly raises under `-D
warnings`: `chunks_exact_to_as_chunks` at five fixed-width PCM/UTF-16
decoders, `needless_borrows_for_generic_args` on the `Copy` `norm`
closure in `config_bridge/reader.rs`, and a redundant `Engine as _`
import in `hpke_key_backup_tests.rs` that `use super::*` already
provides |
| Workspace crates (9) | the 30 lints clippy 1.98 newly raises in `cargo
clippy --workspace --all-targets -- -D warnings`, listed below |
Workspace lint fixes:
| Lint | Sites | Fix |
|---|---|---|
| `result_large_err` (20) | `buzz-relay` `api/git/transport.rs` (8),
`api/git/settings.rs` (6), `api/gifs.rs`, `api/workflows.rs`,
`api/profiles.rs`; `buzz-push-gateway` `verify_installation_assertion` |
Clippy 1.98 now checks `async fn`s, so every `async fn … -> Result<_,
axum::response::Response>` trips it. These follow the existing allow
pattern in `api/media.rs` and `nip_fi_http.rs` ("Response is the natural
error type for axum handlers"): module-level in the two git files,
per-function elsewhere. Boxing `Response` would touch every handler for
no runtime benefit. |
| `chunks_exact_to_as_chunks` (3) | `buzz-media` MP4 brand scan;
`buzz-voice` npy f32 reader and stereo test fixture | `chunks_exact(N)`
→ `as_chunks::<N>().0` |
| `question_mark` (3) | `buzz-acp` queue flush fallback and
slash-command mention skip; `buzz-agent` `find_git_root` | `match … {
None => return None }` → `?` |
| `useless_format` (2) | `buzz-auth` nip_fi startup test policies |
`format!("…")` → `"…".to_string()` |
| `useless_borrows_in_formatting` (1) | `buzz-pairing-cli` secret print
| drop the borrow |
| `manual_noop_waker` (1) | `buzz-sdk` broker test `block_on` |
`Waker::noop()` (stable since 1.85) |
All `as_chunks` rewrites keep `chunks_exact`'s remainder-dropping
semantics, and `as_chunks` is stable since 1.88, which is the MSRV.
One change in `api/git/transport.rs` is not a lint fix. Staging that
file trips the pre-commit secret scanner on its existing test-only ES256
fixture (`GIT_TEST_EC_PEM`), and the scanner only honours a suppression
marker on the flagged line. So the `\`-continued string literal is
rewritten as a byte-identical `concat!` whose `BEGIN` line carries
`sadscan:disable kingfisher.privkey.2`.
Left as is on purpose: `Cargo.toml` `rust-version = "1.88.0"` (MSRV
floor, a separate concern), the README/CONTRIBUTING "1.88+" text, Hermit
(no Rust packages there), the lockfiles, and the `(1.95.0)` comment in
`scripts/start-isolated-test-relay.sh` (the pre-commit secret scanner
flags that file's pre-existing dev-only `DATABASE_URL` whenever it is
staged).
### Related issue
Fixes #8074
### Testing
All under rustc/clippy 1.98.1 on a macOS 27 host (Darwin 27.0.0,
aarch64), with no `RUSTFLAGS` / `SDKROOT` / strip overrides in the
environment:
- `cargo build --release -p buzz-db` (the #8074 repro: the release
profile strips the proc-macro dylib): **builds**. The resulting
`libsqlx_macros-*.dylib` (14 MB) has `LC_SYMTAB stroff % 8 == 0` per
`otool -l`. The same build on 1.95.0 died with `mis-aligned LINKEDIT
string pool`.
- `just clippy` (workspace, `-D warnings`) and `cargo fmt --check`
(workspace and `desktop/src-tauri`): pass
- `just desktop-tauri-clippy` (default and `mesh-llm` feature sets):
pass
- `just test-unit`: every crate passes. The `buzz-acp` binary failed
only under pre-push lane contention; rerun solo it is 1060 passed, 0
failed.
- `just desktop-tauri-test`: 3422 passed, 0 failed
- After the lint fixes: lib/bin unit tests for `buzz-acp`, `buzz-agent`,
`buzz-auth`, `buzz-media`, `buzz-voice`, `buzz-sdk`, `buzz-pairing-cli`,
and `buzz-relay`'s `api:
:transport` tests, including the
Postgres-backed
`enforce_mode_git_pack_routes_missing_assertion_exact_bytes`, which
parses the rewritten PEM: pass
`cfg(windows)` code was not linted locally (no Windows target
installed). CI's Windows Rust job runs workspace clippy for
`x86_64-pc-windows-msvc`, and that passes on this branch. CI also builds
the relay, push-gateway and sprig images on the new `rust:1.98` bases
for amd64 and arm64.
No runtime behavior changes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
632 lines
22 KiB
Rust
632 lines
22 KiB
Rust
//! `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() {
|
|
// Install ring as the process-level rustls CryptoProvider before any WSS
|
|
// connection. The documented default relay is `wss://relay.damus.io`, and a
|
|
// multi-package release build unifies both ring and aws-lc-rs across the
|
|
// workspace, so rustls cannot auto-select a provider. Without this,
|
|
// `buzz-pair source` panics at rustls crypto/mod.rs after printing the QR.
|
|
// The `let _ =` swallow is intentional: a second install returns Err.
|
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
|
|
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}'")))
|
|
}
|