mirror of
https://github.com/block/buzz.git
synced 2026-11-11 20:01:09 +00:00
## TL;DR Retry missing desktop-provider socket ownership proof on the same open connection, within the existing five-second limit. Credentials and provider requests remain withheld until the exact server-side socket belongs to the caller's UID; a wrong UID or lookup error still fails immediately. The ownership code moves into its own module to keep the existing oversized file within the repository's file-size ratchet. The macOS lookup now bounds output and checks the child exit status while retaining cancellation cleanup. ### Related issue Fixes NEXUS-4336. ### Testing 42 provider tests passed on Linux, including synthetic transport tests for delayed/absent proof, wrong UID, lookup failure, deadline expiry, zero early request bytes, and child cleanup after timeout/cancellation. Targeted Clippy, formatting, and file-size checks passed. Removing the retry or bypassing proof caused the production-path regression to fail. Native macOS execution, installed/source parity, and the cause of any live authentication failure remain unverified. This source change does not establish installed or live recovery. No human-test attestation is claimed. Generated with Codex. Posted by Codex (gpt-6.1-sol) on behalf of kylez. Signed-off-by: Kyle Zemek <kylez@squareup.com> Co-authored-by: Codex <noreply@openai.com>
2634 lines
100 KiB
Rust
2634 lines
100 KiB
Rust
//! The desktop link of the credential chain: a running Buzz Desktop acts as
|
|
//! one identity over loopback (`docs/local-credential-provider.md`).
|
|
//!
|
|
//! The caller never holds the private key. It finds a provider through an
|
|
//! inherited `BUZZ_CREDENTIAL_PROVIDER` token or a paired token in the OS
|
|
//! keychain, and sends each request on a connection whose server side it has
|
|
//! confirmed belongs to the same OS user, for inherited and paired tokens
|
|
//! alike.
|
|
//!
|
|
//! Version 1 has no Windows transport, so the link is compiled for Unix only.
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use nostr::signer::{SignerBackend, SignerError};
|
|
use nostr::{Event, JsonUtil, PublicKey, Tag, UnsignedEvent};
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::TcpStream;
|
|
|
|
use crate::error::CliError;
|
|
|
|
mod ownership;
|
|
use ownership::{connect, current_uid};
|
|
|
|
pub const PROVIDER_ENV: &str = "BUZZ_CREDENTIAL_PROVIDER";
|
|
|
|
/// The protocol version this caller speaks.
|
|
const VERSION: u32 = 1;
|
|
const VERSION_HEADER: &str = "Buzz-Credential-Provider";
|
|
|
|
/// Methods the provider may hold open for a person's decision (up to 120s).
|
|
pub(crate) const HELD_TIMEOUT: Duration = Duration::from_secs(150);
|
|
const QUICK_TIMEOUT: Duration = Duration::from_secs(10);
|
|
/// Every result is built from a request's params, and the desktop reads at
|
|
/// most 2 MiB of request body, so no result is larger than that in bytes.
|
|
/// Escaping a result as a JSON string grows it at most sixfold: a decrypted
|
|
/// NIP-44 plaintext (extended lengths have no 64 KiB ceiling) may be all
|
|
/// control characters, each becoming `\u00XX`. The 64 KiB on top covers a
|
|
/// signed event's added `id`, `pubkey` and `sig`, the envelope and the headers.
|
|
const MAX_REQUEST_BYTES: usize = 2 * 1024 * 1024;
|
|
const MAX_RESPONSE_BYTES: usize = 6 * MAX_REQUEST_BYTES + 64 * 1024;
|
|
|
|
/// `rate_limited` refuses before acting, so the same envelope is re-sent:
|
|
/// up to this many times, each after `retry_after` capped at the wait below.
|
|
const RATE_LIMIT_RETRIES: u32 = 4;
|
|
const RATE_LIMIT_MAX_WAIT_SECS: u64 = 30;
|
|
/// One `retry_after` second; tests shorten it so throttling stays fast.
|
|
const RETRY_AFTER_UNIT: Duration = if cfg!(test) {
|
|
Duration::from_millis(10)
|
|
} else {
|
|
Duration::from_secs(1)
|
|
};
|
|
|
|
/// A provider failure, by the contract's error code.
|
|
#[derive(Debug)]
|
|
pub enum ProviderError {
|
|
/// `revoked`: a paired token is dropped and the next paired one tried;
|
|
/// an inherited one fails the command.
|
|
Revoked,
|
|
/// No common protocol version: the provider does not apply.
|
|
NoCommonVersion,
|
|
/// `unknown_method`: degrade where the caller can.
|
|
UnknownMethod,
|
|
/// Any other code. Unrecognized codes are reported as `denied`.
|
|
Code {
|
|
code: &'static str,
|
|
message: Option<String>,
|
|
retry_after: Option<u64>,
|
|
},
|
|
/// The provider could not be reached or answered outside the contract.
|
|
Transport(String),
|
|
/// The same-user check ran out of time before anything was sent.
|
|
Unconfirmed,
|
|
/// The provider port is held by another user, or no owner was found:
|
|
/// nothing was sent.
|
|
OtherUser,
|
|
}
|
|
|
|
impl ProviderError {
|
|
fn into_cli(self, method: &str) -> CliError {
|
|
match self {
|
|
Self::Revoked => CliError::Auth(
|
|
"the Buzz desktop revoked this CLI's token; run `buzz auth desktop pair` again"
|
|
.into(),
|
|
),
|
|
Self::NoCommonVersion => CliError::Auth(
|
|
"the Buzz desktop speaks no credential-provider version this CLI supports".into(),
|
|
),
|
|
Self::UnknownMethod => CliError::Auth(format!(
|
|
"the Buzz desktop does not implement `{method}`; update Buzz"
|
|
)),
|
|
Self::Code {
|
|
code,
|
|
message,
|
|
retry_after,
|
|
} => {
|
|
let detail = message.map(|m| format!(": {m}")).unwrap_or_default();
|
|
match code {
|
|
"approval_required" => {
|
|
CliError::Auth(format!("approve this request in Buzz, then retry{detail}"))
|
|
}
|
|
"rate_limited" => CliError::Throttled(format!(
|
|
"the Buzz desktop is rate-limiting this CLI; retry in {}s{detail}",
|
|
retry_after.unwrap_or(1)
|
|
)),
|
|
"unavailable" => {
|
|
CliError::Auth(format!("the Buzz desktop cannot act now{detail}"))
|
|
}
|
|
"invalid_request" => CliError::Other(format!(
|
|
"the Buzz desktop rejected `{method}` as malformed{detail}"
|
|
)),
|
|
_ => CliError::Auth(format!("the Buzz desktop refused `{method}`{detail}")),
|
|
}
|
|
}
|
|
Self::Transport(why) => CliError::Auth(format!("Buzz desktop: {why}")),
|
|
Self::Unconfirmed => CliError::Unavailable(
|
|
"Buzz desktop: the same-user check timed out; nothing was sent".into(),
|
|
),
|
|
Self::OtherUser => CliError::Auth(
|
|
"Buzz desktop: could not confirm the provider runs as this user; nothing was sent"
|
|
.into(),
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `unsigned` dated now, if it is a short-lived auth proof (NIP-42, NIP-98
|
|
/// or Blossom); a Blossom `expiration` moves by the same amount.
|
|
fn renewed(mut unsigned: UnsignedEvent) -> UnsignedEvent {
|
|
if !matches!(unsigned.kind.as_u16(), 22242 | 27235 | 24242) {
|
|
return unsigned;
|
|
}
|
|
let now = nostr::Timestamp::now();
|
|
let shift = now.as_secs().saturating_sub(unsigned.created_at.as_secs());
|
|
if shift == 0 {
|
|
return unsigned;
|
|
}
|
|
let tags = unsigned
|
|
.tags
|
|
.clone()
|
|
.to_vec()
|
|
.into_iter()
|
|
.map(|tag| match tag.as_slice() {
|
|
[name, exp] if name == "expiration" => match exp.parse::<u64>() {
|
|
Ok(exp) => Tag::parse(["expiration", &(exp + shift).to_string()]).unwrap_or(tag),
|
|
Err(_) => tag,
|
|
},
|
|
_ => tag,
|
|
});
|
|
unsigned.tags = nostr::Tags::from_list(tags.collect());
|
|
unsigned.created_at = now;
|
|
unsigned.id = None;
|
|
unsigned.ensure_id();
|
|
unsigned
|
|
}
|
|
|
|
/// The contract's codes; anything else is `denied`.
|
|
fn known_code(code: &str) -> &'static str {
|
|
match code {
|
|
"approval_required" => "approval_required",
|
|
"rate_limited" => "rate_limited",
|
|
"unavailable" => "unavailable",
|
|
"invalid_request" => "invalid_request",
|
|
"unsupported_version" => "unsupported_version",
|
|
_ => "denied",
|
|
}
|
|
}
|
|
|
|
fn held(method: &str) -> bool {
|
|
matches!(method, "sign_event" | "nip44_decrypt" | "pair")
|
|
}
|
|
|
|
/// A provider endpoint: `http://127.0.0.1:<port>/<path>`.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Endpoint {
|
|
port: u16,
|
|
path: String,
|
|
}
|
|
|
|
impl Endpoint {
|
|
pub fn parse(raw: &str) -> Result<Self, CliError> {
|
|
let invalid =
|
|
|| CliError::Auth("credential provider URL must be http://127.0.0.1:<port>/…".into());
|
|
let url = url::Url::parse(raw).map_err(|_| invalid())?;
|
|
if url.scheme() != "http"
|
|
|| url.host() != Some(url::Host::Ipv4(std::net::Ipv4Addr::LOCALHOST))
|
|
|| !url.username().is_empty()
|
|
|| url.password().is_some()
|
|
|| url.fragment().is_some()
|
|
{
|
|
return Err(invalid());
|
|
}
|
|
let port = url.port().ok_or_else(invalid)?;
|
|
let mut path = url.path().to_owned();
|
|
if let Some(query) = url.query() {
|
|
path = format!("{path}?{query}");
|
|
}
|
|
Ok(Self { port, path })
|
|
}
|
|
}
|
|
|
|
/// Exchange one request on `stream` (one request per connection) and return
|
|
/// the HTTP status and body.
|
|
///
|
|
/// This is a minimal HTTP/1.1 exchange rather than reqwest or hyper because
|
|
/// the same-user check in [`connect`] must vet the very TCP connection the
|
|
/// token is then sent on; a pooled client opens its own connections, which
|
|
/// the check never saw.
|
|
async fn exchange(
|
|
mut stream: TcpStream,
|
|
endpoint: &Endpoint,
|
|
token: Option<&str>,
|
|
version: u32,
|
|
body: &[u8],
|
|
) -> Result<(u16, Vec<u8>), ProviderError> {
|
|
let io =
|
|
|e: std::io::Error| ProviderError::Transport(format!("connection failed: {}", e.kind()));
|
|
let mut head = format!(
|
|
"POST {} HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nContent-Type: application/json\r\n\
|
|
Content-Length: {}\r\n{VERSION_HEADER}: {version}\r\nConnection: close\r\n",
|
|
endpoint.path,
|
|
endpoint.port,
|
|
body.len()
|
|
);
|
|
if let Some(token) = token {
|
|
head.push_str(&format!("Authorization: Bearer {token}\r\n"));
|
|
}
|
|
head.push_str("\r\n");
|
|
stream.write_all(head.as_bytes()).await.map_err(io)?;
|
|
stream.write_all(body).await.map_err(io)?;
|
|
let mut raw = Vec::new();
|
|
(&mut stream)
|
|
.take(MAX_RESPONSE_BYTES as u64 + 1)
|
|
.read_to_end(&mut raw)
|
|
.await
|
|
.map_err(io)?;
|
|
if raw.len() > MAX_RESPONSE_BYTES {
|
|
return Err(ProviderError::Transport("response too large".into()));
|
|
}
|
|
parse_response(&raw).ok_or_else(|| ProviderError::Transport("malformed HTTP response".into()))
|
|
}
|
|
|
|
/// Parse an HTTP/1.1 response read to EOF: status plus body, decoding
|
|
/// `Content-Length` or chunked framing.
|
|
fn parse_response(raw: &[u8]) -> Option<(u16, Vec<u8>)> {
|
|
let split = raw.windows(4).position(|w| w == b"\r\n\r\n")?;
|
|
let head = std::str::from_utf8(&raw[..split]).ok()?;
|
|
let rest = &raw[split + 4..];
|
|
let mut lines = head.split("\r\n");
|
|
let status: u16 = lines.next()?.split(' ').nth(1)?.parse().ok()?;
|
|
let mut length = None;
|
|
let mut chunked = false;
|
|
for line in lines {
|
|
let (name, value) = line.split_once(':')?;
|
|
let value = value.trim();
|
|
if name.eq_ignore_ascii_case("content-length") {
|
|
length = Some(value.parse::<usize>().ok()?);
|
|
} else if name.eq_ignore_ascii_case("transfer-encoding") {
|
|
chunked = value.eq_ignore_ascii_case("chunked");
|
|
}
|
|
}
|
|
if chunked {
|
|
let mut body = Vec::new();
|
|
let mut at = rest;
|
|
loop {
|
|
let end = at.windows(2).position(|w| w == b"\r\n")?;
|
|
let size_hex = std::str::from_utf8(&at[..end])
|
|
.ok()?
|
|
.split(';')
|
|
.next()?
|
|
.trim();
|
|
let size = usize::from_str_radix(size_hex, 16).ok()?;
|
|
at = &at[end + 2..];
|
|
if size == 0 {
|
|
return Some((status, body));
|
|
}
|
|
body.extend_from_slice(at.get(..size)?);
|
|
at = at.get(size + 2..)?;
|
|
}
|
|
}
|
|
match length {
|
|
Some(n) => Some((status, rest.get(..n)?.to_vec())),
|
|
None => Some((status, rest.to_vec())),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Provider client
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct Envelope {
|
|
#[serde(default)]
|
|
id: String,
|
|
#[serde(default)]
|
|
result: String,
|
|
error: Option<String>,
|
|
message: Option<String>,
|
|
retry_after: Option<u64>,
|
|
versions: Option<Vec<u32>>,
|
|
}
|
|
|
|
/// Send one envelope and return its `result`, negotiating the version once
|
|
/// and waiting out `rate_limited` a bounded number of times. Transport
|
|
/// failures are never re-sent: the provider may have acted.
|
|
async fn call(
|
|
endpoint: &Endpoint,
|
|
token: Option<&str>,
|
|
same_user: bool,
|
|
method: &str,
|
|
params: &[String],
|
|
) -> Result<String, ProviderError> {
|
|
call_with(
|
|
endpoint,
|
|
token,
|
|
same_user,
|
|
method,
|
|
RATE_LIMIT_RETRIES,
|
|
&|| params.to_vec(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// [`call`], building `params` afresh for every envelope, so a re-send after
|
|
/// a refusal can carry a renewed auth proof.
|
|
async fn call_with(
|
|
endpoint: &Endpoint,
|
|
token: Option<&str>,
|
|
same_user: bool,
|
|
method: &str,
|
|
retries: u32,
|
|
params: &(dyn Fn() -> Vec<String> + Sync),
|
|
) -> Result<String, ProviderError> {
|
|
let mut version = VERSION;
|
|
let mut negotiated = false;
|
|
let mut throttled = 0;
|
|
loop {
|
|
let id = uuid::Uuid::new_v4().simple().to_string();
|
|
let body = serde_json::to_vec(&serde_json::json!({
|
|
"id": id, "method": method, "params": params(),
|
|
}))
|
|
.map_err(|_| ProviderError::Transport("request serialization failed".into()))?;
|
|
let timeout = if held(method) {
|
|
HELD_TIMEOUT
|
|
} else {
|
|
QUICK_TIMEOUT
|
|
};
|
|
let attempt = async {
|
|
let stream = connect(endpoint.port, same_user).await?;
|
|
exchange(stream, endpoint, token, version, &body).await
|
|
};
|
|
let (_, raw) = tokio::time::timeout(timeout, attempt)
|
|
.await
|
|
.map_err(|_| ProviderError::Transport(format!("`{method}` timed out")))??;
|
|
let envelope: Envelope = serde_json::from_slice(&raw)
|
|
.map_err(|_| ProviderError::Transport("unreadable response".into()))?;
|
|
if envelope.id != id {
|
|
return Err(ProviderError::Transport("response id mismatch".into()));
|
|
}
|
|
// Decide on `error`, never on the HTTP status.
|
|
let Some(code) = envelope.error.filter(|c| !c.is_empty()) else {
|
|
return Ok(envelope.result);
|
|
};
|
|
match code.as_str() {
|
|
"revoked" => return Err(ProviderError::Revoked),
|
|
"unknown_method" => return Err(ProviderError::UnknownMethod),
|
|
"unsupported_version" if !negotiated => {
|
|
negotiated = true;
|
|
let common = envelope
|
|
.versions
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter(|v| *v <= VERSION && *v != version)
|
|
.max();
|
|
match common {
|
|
Some(v) => version = v,
|
|
None => return Err(ProviderError::NoCommonVersion),
|
|
}
|
|
}
|
|
"unsupported_version" => return Err(ProviderError::NoCommonVersion),
|
|
// A wait past the cap is reported at once, with its real length.
|
|
"rate_limited"
|
|
if throttled < retries
|
|
&& envelope.retry_after.unwrap_or(1) <= RATE_LIMIT_MAX_WAIT_SECS =>
|
|
{
|
|
throttled += 1;
|
|
tokio::time::sleep(throttle_wait(envelope.retry_after)).await;
|
|
}
|
|
other => {
|
|
return Err(ProviderError::Code {
|
|
code: known_code(other),
|
|
message: envelope.message,
|
|
retry_after: envelope.retry_after,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// How long to wait out a `rate_limited` answer.
|
|
pub(crate) fn throttle_wait(retry_after: Option<u64>) -> Duration {
|
|
RETRY_AFTER_UNIT * retry_after.unwrap_or(1).clamp(1, RATE_LIMIT_MAX_WAIT_SECS) as u32
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct Describe {
|
|
pubkey: String,
|
|
#[serde(default)]
|
|
relays: Vec<String>,
|
|
#[serde(default)]
|
|
methods: Vec<String>,
|
|
}
|
|
|
|
/// A connected provider acting as one identity.
|
|
pub struct Provider {
|
|
endpoint: Endpoint,
|
|
/// The relay it was opened for, as the desktop binds it.
|
|
relay: String,
|
|
token: String,
|
|
same_user: bool,
|
|
public_key: PublicKey,
|
|
methods: Vec<String>,
|
|
cached: crate::cached_assertion::CachedAssertion,
|
|
}
|
|
|
|
impl std::fmt::Debug for Provider {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("Provider")
|
|
.field("port", &self.endpoint.port)
|
|
.field("public_key", &self.public_key.to_hex())
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
/// Why a provider was not used for this relay.
|
|
pub enum Skip {
|
|
/// The token was revoked: the caller drops it.
|
|
Revoked,
|
|
/// The provider does not apply (relay not bound, no common version).
|
|
NotApplicable,
|
|
/// The endpoint could not be reached: nothing was decided.
|
|
Unreachable,
|
|
/// The same-user check timed out before anything was sent.
|
|
Unconfirmed,
|
|
}
|
|
|
|
/// A relay URL as the local provider binds it: `http`/`https` read as
|
|
/// `ws`/`wss`, then URL normalization (lowercase host, no default port, `/`
|
|
/// for an empty path). The path and query count: one host can serve several
|
|
/// communities. This is not NIP-FA's audience form; the desktop derives that.
|
|
pub fn provider_relay_url(raw: &str) -> Result<String, CliError> {
|
|
let invalid = || CliError::Usage(format!("invalid relay URL: {raw}"));
|
|
let mut url = url::Url::parse(raw).map_err(|_| invalid())?;
|
|
let scheme = match url.scheme() {
|
|
"http" | "ws" => "ws",
|
|
"https" | "wss" => "wss",
|
|
_ => return Err(invalid()),
|
|
};
|
|
if !url.username().is_empty() || url.password().is_some() || url.host().is_none() {
|
|
return Err(invalid());
|
|
}
|
|
url.set_fragment(None);
|
|
// Changing between special schemes keeps host, port and path.
|
|
url.set_scheme(scheme).map_err(|()| invalid())?;
|
|
// Re-parse so a default port for the new scheme is dropped.
|
|
url::Url::parse(url.as_str())
|
|
.map(|u| u.to_string())
|
|
.map_err(|_| invalid())
|
|
}
|
|
|
|
fn same_relay(a: &str, b: &str) -> bool {
|
|
provider_relay_url(b).is_ok_and(|b| b == a)
|
|
}
|
|
|
|
impl Provider {
|
|
/// Connect with `token` and confirm the provider acts for `relay_url`.
|
|
pub async fn open(
|
|
endpoint: Endpoint,
|
|
token: String,
|
|
same_user: bool,
|
|
relay_url: &str,
|
|
) -> Result<Result<Arc<Self>, Skip>, CliError> {
|
|
let raw = match call(&endpoint, Some(&token), same_user, "describe", &[]).await {
|
|
Ok(raw) => raw,
|
|
Err(ProviderError::Revoked) => return Ok(Err(Skip::Revoked)),
|
|
Err(ProviderError::NoCommonVersion) => return Ok(Err(Skip::NotApplicable)),
|
|
Err(ProviderError::Transport(_) | ProviderError::OtherUser) => {
|
|
return Ok(Err(Skip::Unreachable))
|
|
}
|
|
Err(ProviderError::Unconfirmed) => return Ok(Err(Skip::Unconfirmed)),
|
|
Err(e) => return Err(e.into_cli("describe")),
|
|
};
|
|
let describe: Describe = serde_json::from_str(&raw)
|
|
.map_err(|_| CliError::Auth("Buzz desktop: unreadable `describe` answer".into()))?;
|
|
let public_key = PublicKey::from_hex(&describe.pubkey)
|
|
.map_err(|_| CliError::Auth("Buzz desktop: invalid identity public key".into()))?;
|
|
let relay = provider_relay_url(relay_url)?;
|
|
if !describe.relays.iter().any(|r| same_relay(&relay, r)) {
|
|
return Ok(Err(Skip::NotApplicable));
|
|
}
|
|
Ok(Ok(Arc::new(Self {
|
|
endpoint,
|
|
relay,
|
|
token,
|
|
same_user,
|
|
public_key,
|
|
methods: describe.methods,
|
|
cached: Default::default(),
|
|
})))
|
|
}
|
|
|
|
pub fn public_key(&self) -> PublicKey {
|
|
self.public_key
|
|
}
|
|
|
|
/// Sign `unsigned` as this identity; the result is checked.
|
|
pub async fn sign(&self, unsigned: UnsignedEvent) -> Result<Event, CliError> {
|
|
self.sign_or_wait(unsigned).await.map_err(|(e, _)| e)
|
|
}
|
|
|
|
/// [`Self::sign`], except that a throttled NIP-42 sign-in is not waited
|
|
/// out here: its challenge dies with the socket meanwhile, so it fails
|
|
/// at once with the wait, for the caller to reconnect after.
|
|
pub async fn sign_or_wait(
|
|
&self,
|
|
unsigned: UnsignedEvent,
|
|
) -> Result<Event, (CliError, Option<Duration>)> {
|
|
let retries = if unsigned.kind == nostr::Kind::Authentication {
|
|
0
|
|
} else {
|
|
RATE_LIMIT_RETRIES
|
|
};
|
|
// A re-sent auth proof is renewed so it is fresh when it reaches the
|
|
// relay; any other event is signed exactly as built.
|
|
let signed = call_with(
|
|
&self.endpoint,
|
|
Some(&self.token),
|
|
self.same_user,
|
|
"sign_event",
|
|
retries,
|
|
&|| vec![renewed(unsigned.clone()).as_json()],
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
let wait = match &e {
|
|
ProviderError::Code {
|
|
code: "rate_limited",
|
|
retry_after,
|
|
..
|
|
} => Some(throttle_wait(*retry_after)),
|
|
_ => None,
|
|
};
|
|
(e.into_cli("sign_event"), wait)
|
|
})?;
|
|
let refused = |why: &str| (CliError::Auth(format!("Buzz desktop returned {why}")), None);
|
|
let event = Event::from_json(&signed).map_err(|_| refused("an unreadable event"))?;
|
|
if event.pubkey != self.public_key || event.verify().is_err() {
|
|
return Err(refused("an invalid signature"));
|
|
}
|
|
Ok(event)
|
|
}
|
|
|
|
/// NIP-44 encrypt `content` to `peer` as this identity.
|
|
pub async fn nip44_encrypt(&self, peer: &PublicKey, content: &str) -> Result<String, CliError> {
|
|
self.call("nip44_encrypt", &[peer.to_hex(), content.to_owned()])
|
|
.await
|
|
}
|
|
|
|
async fn call(&self, method: &str, params: &[String]) -> Result<String, CliError> {
|
|
call(
|
|
&self.endpoint,
|
|
Some(&self.token),
|
|
self.same_user,
|
|
method,
|
|
params,
|
|
)
|
|
.await
|
|
.map_err(|e| e.into_cli(method))
|
|
}
|
|
|
|
/// The identity's NIP-OA `auth` tag JSON, if it has one.
|
|
pub async fn auth_tag(&self) -> Result<Option<String>, CliError> {
|
|
if !self.methods.iter().any(|m| m == "auth_tag") {
|
|
return Ok(None);
|
|
}
|
|
match call(
|
|
&self.endpoint,
|
|
Some(&self.token),
|
|
self.same_user,
|
|
"auth_tag",
|
|
&[],
|
|
)
|
|
.await
|
|
{
|
|
Ok(tag) => Ok(Some(tag).filter(|t| !t.is_empty())),
|
|
Err(ProviderError::UnknownMethod) => Ok(None),
|
|
Err(e) => Err(e.into_cli("auth_tag")),
|
|
}
|
|
}
|
|
|
|
/// The NIP-FI assertion for the relay it was opened for, cached until
|
|
/// shortly before its `exp`. `None` when the desktop has none for it.
|
|
pub async fn federated_identity(&self) -> Result<Option<String>, CliError> {
|
|
let params = [self.relay.clone()];
|
|
self.cached
|
|
.get(async {
|
|
match call(
|
|
&self.endpoint,
|
|
Some(&self.token),
|
|
self.same_user,
|
|
"federated_identity",
|
|
¶ms,
|
|
)
|
|
.await
|
|
{
|
|
Ok(a) if a.is_empty() => Ok(None),
|
|
Ok(a) => Ok(Some(a)),
|
|
Err(ProviderError::UnknownMethod) => Ok(None),
|
|
Err(e) => Err(e.into_cli("federated_identity")),
|
|
}
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// NIP-44 decrypt `payload` from `peer`. `Ok(None)` when the desktop
|
|
/// answers `invalid_request`: the payload is not valid ciphertext for this
|
|
/// identity (the CLI's params are always well formed). Any other failure,
|
|
/// such as revoked, denied or unreachable, is an error.
|
|
pub async fn nip44_decrypt(
|
|
&self,
|
|
peer: &PublicKey,
|
|
payload: &str,
|
|
) -> Result<Option<String>, CliError> {
|
|
match call(
|
|
&self.endpoint,
|
|
Some(&self.token),
|
|
self.same_user,
|
|
"nip44_decrypt",
|
|
&[peer.to_hex(), payload.to_owned()],
|
|
)
|
|
.await
|
|
{
|
|
Ok(plaintext) => Ok(Some(plaintext)),
|
|
Err(ProviderError::Code {
|
|
code: "invalid_request",
|
|
..
|
|
}) => Ok(None),
|
|
Err(e) => Err(e.into_cli("nip44_decrypt")),
|
|
}
|
|
}
|
|
|
|
/// The NIP-AE `d` tag for `slug`, derived by the desktop.
|
|
pub async fn engram_d_tag(&self, peer: &PublicKey, slug: &str) -> Result<String, CliError> {
|
|
let tag = self
|
|
.call("engram_d_tag", &[peer.to_hex(), slug.to_owned()])
|
|
.await?;
|
|
if tag.len() != 64 || !tag.bytes().all(|b| b.is_ascii_hexdigit()) {
|
|
return Err(CliError::Auth(
|
|
"Buzz desktop returned an invalid engram d tag".into(),
|
|
));
|
|
}
|
|
Ok(tag)
|
|
}
|
|
}
|
|
|
|
fn signer_error(e: CliError) -> SignerError {
|
|
SignerError::from(e.to_string())
|
|
}
|
|
|
|
impl nostr::NostrSigner for Provider {
|
|
fn backend(&self) -> SignerBackend<'_> {
|
|
SignerBackend::Custom("buzz-desktop".into())
|
|
}
|
|
|
|
fn get_public_key(&self) -> nostr::util::BoxedFuture<'_, Result<PublicKey, SignerError>> {
|
|
Box::pin(async move { Ok(self.public_key) })
|
|
}
|
|
|
|
fn sign_event(
|
|
&self,
|
|
unsigned: UnsignedEvent,
|
|
) -> nostr::util::BoxedFuture<'_, Result<Event, SignerError>> {
|
|
Box::pin(async move { Provider::sign(self, unsigned).await.map_err(signer_error) })
|
|
}
|
|
|
|
fn nip04_encrypt<'a>(
|
|
&'a self,
|
|
_: &'a PublicKey,
|
|
_: &'a str,
|
|
) -> nostr::util::BoxedFuture<'a, Result<String, SignerError>> {
|
|
Box::pin(async { Err("the Buzz desktop provider does not offer NIP-04".into()) })
|
|
}
|
|
|
|
fn nip04_decrypt<'a>(
|
|
&'a self,
|
|
_: &'a PublicKey,
|
|
_: &'a str,
|
|
) -> nostr::util::BoxedFuture<'a, Result<String, SignerError>> {
|
|
Box::pin(async { Err("the Buzz desktop provider does not offer NIP-04".into()) })
|
|
}
|
|
|
|
fn nip44_encrypt<'a>(
|
|
&'a self,
|
|
peer: &'a PublicKey,
|
|
content: &'a str,
|
|
) -> nostr::util::BoxedFuture<'a, Result<String, SignerError>> {
|
|
Box::pin(async move {
|
|
Provider::nip44_encrypt(self, peer, content)
|
|
.await
|
|
.map_err(signer_error)
|
|
})
|
|
}
|
|
|
|
fn nip44_decrypt<'a>(
|
|
&'a self,
|
|
peer: &'a PublicKey,
|
|
payload: &'a str,
|
|
) -> nostr::util::BoxedFuture<'a, Result<String, SignerError>> {
|
|
Box::pin(async move {
|
|
self.call("nip44_decrypt", &[peer.to_hex(), payload.to_owned()])
|
|
.await
|
|
.map_err(signer_error)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Discovery
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct Inherited {
|
|
url: String,
|
|
token: String,
|
|
}
|
|
|
|
/// The inherited token from `BUZZ_CREDENTIAL_PROVIDER`, if set.
|
|
pub fn inherited() -> Result<Option<(Endpoint, String)>, CliError> {
|
|
let Some(raw) = std::env::var(PROVIDER_ENV)
|
|
.ok()
|
|
.filter(|v| !v.trim().is_empty())
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
let inherited: Inherited = serde_json::from_str(&raw)
|
|
.map_err(|_| CliError::Auth(format!("{PROVIDER_ENV} is not valid JSON")))?;
|
|
let endpoint = Endpoint::parse(&inherited.url)?;
|
|
if !usable_token(&inherited.token) {
|
|
return Err(CliError::Auth(format!(
|
|
"{PROVIDER_ENV} carries an unusable token"
|
|
)));
|
|
}
|
|
Ok(Some((endpoint, inherited.token)))
|
|
}
|
|
|
|
fn usable_token(token: &str) -> bool {
|
|
!token.is_empty() && token.bytes().all(|b| b.is_ascii_graphic())
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct Advertisement {
|
|
instance: String,
|
|
url: String,
|
|
}
|
|
|
|
fn advertisement_dir() -> Option<std::path::PathBuf> {
|
|
std::env::var_os("HOME")
|
|
.map(|home| std::path::Path::new(&home).join(".buzz-foundation/credential-providers"))
|
|
}
|
|
|
|
/// Every advertisement that passes the ownership and permission checks:
|
|
/// a `0700` directory and files owned by this user with no group or other
|
|
/// bits. Anything else is ignored, so another user cannot plant one.
|
|
fn advertisements_in(dir: &std::path::Path) -> Vec<(String, Endpoint)> {
|
|
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
|
let uid = current_uid();
|
|
let private =
|
|
|m: &std::fs::Metadata, mode: u32| m.uid() == uid && m.permissions().mode() & 0o777 == mode;
|
|
let Ok(meta) = std::fs::symlink_metadata(dir) else {
|
|
return Vec::new();
|
|
};
|
|
if !meta.is_dir() || !private(&meta, 0o700) {
|
|
return Vec::new();
|
|
}
|
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
return Vec::new();
|
|
};
|
|
let mut found: Vec<(String, Endpoint)> = entries
|
|
.flatten()
|
|
.filter(|e| {
|
|
let name = e.file_name();
|
|
let name = name.to_string_lossy();
|
|
!name.starts_with('.') && name.ends_with(".json")
|
|
})
|
|
.filter_map(|e| {
|
|
let meta = std::fs::symlink_metadata(e.path()).ok()?;
|
|
if !meta.is_file() || meta.uid() != uid || meta.permissions().mode() & 0o077 != 0 {
|
|
return None;
|
|
}
|
|
let ad: Advertisement = serde_json::from_slice(&std::fs::read(e.path()).ok()?).ok()?;
|
|
Some((ad.instance, Endpoint::parse(&ad.url).ok()?))
|
|
})
|
|
.collect();
|
|
found.sort_by(|a, b| a.0.cmp(&b.0));
|
|
found
|
|
}
|
|
|
|
fn advertisements() -> Vec<(String, Endpoint)> {
|
|
advertisement_dir().map_or_else(Vec::new, |d| advertisements_in(&d))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Paired tokens, in the OS keychain
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const KEYCHAIN_SERVICE: &str = "buzz-cli";
|
|
const KEYCHAIN_ACCOUNT: &str = "desktop-pairings";
|
|
|
|
/// One pairing per instance.
|
|
#[derive(serde::Serialize, serde::Deserialize, Default)]
|
|
struct Pairings(std::collections::BTreeMap<String, Pairing>);
|
|
|
|
#[derive(serde::Serialize, serde::Deserialize, Clone)]
|
|
struct Pairing {
|
|
token: String,
|
|
pubkey: String,
|
|
relays: Vec<String>,
|
|
}
|
|
|
|
/// A held lock; dropping it releases the lock.
|
|
trait Held {}
|
|
impl<T> Held for T {}
|
|
|
|
/// Where the pairing map lives. Every change goes through [`update`], which
|
|
/// holds `lock` only for one reload-change-write.
|
|
trait PairingStore: Send + Sync {
|
|
fn read(&self) -> Result<Option<String>, CliError>;
|
|
/// Write `json`, or delete the entry for `None`.
|
|
fn write(&self, json: Option<&str>) -> Result<(), CliError>;
|
|
/// A cross-process lock, released when the guard drops.
|
|
fn lock(&self) -> Result<Box<dyn Held + '_>, CliError>;
|
|
}
|
|
|
|
struct Keychain;
|
|
|
|
fn no_keychain(e: keyring::Error) -> CliError {
|
|
CliError::Auth(format!(
|
|
"no OS keychain is reachable ({e}); on Linux start a Secret Service such as \
|
|
gnome-keyring, or set BUZZ_PRIVATE_KEY instead. Paired tokens are never written to files."
|
|
))
|
|
}
|
|
|
|
impl Keychain {
|
|
fn entry() -> Result<keyring::Entry, CliError> {
|
|
keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT).map_err(no_keychain)
|
|
}
|
|
}
|
|
|
|
impl PairingStore for Keychain {
|
|
fn read(&self) -> Result<Option<String>, CliError> {
|
|
match Self::entry()?.get_password() {
|
|
Ok(json) => Ok(Some(json)),
|
|
Err(keyring::Error::NoEntry) => Ok(None),
|
|
Err(e) => Err(no_keychain(e)),
|
|
}
|
|
}
|
|
|
|
fn write(&self, json: Option<&str>) -> Result<(), CliError> {
|
|
let entry = Self::entry()?;
|
|
match json {
|
|
Some(json) => entry.set_password(json).map_err(no_keychain),
|
|
None => match entry.delete_credential() {
|
|
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
|
Err(e) => Err(no_keychain(e)),
|
|
},
|
|
}
|
|
}
|
|
|
|
/// `flock` on `~/.buzz-foundation/cli-pairings.lock` (no secret in it).
|
|
fn lock(&self) -> Result<Box<dyn Held + '_>, CliError> {
|
|
use std::os::fd::AsRawFd;
|
|
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
|
|
let failed = |e: std::io::Error| CliError::Other(format!("pairing lock failed: {e}"));
|
|
let dir = advertisement_dir()
|
|
.and_then(|d| d.parent().map(std::path::Path::to_path_buf))
|
|
.ok_or_else(|| CliError::Other("HOME is not set".into()))?;
|
|
std::fs::DirBuilder::new()
|
|
.recursive(true)
|
|
.mode(0o700)
|
|
.create(&dir)
|
|
.map_err(failed)?;
|
|
// An existing directory keeps whatever mode it had; tighten it.
|
|
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).map_err(failed)?;
|
|
let file = std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.truncate(false)
|
|
.write(true)
|
|
.custom_flags(libc::O_NOFOLLOW)
|
|
.open(dir.join("cli-pairings.lock"))
|
|
.map_err(failed)?;
|
|
// SAFETY: a valid open descriptor; flock has no other preconditions.
|
|
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
|
|
return Err(failed(std::io::Error::last_os_error()));
|
|
}
|
|
Ok(Box::new(file))
|
|
}
|
|
}
|
|
|
|
/// The stored map. An unreadable entry is refused, never treated as empty,
|
|
/// so no write can replace it with less.
|
|
fn parse_pairings(json: Option<String>) -> Result<Pairings, CliError> {
|
|
match json {
|
|
None => Ok(Pairings::default()),
|
|
Some(json) => serde_json::from_str(&json).map_err(|_| {
|
|
CliError::Auth(
|
|
"the CLI's paired tokens in the OS keychain are unreadable; \
|
|
run `buzz auth desktop unpair` to clear them, then pair again"
|
|
.into(),
|
|
)
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Reload the map under the lock, apply `change`, and write it back.
|
|
fn update<R>(
|
|
store: &dyn PairingStore,
|
|
change: impl FnOnce(&mut Pairings) -> R,
|
|
) -> Result<R, CliError> {
|
|
let _guard = store.lock()?;
|
|
let mut pairings = parse_pairings(store.read()?)?;
|
|
let result = change(&mut pairings);
|
|
if pairings.0.is_empty() {
|
|
store.write(None)?;
|
|
} else {
|
|
let json = serde_json::to_string(&pairings)
|
|
.map_err(|e| CliError::Other(format!("pairing serialization failed: {e}")))?;
|
|
store.write(Some(&json))?;
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
/// Drop each revoked pairing, unless it was replaced since it was read.
|
|
fn forget_revoked(store: &dyn PairingStore, revoked: &[(String, String)]) {
|
|
if revoked.is_empty() {
|
|
return;
|
|
}
|
|
let _ = update(store, |pairings| {
|
|
for (instance, token) in revoked {
|
|
if pairings.0.get(instance).is_some_and(|p| &p.token == token) {
|
|
pairings.0.remove(instance);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The chain link, and pairing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The desktop link for `relay_url`. An inherited token decides the identity:
|
|
/// when it cannot serve, the command fails, and a paired token (which acts as
|
|
/// the human) is never tried, so an agent's process acts only as that agent
|
|
/// unless the owner approves pairing it. Without an
|
|
/// inherited token, the first paired token whose instance is advertised.
|
|
/// `None` when no paired token applies, so the chain moves on.
|
|
pub async fn discover(relay_url: &str) -> Result<Option<Arc<Provider>>, CliError> {
|
|
discover_with(inherited()?, &Keychain, advertisements, relay_url).await
|
|
}
|
|
|
|
/// The relay an agent's inherited credential acts on, for a command given
|
|
/// none. `None` without an inherited token, or when `describe` fails (the
|
|
/// credential chain reports that); bad input when it acts on several relays.
|
|
pub async fn inherited_relay() -> Result<Option<String>, CliError> {
|
|
let Some((endpoint, token)) = inherited()? else {
|
|
return Ok(None);
|
|
};
|
|
let Ok(raw) = call(&endpoint, Some(&token), true, "describe", &[]).await else {
|
|
return Ok(None);
|
|
};
|
|
let relays = serde_json::from_str::<Describe>(&raw)
|
|
.map(|d| d.relays)
|
|
.unwrap_or_default();
|
|
let mut distinct: Vec<String> = Vec::new();
|
|
for relay in relays {
|
|
push_distinct(&mut distinct, relay);
|
|
}
|
|
match distinct.len() {
|
|
0 => Ok(None),
|
|
1 => Ok(distinct.pop()),
|
|
_ => Err(CliError::Usage(format!(
|
|
"this agent's Buzz credential acts on several relays ({}); pass --relay or set \
|
|
BUZZ_RELAY_URL",
|
|
distinct.join(", ")
|
|
))),
|
|
}
|
|
}
|
|
|
|
/// Add `relay` to `list` in bound form, unless it is already there.
|
|
fn push_distinct(list: &mut Vec<String>, relay: String) {
|
|
if !list.iter().any(|known| same_relay(known, &relay)) {
|
|
list.push(provider_relay_url(&relay).unwrap_or(relay));
|
|
}
|
|
}
|
|
|
|
/// The relay a paired person's command given none acts on: the one relay a
|
|
/// running desktop still accepts this CLI's pairing for, by its `describe`.
|
|
/// A stored pairing alone does not count, since its desktop may have revoked
|
|
/// it or stopped. `None` for an agent (its inherited token decides), or when
|
|
/// no relay or several qualify.
|
|
pub async fn paired_relay() -> Result<Option<String>, CliError> {
|
|
if inherited()?.is_some() {
|
|
return Ok(None);
|
|
}
|
|
accepted_relay(&Keychain, advertisements()).await
|
|
}
|
|
|
|
async fn accepted_relay(
|
|
store: &dyn PairingStore,
|
|
advertised: Vec<(String, Endpoint)>,
|
|
) -> Result<Option<String>, CliError> {
|
|
let Ok(Ok(pairings)) = store.read().map(parse_pairings) else {
|
|
return Ok(None);
|
|
};
|
|
let mut accepted = Vec::new();
|
|
let mut transient = None;
|
|
for (instance, endpoint) in advertised {
|
|
let Some(pairing) = pairings.0.get(&instance) else {
|
|
continue;
|
|
};
|
|
match probe(&endpoint, pairing, RATE_LIMIT_RETRIES).await {
|
|
Probe::Accepted(relays) => {
|
|
for relay in relays {
|
|
push_distinct(&mut accepted, relay);
|
|
}
|
|
}
|
|
Probe::NotAccepted | Probe::NotRunning => {}
|
|
// A desktop that answered with a refusal has decided.
|
|
Probe::Refused(e) => return Err(e),
|
|
Probe::Unconfirmed(e) | Probe::Throttled(e) => keep_transient(&mut transient, e),
|
|
}
|
|
}
|
|
match (accepted.len(), transient) {
|
|
// A desktop whose check failed may accept a relay of its own: report
|
|
// it rather than pick, or fall back to, a relay it might contradict.
|
|
(0 | 1, Some(e)) => Err(e),
|
|
(1, None) => Ok(accepted.pop()),
|
|
_ => Ok(None),
|
|
}
|
|
}
|
|
|
|
/// Keep the first transient failure, except that a retryable one replaces an
|
|
/// identity mismatch: the command may succeed once that desktop answers.
|
|
fn keep_transient(kept: &mut Option<CliError>, e: CliError) {
|
|
let better = kept.as_ref().is_none_or(|k| {
|
|
!crate::error::is_retryable_error(k) && crate::error::is_retryable_error(&e)
|
|
});
|
|
if better {
|
|
*kept = Some(e);
|
|
}
|
|
}
|
|
|
|
/// What a running desktop says about a stored pairing now.
|
|
enum Probe {
|
|
/// The pairing's relays it still binds for the token.
|
|
Accepted(Vec<String>),
|
|
/// It answered: the token is revoked, or binds none of the relays.
|
|
NotAccepted,
|
|
/// The advertised endpoint could not be reached or did not answer.
|
|
NotRunning,
|
|
/// The same-user check timed out, or the port's owner is another user.
|
|
Unconfirmed(CliError),
|
|
/// It is still rate-limiting after `retries` waits.
|
|
Throttled(CliError),
|
|
/// It refused `describe` (`denied`, `unavailable`, ...): final.
|
|
Refused(CliError),
|
|
}
|
|
|
|
/// Ask the desktop at `endpoint`, on a same-user connection, which of
|
|
/// `pairing`'s relays it binds for its token. The one check behind both the
|
|
/// default relay and `auth desktop list`.
|
|
async fn probe(endpoint: &Endpoint, pairing: &Pairing, retries: u32) -> Probe {
|
|
let raw = match call_with(
|
|
endpoint,
|
|
Some(&pairing.token),
|
|
true,
|
|
"describe",
|
|
retries,
|
|
&Vec::new,
|
|
)
|
|
.await
|
|
{
|
|
Ok(raw) => raw,
|
|
Err(ProviderError::Revoked | ProviderError::NoCommonVersion) => return Probe::NotAccepted,
|
|
Err(ProviderError::Transport(_)) => return Probe::NotRunning,
|
|
Err(e @ (ProviderError::Unconfirmed | ProviderError::OtherUser)) => {
|
|
return Probe::Unconfirmed(e.into_cli("describe"))
|
|
}
|
|
Err(
|
|
e @ ProviderError::Code {
|
|
code: "rate_limited",
|
|
..
|
|
},
|
|
) => return Probe::Throttled(e.into_cli("describe")),
|
|
Err(e) => return Probe::Refused(e.into_cli("describe")),
|
|
};
|
|
let Ok(describe) = serde_json::from_str::<Describe>(&raw) else {
|
|
return Probe::Refused(CliError::Auth(
|
|
"Buzz desktop: unreadable `describe` answer".into(),
|
|
));
|
|
};
|
|
let mut accepted = Vec::new();
|
|
for relay in describe.relays {
|
|
let bound = provider_relay_url(&relay).is_ok_and(|bound| {
|
|
pairing
|
|
.relays
|
|
.iter()
|
|
.any(|paired| same_relay(&bound, paired))
|
|
});
|
|
if bound {
|
|
push_distinct(&mut accepted, relay);
|
|
}
|
|
}
|
|
if accepted.is_empty() {
|
|
Probe::NotAccepted
|
|
} else {
|
|
Probe::Accepted(accepted)
|
|
}
|
|
}
|
|
|
|
/// This CLI's pairings, without their tokens, each with what a running
|
|
/// desktop says about it now: `accepted`, `not accepted` (revoked or no
|
|
/// longer bound), `not running`, `unconfirmed` (same-user check failed),
|
|
/// `rate limited` or `refused`. Local and read-only. Desktops are asked at
|
|
/// once, each without waiting out throttling.
|
|
pub async fn list() -> Result<Vec<serde_json::Value>, CliError> {
|
|
list_with(&Keychain, advertisements()).await
|
|
}
|
|
|
|
async fn list_with(
|
|
store: &dyn PairingStore,
|
|
advertised: Vec<(String, Endpoint)>,
|
|
) -> Result<Vec<serde_json::Value>, CliError> {
|
|
let pairings = parse_pairings(store.read()?)?;
|
|
let rows = pairings.0.into_iter().map(|(instance, pairing)| {
|
|
let running = advertised
|
|
.iter()
|
|
.find(|(i, _)| *i == instance)
|
|
.map(|(_, e)| e.clone());
|
|
async move {
|
|
let (status, error) = match running {
|
|
None => ("not running", None),
|
|
Some(endpoint) => match probe(&endpoint, &pairing, 0).await {
|
|
Probe::Accepted(_) => ("accepted", None),
|
|
Probe::NotAccepted => ("not accepted", None),
|
|
Probe::NotRunning => ("not running", None),
|
|
Probe::Unconfirmed(e) => ("unconfirmed", Some(e)),
|
|
Probe::Throttled(e) => ("rate limited", Some(e)),
|
|
Probe::Refused(e) => ("refused", Some(e)),
|
|
},
|
|
};
|
|
let mut row = serde_json::json!({
|
|
"instance": instance,
|
|
"relays": pairing.relays,
|
|
"pubkey": pairing.pubkey,
|
|
"status": status,
|
|
});
|
|
if let Some(e) = error {
|
|
row["error"] = e.to_string().into();
|
|
}
|
|
row
|
|
}
|
|
});
|
|
let asked: Vec<_> = rows.map(tokio::spawn).collect();
|
|
let mut listed = Vec::with_capacity(asked.len());
|
|
for row in asked {
|
|
listed.push(
|
|
row.await
|
|
.map_err(|e| CliError::Other(format!("listing failed: {e}")))?,
|
|
);
|
|
}
|
|
Ok(listed)
|
|
}
|
|
|
|
/// The relays this CLI's stored pairings are for, in bound form, without
|
|
/// asking any desktop. For error hints only: a stored pairing may be stale.
|
|
pub fn paired_relays() -> Vec<String> {
|
|
// Tests never read the real keychain.
|
|
#[cfg(test)]
|
|
return Vec::new();
|
|
#[cfg(not(test))]
|
|
paired_relays_in(&Keychain)
|
|
}
|
|
|
|
fn paired_relays_in(store: &dyn PairingStore) -> Vec<String> {
|
|
let Ok(Ok(pairings)) = store.read().map(parse_pairings) else {
|
|
return Vec::new();
|
|
};
|
|
let mut relays = Vec::new();
|
|
for relay in pairings.0.into_values().flat_map(|p| p.relays) {
|
|
push_distinct(&mut relays, relay);
|
|
}
|
|
relays
|
|
}
|
|
|
|
async fn discover_with(
|
|
inherited: Option<(Endpoint, String)>,
|
|
store: &dyn PairingStore,
|
|
advertised: impl FnOnce() -> Vec<(String, Endpoint)>,
|
|
relay_url: &str,
|
|
) -> Result<Option<Arc<Provider>>, CliError> {
|
|
match inherited {
|
|
Some((endpoint, token)) => inherited_link(endpoint, token, relay_url).await.map(Some),
|
|
None => paired_link(store, advertised(), relay_url).await,
|
|
}
|
|
}
|
|
|
|
async fn inherited_link(
|
|
endpoint: Endpoint,
|
|
token: String,
|
|
relay_url: &str,
|
|
) -> Result<Arc<Provider>, CliError> {
|
|
match Provider::open(endpoint, token, true, relay_url).await? {
|
|
Ok(provider) => Ok(provider),
|
|
Err(Skip::Revoked) => Err(CliError::Auth(
|
|
"the Buzz desktop revoked this agent's credential; restart the agent in Buzz".into(),
|
|
)),
|
|
Err(Skip::NotApplicable) => Err(CliError::Auth(format!(
|
|
"this agent's Buzz credential does not act on {relay_url}"
|
|
))),
|
|
Err(Skip::Unreachable) => Err(CliError::Auth(
|
|
"the Buzz desktop that launched this agent is not reachable".into(),
|
|
)),
|
|
Err(Skip::Unconfirmed) => Err(ProviderError::Unconfirmed.into_cli("describe")),
|
|
}
|
|
}
|
|
|
|
async fn paired_link(
|
|
store: &dyn PairingStore,
|
|
advertised: Vec<(String, Endpoint)>,
|
|
relay_url: &str,
|
|
) -> Result<Option<Arc<Provider>>, CliError> {
|
|
// A missing keychain only means there are no paired tokens to use here.
|
|
let Ok(Ok(pairings)) = store.read().map(parse_pairings) else {
|
|
return Ok(None);
|
|
};
|
|
let relay = provider_relay_url(relay_url)?;
|
|
let mut revoked = Vec::new();
|
|
let mut unconfirmed = false;
|
|
let mut found = Ok(None);
|
|
for (instance, endpoint) in advertised {
|
|
let Some(pairing) = pairings.0.get(&instance) else {
|
|
continue;
|
|
};
|
|
if !pairing.relays.iter().any(|r| same_relay(&relay, r)) {
|
|
continue;
|
|
}
|
|
match Provider::open(endpoint, pairing.token.clone(), true, relay_url).await {
|
|
Ok(Ok(provider)) => {
|
|
found = Ok(Some(provider));
|
|
break;
|
|
}
|
|
Ok(Err(Skip::Revoked)) => {
|
|
revoked.push((instance.clone(), pairing.token.clone()));
|
|
eprintln!(
|
|
"warning: Buzz ({instance}) revoked this CLI's pairing; \
|
|
run `buzz auth desktop pair` to pair again"
|
|
);
|
|
}
|
|
// A stale or unconfirmed advertisement decides nothing: skip it.
|
|
Ok(Err(Skip::NotApplicable | Skip::Unreachable)) => {}
|
|
// A same-user check that ran out of time decided nothing either,
|
|
// but if nothing else serves, the command is worth retrying.
|
|
Ok(Err(Skip::Unconfirmed)) => unconfirmed = true,
|
|
// The desktop answered (denied, unavailable, ...): that is final.
|
|
Err(e) => {
|
|
found = Err(e);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
forget_revoked(store, &revoked);
|
|
match found {
|
|
Ok(None) if unconfirmed => Err(ProviderError::Unconfirmed.into_cli("describe")),
|
|
found => found,
|
|
}
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct Paired {
|
|
token: String,
|
|
instance: String,
|
|
pubkey: String,
|
|
relays: Vec<String>,
|
|
}
|
|
|
|
/// Ask the running desktop to pair this CLI for `relay_url`, holding until the
|
|
/// person decides, and store the token in the OS keychain.
|
|
pub async fn pair(
|
|
relay_url: &str,
|
|
client_name: &str,
|
|
instance: Option<&str>,
|
|
) -> Result<serde_json::Value, CliError> {
|
|
pair_among(
|
|
&Keychain,
|
|
advertisements(),
|
|
relay_url,
|
|
client_name,
|
|
instance,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// [`pair`] among the `found` desktops, storing in `store`.
|
|
async fn pair_among(
|
|
store: &dyn PairingStore,
|
|
mut found: Vec<(String, Endpoint)>,
|
|
relay_url: &str,
|
|
client_name: &str,
|
|
instance: Option<&str>,
|
|
) -> Result<serde_json::Value, CliError> {
|
|
if let Some(want) = instance {
|
|
found.retain(|(i, _)| i == want);
|
|
}
|
|
let (instance, endpoint) = match found.len() {
|
|
0 => {
|
|
return Err(CliError::Auth(
|
|
"no running Buzz desktop found; open Buzz and try again".into(),
|
|
))
|
|
}
|
|
1 => found.remove(0),
|
|
_ => {
|
|
let names: Vec<_> = found.iter().map(|(i, _)| i.as_str()).collect();
|
|
return Err(CliError::Usage(format!(
|
|
"several Buzz desktops are running ({}); pass --instance",
|
|
names.join(", ")
|
|
)));
|
|
}
|
|
};
|
|
pair_with(store, instance, endpoint, relay_url, client_name).await
|
|
}
|
|
|
|
async fn pair_with(
|
|
store: &dyn PairingStore,
|
|
instance: String,
|
|
endpoint: Endpoint,
|
|
relay_url: &str,
|
|
client_name: &str,
|
|
) -> Result<serde_json::Value, CliError> {
|
|
// Fail on a missing or unreadable keychain before asking for approval.
|
|
parse_pairings(store.read()?)?;
|
|
let relay = provider_relay_url(relay_url)?;
|
|
let raw = call(
|
|
&endpoint,
|
|
None,
|
|
true,
|
|
"pair",
|
|
&[client_name.to_owned(), relay],
|
|
)
|
|
.await
|
|
.map_err(|e| e.into_cli("pair"))?;
|
|
let paired: Paired = serde_json::from_str(&raw)
|
|
.map_err(|_| CliError::Auth("Buzz desktop: unreadable `pair` answer".into()))?;
|
|
if paired.instance != instance
|
|
|| !usable_token(&paired.token)
|
|
|| PublicKey::from_hex(&paired.pubkey).is_err()
|
|
{
|
|
return Err(CliError::Auth(
|
|
"Buzz desktop returned an invalid pairing".into(),
|
|
));
|
|
}
|
|
let summary = serde_json::json!({
|
|
"instance": instance,
|
|
"pubkey": paired.pubkey,
|
|
"relays": paired.relays,
|
|
});
|
|
// Reload at commit time: only this instance changes, and a new pairing
|
|
// replaces its old token.
|
|
update(store, |pairings| {
|
|
pairings.0.insert(
|
|
instance,
|
|
Pairing {
|
|
token: paired.token,
|
|
pubkey: paired.pubkey,
|
|
relays: paired.relays,
|
|
},
|
|
);
|
|
})?;
|
|
Ok(summary)
|
|
}
|
|
|
|
/// Forget paired tokens: one instance's, or all of them. Local only.
|
|
pub fn unpair(instance: Option<&str>) -> Result<serde_json::Value, CliError> {
|
|
unpair_with(&Keychain, instance)
|
|
}
|
|
|
|
fn unpair_with(
|
|
store: &dyn PairingStore,
|
|
instance: Option<&str>,
|
|
) -> Result<serde_json::Value, CliError> {
|
|
let removed = match instance {
|
|
Some(i) => update(store, |pairings| {
|
|
pairings
|
|
.0
|
|
.remove(i)
|
|
.map(|_| vec![i.to_owned()])
|
|
.unwrap_or_default()
|
|
})?,
|
|
// Clearing everything needs no read, so it also clears an unreadable entry.
|
|
None => {
|
|
let _guard = store.lock()?;
|
|
let removed = store
|
|
.read()?
|
|
.map(|json| parse_pairings(Some(json)).map(|p| p.0.into_keys().collect()))
|
|
.transpose()
|
|
.unwrap_or_default()
|
|
.unwrap_or_default();
|
|
store.write(None)?;
|
|
removed
|
|
}
|
|
};
|
|
Ok(serde_json::json!({ "removed": removed }))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) mod tests {
|
|
use super::ownership::{lsof_uid, proc_net_tcp_uid};
|
|
use super::*;
|
|
use crate::cached_assertion::unix_now;
|
|
|
|
tokio::task_local! {
|
|
/// Pretend the caller runs as this user ID.
|
|
pub(crate) static CALLER_UID: u32;
|
|
/// Make every same-user check run out of time.
|
|
pub(crate) static SAME_USER_HANGS: ();
|
|
}
|
|
|
|
use std::sync::Mutex as StdMutex;
|
|
|
|
#[test]
|
|
fn endpoint_must_be_loopback_http_ipv4() {
|
|
assert!(Endpoint::parse("http://127.0.0.1:4100/rpc").is_ok());
|
|
for bad in [
|
|
"https://127.0.0.1:4100/rpc",
|
|
"http://localhost:4100/rpc",
|
|
"http://[::1]:4100/rpc",
|
|
"http://user@127.0.0.1:4100/rpc",
|
|
"http://10.0.0.1:4100/rpc",
|
|
"http://127.0.0.1/rpc",
|
|
] {
|
|
assert!(Endpoint::parse(bad).is_err(), "{bad}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unrecognized_codes_are_denied() {
|
|
assert_eq!(known_code("approval_required"), "approval_required");
|
|
assert_eq!(known_code("brand_new_code"), "denied");
|
|
}
|
|
|
|
#[test]
|
|
fn held_methods_get_the_long_timeout() {
|
|
assert!(held("sign_event") && held("nip44_decrypt") && held("pair"));
|
|
assert!(!held("describe") && !held("federated_identity"));
|
|
}
|
|
|
|
/// Serves `body` as one raw HTTP response and returns what `exchange` makes of it.
|
|
async fn exchanged(body: Vec<u8>) -> Result<(u16, Vec<u8>), ProviderError> {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
let server = tokio::spawn(async move {
|
|
let (mut stream, _) = listener.accept().await.unwrap();
|
|
// Read the whole request first: closing with any of it unread
|
|
// resets the connection and loses the reply.
|
|
let mut request = Vec::new();
|
|
while !request.ends_with(b"\r\n\r\n{}") {
|
|
let mut chunk = [0u8; 4096];
|
|
match tokio::io::AsyncReadExt::read(&mut stream, &mut chunk).await {
|
|
Ok(0) | Err(_) => break,
|
|
Ok(n) => request.extend_from_slice(&chunk[..n]),
|
|
}
|
|
}
|
|
let head = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len());
|
|
let _ = stream.write_all(head.as_bytes()).await;
|
|
let _ = stream.write_all(&body).await;
|
|
let _ = stream.shutdown().await;
|
|
});
|
|
let endpoint = Endpoint::parse(&format!("http://127.0.0.1:{port}/rpc")).unwrap();
|
|
let stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
|
|
let reply = exchange(stream, &endpoint, Some("t"), VERSION, b"{}").await;
|
|
server.abort();
|
|
reply
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_largest_signed_event_fits_and_more_is_refused() {
|
|
const CANVAS_BYTES: usize = 512 * 1024;
|
|
// An event at the desktop's limit whose content is all quotes, so
|
|
// every content byte doubles again when the event is the result string.
|
|
let shell =
|
|
r#"{"id":"","pubkey":"","created_at":0,"kind":40100,"tags":[],"content":"","sig":""}"#;
|
|
let content = "\"".repeat((CANVAS_BYTES - shell.len()) / 2);
|
|
let event = serde_json::json!({
|
|
"id": "", "pubkey": "", "created_at": 0, "kind": 40100, "tags": [],
|
|
"content": content, "sig": "",
|
|
})
|
|
.to_string();
|
|
assert!(event.len() <= CANVAS_BYTES && event.len() > CANVAS_BYTES - 16);
|
|
// An agent note isn't bound by the relay frame: 64 KiB of content and
|
|
// 99 quote-filled 4 KiB tags, over the old Canvas-based cap once escaped.
|
|
let tags: Vec<_> = (0..99)
|
|
.map(|i| serde_json::json!(["t", format!("{i}{}", "\"".repeat(4000))]))
|
|
.collect();
|
|
let note = serde_json::json!({
|
|
"id": "", "pubkey": "", "created_at": 0, "kind": 30023, "tags": tags,
|
|
"content": "\"".repeat(64 * 1024), "sig": "",
|
|
})
|
|
.to_string();
|
|
// A decrypt whose base64 payload fills the request: its plaintext,
|
|
// three quarters of that, all control characters (`\u0001`, six bytes).
|
|
let plaintext = "\u{1}".repeat(MAX_REQUEST_BYTES / 4 * 3 - 64 * 1024);
|
|
for result in [event, note, plaintext] {
|
|
let body = serde_json::json!({ "id": "1", "result": result })
|
|
.to_string()
|
|
.into_bytes();
|
|
let (status, parsed) = exchanged(body.clone()).await.unwrap();
|
|
assert_eq!((status, parsed), (200, body));
|
|
}
|
|
let runaway = vec![b'x'; MAX_RESPONSE_BYTES];
|
|
assert!(matches!(
|
|
exchanged(runaway).await,
|
|
Err(ProviderError::Transport(message)) if message == "response too large"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_content_length_and_chunked_responses() {
|
|
let fixed = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhiEXTRA";
|
|
assert_eq!(parse_response(fixed), Some((200, b"hi".to_vec())));
|
|
let chunked =
|
|
b"HTTP/1.1 403 Forbidden\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n2\r\nde\r\n0\r\n\r\n";
|
|
assert_eq!(parse_response(chunked), Some((403, b"abcde".to_vec())));
|
|
assert_eq!(parse_response(b"garbage"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn proc_net_tcp_finds_the_server_side_of_this_connection() {
|
|
let table = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n\
|
|
0: 0100007F:1004 00000000:0000 0A 00000000:00000000 00:00000000 00000000 501 0 1\n\
|
|
1: 0100007F:1004 0100007F:C350 01 00000000:00000000 00:00000000 00000000 502 0 2\n\
|
|
2: 0100007F:C350 0100007F:1004 01 00000000:00000000 00:00000000 00000000 501 0 3\n";
|
|
assert_eq!(proc_net_tcp_uid(table, 0x1004, 0xC350), Some(502));
|
|
assert_eq!(proc_net_tcp_uid(table, 0x1004, 0xC351), None);
|
|
}
|
|
|
|
#[test]
|
|
fn lsof_finds_the_server_side_of_this_connection() {
|
|
let out = "p100\nu501\nn127.0.0.1:50000->127.0.0.1:4100\np200\nu502\nn127.0.0.1:4100->127.0.0.1:50000\n";
|
|
assert_eq!(lsof_uid(out, 4100, 50000), Some(502));
|
|
assert_eq!(lsof_uid(out, 4100, 50001), None);
|
|
}
|
|
|
|
#[test]
|
|
fn advertisements_require_private_ownership() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let ads = dir.path().join("providers");
|
|
std::fs::create_dir(&ads).unwrap();
|
|
let write = |name: &str, mode: u32| {
|
|
let path = ads.join(name);
|
|
std::fs::write(
|
|
&path,
|
|
r#"{"instance":"xyz.block.buzz","url":"http://127.0.0.1:4100/rpc"}"#,
|
|
)
|
|
.unwrap();
|
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
|
|
};
|
|
write("good.json", 0o600);
|
|
write("open.json", 0o644);
|
|
write(".partial.json", 0o600);
|
|
std::fs::set_permissions(&ads, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
assert!(
|
|
advertisements_in(&ads).is_empty(),
|
|
"a group-readable directory is ignored"
|
|
);
|
|
std::fs::set_permissions(&ads, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
let found = advertisements_in(&ads);
|
|
assert_eq!(found.len(), 1);
|
|
assert_eq!(found[0].0, "xyz.block.buzz");
|
|
assert_eq!(found[0].1.port, 4100);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Against a mock provider
|
|
// -----------------------------------------------------------------------
|
|
|
|
pub(crate) type Seen = Arc<StdMutex<Vec<(Option<String>, String, serde_json::Value)>>>;
|
|
|
|
/// A provider answering each envelope with `respond(version, request)`.
|
|
pub(crate) async fn provider<F>(respond: F) -> (Endpoint, Seen)
|
|
where
|
|
F: Fn(&str, &serde_json::Value) -> serde_json::Value + Send + Sync + 'static,
|
|
{
|
|
use axum::http::HeaderMap;
|
|
let seen: Seen = Arc::default();
|
|
let log = seen.clone();
|
|
let respond = Arc::new(respond);
|
|
let app = axum::Router::new().route(
|
|
"/rpc",
|
|
axum::routing::post(move |headers: HeaderMap, body: axum::body::Bytes| {
|
|
let (log, respond) = (log.clone(), respond.clone());
|
|
async move {
|
|
let request: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
let header = |n: &str| {
|
|
headers
|
|
.get(n)
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(str::to_owned)
|
|
};
|
|
let version = header(VERSION_HEADER).unwrap_or_default();
|
|
let mut answer = respond(&version, &request);
|
|
answer["id"] = request["id"].clone();
|
|
log.lock()
|
|
.unwrap()
|
|
.push((header("authorization"), version, request));
|
|
axum::Json(answer)
|
|
}
|
|
}),
|
|
);
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
|
(
|
|
Endpoint::parse(&format!("http://127.0.0.1:{port}/rpc")).unwrap(),
|
|
seen,
|
|
)
|
|
}
|
|
|
|
const RELAY: &str = "wss://community.example";
|
|
|
|
fn describe(relays: &[&str]) -> serde_json::Value {
|
|
let pubkey = nostr::Keys::generate().public_key().to_hex();
|
|
serde_json::json!({"result": serde_json::json!({
|
|
"instance": "xyz.block.buzz", "pubkey": pubkey, "role": "owner",
|
|
"relays": relays, "methods": ["get_public_key", "sign_event"],
|
|
}).to_string()})
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn describe_sends_token_and_version_and_binds_the_relay() {
|
|
let (endpoint, seen) = provider(|_, _| describe(&[RELAY])).await;
|
|
let opened = Provider::open(endpoint, "tok".into(), false, RELAY)
|
|
.await
|
|
.unwrap();
|
|
assert!(opened.is_ok());
|
|
let seen = seen.lock().unwrap();
|
|
assert_eq!(seen[0].0.as_deref(), Some("Bearer tok"));
|
|
assert_eq!(seen[0].1, "1");
|
|
assert_eq!(seen[0].2["method"], "describe");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn short_and_long_assertions_refresh_at_the_earlier_point() {
|
|
use base64::Engine;
|
|
for (lifetime, refresh) in [(20, 10), (300, 150)] {
|
|
let (endpoint, seen) = provider(move |_, request| match request["method"].as_str() {
|
|
Some("federated_identity") => {
|
|
let b64 = |v: serde_json::Value| {
|
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(v.to_string())
|
|
};
|
|
let jws = format!(
|
|
"{}.{}.c2ln",
|
|
b64(serde_json::json!({"alg": "ES256"})),
|
|
b64(serde_json::json!({"exp": unix_now() + lifetime}))
|
|
);
|
|
serde_json::json!({ "result": jws })
|
|
}
|
|
_ => describe(&[RELAY]),
|
|
})
|
|
.await;
|
|
let Ok(Ok(provider)) = Provider::open(endpoint, "tok".into(), false, RELAY).await
|
|
else {
|
|
panic!("the provider should open");
|
|
};
|
|
let mints = || {
|
|
let seen = seen.lock().unwrap();
|
|
seen.iter()
|
|
.filter(|s| s.2["method"] == "federated_identity")
|
|
.count()
|
|
};
|
|
provider.federated_identity().await.unwrap();
|
|
crate::cached_assertion::test_clock::advance(refresh - 1);
|
|
provider.federated_identity().await.unwrap();
|
|
assert_eq!(mints(), 1, "lifetime {lifetime}");
|
|
crate::cached_assertion::test_clock::advance(1);
|
|
provider.federated_identity().await.unwrap();
|
|
assert_eq!(mints(), 2, "lifetime {lifetime}");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_unbound_relay_or_revoked_token_falls_through() {
|
|
let (endpoint, _) = provider(|_, _| describe(&["wss://other.example"])).await;
|
|
let opened = Provider::open(endpoint, "tok".into(), false, RELAY)
|
|
.await
|
|
.unwrap();
|
|
assert!(matches!(opened, Err(Skip::NotApplicable)));
|
|
let (endpoint, _) =
|
|
provider(|_, _| serde_json::json!({"result": "", "error": "revoked"})).await;
|
|
let opened = Provider::open(endpoint, "tok".into(), false, RELAY)
|
|
.await
|
|
.unwrap();
|
|
assert!(matches!(opened, Err(Skip::Revoked)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn version_negotiation_retries_once_or_gives_up() {
|
|
let (endpoint, seen) = provider(|_, _| {
|
|
serde_json::json!({"result": "", "error": "unsupported_version", "versions": [2, 3]})
|
|
})
|
|
.await;
|
|
let err = call(&endpoint, Some("tok"), false, "describe", &[])
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, ProviderError::NoCommonVersion));
|
|
assert_eq!(seen.lock().unwrap().len(), 1);
|
|
}
|
|
|
|
/// A throttled auth proof is re-sent dated now (its Blossom expiration
|
|
/// moved with it); a content event is re-sent exactly as built.
|
|
#[tokio::test]
|
|
async fn a_throttled_auth_proof_is_renewed_and_a_content_event_is_not() {
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
let keys = nostr::Keys::generate();
|
|
let answer = desktop_answers(keys.clone(), vec![RELAY.into()]);
|
|
let throttle = Arc::new(AtomicBool::new(false));
|
|
let gate = throttle.clone();
|
|
let (endpoint, seen) = provider(move |version, request| {
|
|
if request["method"] == "sign_event" && gate.swap(false, Ordering::SeqCst) {
|
|
// As long as a real throttle takes, at least a clock second.
|
|
std::thread::sleep(Duration::from_millis(1100));
|
|
return serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 1});
|
|
}
|
|
answer(version, request)
|
|
})
|
|
.await;
|
|
let desktop = match Provider::open(endpoint, "tok".into(), false, RELAY)
|
|
.await
|
|
.unwrap()
|
|
{
|
|
Ok(p) => p,
|
|
Err(_) => panic!("provider not opened"),
|
|
};
|
|
let expiration = |e: &Event| e.tags.expiration().map(|t| t.as_secs());
|
|
|
|
let proof = nostr::EventBuilder::new(nostr::Kind::from(24242), "Upload file")
|
|
.tags([Tag::parse(["expiration", &(unix_now() + 60).to_string()]).unwrap()])
|
|
.build(keys.public_key());
|
|
throttle.store(true, Ordering::SeqCst);
|
|
let signed = desktop.sign(proof.clone()).await.unwrap();
|
|
assert!(
|
|
signed.created_at > proof.created_at,
|
|
"the proof kept its old date"
|
|
);
|
|
assert_eq!(
|
|
expiration(&signed).unwrap() - signed.created_at.as_secs(),
|
|
60,
|
|
"the expiration did not move with the date"
|
|
);
|
|
|
|
let mut content =
|
|
nostr::EventBuilder::new(nostr::Kind::TextNote, "hi").build(keys.public_key());
|
|
let id = content.id();
|
|
throttle.store(true, Ordering::SeqCst);
|
|
let signed = desktop.sign(content.clone()).await.unwrap();
|
|
assert_eq!(signed.created_at, content.created_at);
|
|
assert_eq!(signed.id, id);
|
|
assert!(seen.lock().unwrap().len() >= 5);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rate_limited_is_waited_out_then_reported_as_retryable() {
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
let asked = Arc::new(AtomicUsize::new(0));
|
|
let count = asked.clone();
|
|
let (endpoint, seen) = provider(move |_, _| match count.fetch_add(1, Ordering::SeqCst) {
|
|
0 => serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 1}),
|
|
_ => serde_json::json!({"result": "signed"}),
|
|
})
|
|
.await;
|
|
let started = std::time::Instant::now();
|
|
let result = call(&endpoint, Some("tok"), false, "sign_event", &[]).await;
|
|
assert_eq!(result.unwrap(), "signed");
|
|
assert!(started.elapsed() >= RETRY_AFTER_UNIT, "honors retry_after");
|
|
assert_eq!(seen.lock().unwrap().len(), 2);
|
|
|
|
let (endpoint, seen) = provider(
|
|
|_, _| serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 0}),
|
|
)
|
|
.await;
|
|
// `retry_after: 0` still waits the 1 s floor, so this takes a few seconds.
|
|
let err = call(&endpoint, Some("tok"), false, "sign_event", &[])
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(seen.lock().unwrap().len(), 1 + RATE_LIMIT_RETRIES as usize);
|
|
let err = err.into_cli("sign_event");
|
|
assert!(matches!(err, CliError::Throttled(_)), "{err:?}");
|
|
assert!(crate::error::is_retryable_error(&err));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_cooldown_past_the_cap_is_reported_without_retrying() {
|
|
let (endpoint, seen) = provider(
|
|
|_, _| serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 300}),
|
|
)
|
|
.await;
|
|
let err = call(&endpoint, None, false, "pair", &[]).await.unwrap_err();
|
|
assert_eq!(seen.lock().unwrap().len(), 1);
|
|
let err = err.into_cli("pair");
|
|
assert!(
|
|
matches!(&err, CliError::Throttled(m) if m.contains("retry in 300s")),
|
|
"{err:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_error_envelope_for_another_request_is_refused() {
|
|
let (endpoint, _) =
|
|
provider(|_, _| serde_json::json!({"result": "", "error": "revoked"})).await;
|
|
// The mock echoes the request id; a raw server that does not is below.
|
|
assert!(matches!(
|
|
call(&endpoint, Some("tok"), false, "describe", &[]).await,
|
|
Err(ProviderError::Revoked)
|
|
));
|
|
let app = axum::Router::new().route(
|
|
"/rpc",
|
|
axum::routing::post(|| async {
|
|
axum::Json(serde_json::json!({"id": "other", "result": "", "error": "revoked"}))
|
|
}),
|
|
);
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
|
let endpoint = Endpoint::parse(&format!("http://127.0.0.1:{port}/rpc")).unwrap();
|
|
assert!(matches!(
|
|
call(&endpoint, Some("tok"), false, "describe", &[]).await,
|
|
Err(ProviderError::Transport(_))
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_unknown_error_code_is_denied() {
|
|
let (endpoint, _) =
|
|
provider(|_, _| serde_json::json!({"result": "", "error": "brand_new"})).await;
|
|
let err = call(&endpoint, Some("tok"), false, "sign_event", &[])
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, ProviderError::Code { code: "denied", .. }));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn same_user_check_accepts_this_users_provider() {
|
|
let (endpoint, _) = provider(|_, _| describe(&[RELAY])).await;
|
|
let opened = Provider::open(endpoint, "tok".into(), true, RELAY)
|
|
.await
|
|
.unwrap();
|
|
assert!(opened.is_ok());
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// A desktop holding a real key, and an in-memory pairing store
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// The assertion the mock desktop hands out for any relay.
|
|
pub(crate) fn mock_assertion() -> String {
|
|
assertion(600)
|
|
}
|
|
|
|
/// An assertion that expires `lifetime` s from now.
|
|
pub(crate) fn assertion(lifetime: u64) -> String {
|
|
use base64::Engine;
|
|
let b64 = |v: serde_json::Value| {
|
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(v.to_string())
|
|
};
|
|
format!(
|
|
"{}.{}.c2ln",
|
|
b64(serde_json::json!({"alg": "ES256", "typ": "nip-fi+jwt"})),
|
|
b64(serde_json::json!({"exp": unix_now() + lifetime}))
|
|
)
|
|
}
|
|
|
|
/// A desktop acting as `keys` for `relays`: it signs, encrypts, decrypts
|
|
/// (answering `invalid_request` for bad ciphertext, as #2260 does),
|
|
/// derives engram `d` tags and hands out [`mock_assertion`].
|
|
pub(crate) async fn desktop(keys: nostr::Keys, relays: Vec<String>) -> (Endpoint, Seen) {
|
|
provider(desktop_answers(keys, relays)).await
|
|
}
|
|
|
|
/// [`desktop`]'s answers, for a provider that wraps them.
|
|
pub(crate) fn desktop_answers(
|
|
keys: nostr::Keys,
|
|
relays: Vec<String>,
|
|
) -> impl Fn(&str, &serde_json::Value) -> serde_json::Value + Send + Sync + 'static {
|
|
move |_, request| {
|
|
let params: Vec<String> =
|
|
serde_json::from_value(request["params"].clone()).unwrap_or_default();
|
|
let peer = || PublicKey::from_hex(¶ms[0]).unwrap();
|
|
let ok = |result: String| serde_json::json!({ "result": result });
|
|
match request["method"].as_str().unwrap_or_default() {
|
|
"describe" => ok(serde_json::json!({
|
|
"instance": "xyz.block.buzz", "pubkey": keys.public_key().to_hex(),
|
|
"role": "owner", "relays": relays,
|
|
"methods": ["sign_event", "nip44_decrypt", "federated_identity"],
|
|
})
|
|
.to_string()),
|
|
"sign_event" => {
|
|
let unsigned = UnsignedEvent::from_json(¶ms[0]).unwrap();
|
|
ok(unsigned.sign_with_keys(&keys).unwrap().as_json())
|
|
}
|
|
"nip44_encrypt" => ok(nostr::nips::nip44::encrypt(
|
|
keys.secret_key(),
|
|
&peer(),
|
|
¶ms[1],
|
|
nostr::nips::nip44::Version::V2,
|
|
)
|
|
.unwrap()),
|
|
"nip44_decrypt" => {
|
|
match nostr::nips::nip44::decrypt(keys.secret_key(), &peer(), ¶ms[1]) {
|
|
Ok(plain) => ok(plain),
|
|
Err(_) => serde_json::json!({"result": "", "error": "invalid_request"}),
|
|
}
|
|
}
|
|
"engram_d_tag" => {
|
|
let k_c = buzz_core::engram::conversation_key(keys.secret_key(), &peer());
|
|
ok(buzz_core::engram::d_tag(&k_c, ¶ms[1]))
|
|
}
|
|
"federated_identity" => ok(mock_assertion()),
|
|
_ => serde_json::json!({"result": "", "error": "unknown_method"}),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct MemoryStore {
|
|
json: StdMutex<Option<String>>,
|
|
lock: StdMutex<()>,
|
|
}
|
|
|
|
impl MemoryStore {
|
|
fn with(pairings: &[(&str, &str, &[&str])]) -> Self {
|
|
let map: std::collections::BTreeMap<_, _> = pairings
|
|
.iter()
|
|
.map(|(instance, token, relays)| {
|
|
let pairing = Pairing {
|
|
token: (*token).into(),
|
|
pubkey: nostr::Keys::generate().public_key().to_hex(),
|
|
relays: relays.iter().map(|r| (*r).to_owned()).collect(),
|
|
};
|
|
((*instance).to_owned(), pairing)
|
|
})
|
|
.collect();
|
|
let store = Self::default();
|
|
*store.json.lock().unwrap() = Some(serde_json::to_string(&Pairings(map)).unwrap());
|
|
store
|
|
}
|
|
|
|
fn instances(&self) -> Vec<String> {
|
|
parse_pairings(self.json.lock().unwrap().clone())
|
|
.unwrap()
|
|
.0
|
|
.into_keys()
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
impl PairingStore for MemoryStore {
|
|
fn read(&self) -> Result<Option<String>, CliError> {
|
|
Ok(self.json.lock().unwrap().clone())
|
|
}
|
|
fn write(&self, json: Option<&str>) -> Result<(), CliError> {
|
|
*self.json.lock().unwrap() = json.map(str::to_owned);
|
|
Ok(())
|
|
}
|
|
fn lock(&self) -> Result<Box<dyn Held + '_>, CliError> {
|
|
Ok(Box::new(self.lock.lock().unwrap()))
|
|
}
|
|
}
|
|
|
|
fn methods(seen: &Seen) -> Vec<String> {
|
|
seen.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|(_, _, r)| r["method"].as_str().unwrap_or_default().to_owned())
|
|
.collect()
|
|
}
|
|
|
|
/// An agent whose inherited token is revoked or not bound to the relay
|
|
/// fails, even with a valid owner pairing advertised: the owner's
|
|
/// desktop gets no request at all.
|
|
#[tokio::test]
|
|
async fn an_agent_never_falls_back_to_the_owner_pairing() {
|
|
let (owner, owner_seen) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await;
|
|
let store = MemoryStore::with(&[("xyz.block.buzz", "owner-token", &[RELAY])]);
|
|
let (revoked, _) =
|
|
provider(|_, _| serde_json::json!({"result": "", "error": "revoked"})).await;
|
|
let (unbound, _) = provider(|_, _| describe(&["wss://other.example"])).await;
|
|
for agent in [revoked, unbound] {
|
|
let ads = || vec![("xyz.block.buzz".to_owned(), owner.clone())];
|
|
let result = discover_with(Some((agent, "agent".into())), &store, ads, RELAY).await;
|
|
assert!(matches!(result, Err(CliError::Auth(_))), "{result:?}");
|
|
}
|
|
assert!(owner_seen.lock().unwrap().is_empty(), "the owner was asked");
|
|
// Without an inherited token the same pairing is used.
|
|
let ads = || vec![("xyz.block.buzz".to_owned(), owner.clone())];
|
|
assert!(discover_with(None, &store, ads, RELAY)
|
|
.await
|
|
.unwrap()
|
|
.is_some());
|
|
assert_eq!(
|
|
owner_seen.lock().unwrap()[0].0.as_deref(),
|
|
Some("Bearer owner-token")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn relay_urls_bind_whole_after_normalization() {
|
|
let norm = |u: &str| provider_relay_url(u).unwrap();
|
|
assert_eq!(norm("https://Host.example"), "wss://host.example/");
|
|
assert_eq!(norm("wss://host.example:443/"), "wss://host.example/");
|
|
assert_eq!(norm("http://127.0.0.1:3000"), "ws://127.0.0.1:3000/");
|
|
assert_eq!(norm("ws://127.0.0.1:80/a"), "ws://127.0.0.1/a");
|
|
assert_eq!(norm("wss://host.example/a?x=1"), "wss://host.example/a?x=1");
|
|
assert_ne!(norm("wss://host.example/a"), norm("wss://host.example/b"));
|
|
assert_ne!(norm("ws://host.example"), norm("wss://host.example"));
|
|
assert!(provider_relay_url("ftp://host.example").is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_token_binds_its_relay_path_and_plain_ws() {
|
|
let bound = vec![
|
|
"wss://host.example/a".to_owned(),
|
|
"ws://127.0.0.1:7000".to_owned(),
|
|
];
|
|
let (endpoint, _) = desktop(nostr::Keys::generate(), bound).await;
|
|
let open = |relay: &'static str| {
|
|
let endpoint = endpoint.clone();
|
|
async move {
|
|
Provider::open(endpoint, "t".into(), false, relay)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
};
|
|
assert!(open("https://host.example/a").await.is_ok());
|
|
assert!(matches!(
|
|
open("https://host.example/b").await,
|
|
Err(Skip::NotApplicable)
|
|
));
|
|
assert!(matches!(
|
|
open("https://host.example").await,
|
|
Err(Skip::NotApplicable)
|
|
));
|
|
assert!(open("http://127.0.0.1:7000/").await.is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bad_ciphertext_is_invalid_but_a_credential_failure_is_an_error() {
|
|
let keys = nostr::Keys::generate();
|
|
let peer = nostr::Keys::generate();
|
|
let (endpoint, _) = desktop(keys.clone(), vec![RELAY.into()]).await;
|
|
let p = Provider::open(endpoint, "t".into(), false, RELAY)
|
|
.await
|
|
.unwrap()
|
|
.ok()
|
|
.unwrap();
|
|
let good = nostr::nips::nip44::encrypt(
|
|
peer.secret_key(),
|
|
&keys.public_key(),
|
|
"hi",
|
|
nostr::nips::nip44::Version::V2,
|
|
)
|
|
.unwrap();
|
|
let plain = p.nip44_decrypt(&peer.public_key(), &good).await.unwrap();
|
|
assert_eq!(plain.as_deref(), Some("hi"));
|
|
assert!(p
|
|
.nip44_decrypt(&peer.public_key(), "AAAA")
|
|
.await
|
|
.unwrap()
|
|
.is_none());
|
|
for code in ["revoked", "denied", "unavailable"] {
|
|
let (endpoint, _) = provider(move |_, r| match r["method"].as_str() {
|
|
Some("describe") => describe(&[RELAY]),
|
|
_ => serde_json::json!({"result": "", "error": code}),
|
|
})
|
|
.await;
|
|
let p = Provider::open(endpoint, "t".into(), false, RELAY)
|
|
.await
|
|
.unwrap()
|
|
.ok()
|
|
.unwrap();
|
|
assert!(
|
|
p.nip44_decrypt(&peer.public_key(), &good).await.is_err(),
|
|
"{code}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_paired_desktop_that_refuses_fails_and_revoked_entries_are_forgotten() {
|
|
let (denied, _) =
|
|
provider(|_, _| serde_json::json!({"result": "", "error": "denied"})).await;
|
|
let store = MemoryStore::with(&[("a", "ta", &[RELAY])]);
|
|
let ads = || vec![("a".to_owned(), denied)];
|
|
assert!(discover_with(None, &store, ads, RELAY).await.is_err());
|
|
|
|
let (revoked, _) =
|
|
provider(|_, _| serde_json::json!({"result": "", "error": "revoked"})).await;
|
|
let (good, _) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await;
|
|
let store = MemoryStore::with(&[("a", "ta", &[RELAY]), ("b", "tb", &[RELAY])]);
|
|
let ads = || vec![("a".to_owned(), revoked), ("b".to_owned(), good)];
|
|
assert!(discover_with(None, &store, ads, RELAY)
|
|
.await
|
|
.unwrap()
|
|
.is_some());
|
|
assert_eq!(store.instances(), ["b"]);
|
|
}
|
|
|
|
/// A pairing waiting for approval does not hold the store, and its commit
|
|
/// reloads: a concurrent unpair stays unpaired and a concurrent pairing
|
|
/// is kept.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn concurrent_pair_and_unpair_keep_each_others_changes() {
|
|
let pubkey = nostr::Keys::generate().public_key().to_hex();
|
|
// `started` says the approval is pending; `release` lets it answer.
|
|
let (started_tx, started) = std::sync::mpsc::channel::<()>();
|
|
let (release, release_rx) = std::sync::mpsc::channel::<()>();
|
|
let (started_tx, release_rx) = (StdMutex::new(started_tx), StdMutex::new(release_rx));
|
|
let (slow, _) = provider(move |_, _| {
|
|
started_tx.lock().unwrap().send(()).unwrap();
|
|
release_rx.lock().unwrap().recv().unwrap();
|
|
serde_json::json!({"result": serde_json::json!({
|
|
"token": "tb", "instance": "b", "pubkey": pubkey, "relays": [RELAY],
|
|
}).to_string()})
|
|
})
|
|
.await;
|
|
let store = Arc::new(MemoryStore::with(&[("a", "ta", &[RELAY])]));
|
|
let pairing = {
|
|
let store = store.clone();
|
|
tokio::spawn(
|
|
async move { pair_with(store.as_ref(), "b".into(), slow, RELAY, "cli").await },
|
|
)
|
|
};
|
|
tokio::task::spawn_blocking(move || started.recv().unwrap())
|
|
.await
|
|
.unwrap();
|
|
unpair_with(store.as_ref(), Some("a")).unwrap();
|
|
update(store.as_ref(), |p| {
|
|
assert!(!p.0.contains_key("b"));
|
|
p.0.insert(
|
|
"c".into(),
|
|
Pairing {
|
|
token: "tc".into(),
|
|
pubkey: String::new(),
|
|
relays: vec![],
|
|
},
|
|
);
|
|
})
|
|
.unwrap();
|
|
release.send(()).unwrap();
|
|
pairing.await.unwrap().unwrap();
|
|
assert_eq!(store.instances(), ["b", "c"]);
|
|
}
|
|
|
|
/// Through the CLI's own wiring of `--relay`, a community ending in a
|
|
/// slash reaches the desktop exactly as configured, for inherited
|
|
/// discovery, evidence and pairing.
|
|
#[tokio::test]
|
|
async fn a_non_root_trailing_slash_relay_reaches_the_desktop_unchanged() {
|
|
use clap::Parser;
|
|
const TEAM: &str = "wss://host.example/team/";
|
|
let parse = |args: &[&str]| crate::Cli::try_parse_from(args).unwrap();
|
|
let store = MemoryStore::default();
|
|
|
|
let (endpoint, seen) = desktop(nostr::Keys::generate(), vec![TEAM.into()]).await;
|
|
let cli = parse(&["buzz", "--relay", TEAM, "users", "get"]);
|
|
let (_, client) = crate::connect(
|
|
cli.relay.as_deref().unwrap(),
|
|
cli.command,
|
|
async |_, _| unreachable!(),
|
|
{
|
|
let store = &store;
|
|
async move |relay| {
|
|
let provider =
|
|
discover_with(Some((endpoint, "agent".into())), store, Vec::new, relay)
|
|
.await?
|
|
.unwrap();
|
|
crate::credentials::Credentials::from_desktop(provider).await
|
|
}
|
|
},
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
client.credentials().evidence().await.unwrap();
|
|
let asked = seen.lock().unwrap().last().unwrap().2.clone();
|
|
assert_eq!(asked["params"], serde_json::json!([TEAM]));
|
|
|
|
let pubkey = nostr::Keys::generate().public_key().to_hex();
|
|
let (endpoint, seen) = provider(move |_, _| {
|
|
serde_json::json!({"result": serde_json::json!({
|
|
"token": "tok", "instance": "i", "pubkey": pubkey, "relays": [TEAM],
|
|
}).to_string()})
|
|
})
|
|
.await;
|
|
let cli = parse(&[
|
|
"buzz", "--relay", TEAM, "auth", "desktop", "pair", "--name", "cli",
|
|
]);
|
|
let paired = crate::connect(
|
|
cli.relay.as_deref().unwrap(),
|
|
cli.command,
|
|
async |sub, relay| {
|
|
let crate::AuthCmd::Desktop(crate::DesktopAuthCmd::Pair { name, .. }) = sub else {
|
|
unreachable!()
|
|
};
|
|
pair_with(&store, "i".into(), endpoint, relay, &name)
|
|
.await
|
|
.map(drop)
|
|
},
|
|
async |_| unreachable!(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(paired.is_none());
|
|
let asked = seen.lock().unwrap()[0].2.clone();
|
|
assert_eq!(asked["params"], serde_json::json!(["cli", TEAM]));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_unreadable_store_is_refused_not_overwritten() {
|
|
let (endpoint, seen) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await;
|
|
let store = MemoryStore::default();
|
|
*store.json.lock().unwrap() = Some("not json".into());
|
|
assert!(
|
|
pair_with(&store, "xyz.block.buzz".into(), endpoint, RELAY, "cli")
|
|
.await
|
|
.is_err()
|
|
);
|
|
assert!(seen.lock().unwrap().is_empty(), "approval was requested");
|
|
assert!(unpair_with(&store, Some("a")).is_err());
|
|
assert_eq!(store.json.lock().unwrap().as_deref(), Some("not json"));
|
|
unpair_with(&store, None).unwrap();
|
|
assert!(store.json.lock().unwrap().is_none());
|
|
}
|
|
|
|
/// `pair` sends the whole bound URL, and the token it gets back is stored.
|
|
#[tokio::test]
|
|
async fn pair_sends_the_bound_url_and_stores_the_token() {
|
|
let pubkey = nostr::Keys::generate().public_key().to_hex();
|
|
let (endpoint, seen) = provider(move |_, _| {
|
|
serde_json::json!({"result": serde_json::json!({
|
|
"token": "tok", "instance": "i", "pubkey": pubkey,
|
|
"relays": ["wss://host.example/a"],
|
|
}).to_string()})
|
|
})
|
|
.await;
|
|
let store = MemoryStore::default();
|
|
pair_with(
|
|
&store,
|
|
"i".into(),
|
|
endpoint,
|
|
"https://host.example/a",
|
|
"cli",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let request = seen.lock().unwrap()[0].2.clone();
|
|
assert_eq!(
|
|
request["params"],
|
|
serde_json::json!(["cli", "wss://host.example/a"])
|
|
);
|
|
assert_eq!(store.instances(), ["i"]);
|
|
unpair_with(&store, Some("i")).unwrap();
|
|
assert!(store.json.lock().unwrap().is_none());
|
|
}
|
|
|
|
/// A paired desktop whose user cannot be confirmed is skipped without a
|
|
/// byte sent, so the chain moves on; a check that only ran out of time
|
|
/// leaves the command retryable rather than without credentials.
|
|
#[tokio::test]
|
|
async fn an_unconfirmed_paired_desktop_is_skipped() {
|
|
let (endpoint, seen) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await;
|
|
let store = MemoryStore::with(&[("a", "ta", &[RELAY])]);
|
|
let ads = || vec![("a".to_owned(), endpoint.clone())];
|
|
let found = CALLER_UID
|
|
.scope(u32::MAX - 1, discover_with(None, &store, ads, RELAY))
|
|
.await
|
|
.unwrap();
|
|
assert!(found.is_none());
|
|
|
|
let ads = || vec![("a".to_owned(), endpoint.clone())];
|
|
let err = SAME_USER_HANGS
|
|
.scope((), discover_with(None, &store, ads, RELAY))
|
|
.await
|
|
.unwrap_err();
|
|
assert!(crate::error::is_retryable_error(&err), "{err:?}");
|
|
assert_eq!(crate::error::exit_code(&err), 2);
|
|
assert!(seen.lock().unwrap().is_empty());
|
|
assert_eq!(store.instances(), ["a"]);
|
|
}
|
|
|
|
/// A command given no relay uses the one relay a running desktop still
|
|
/// accepts a pairing for, never a stored pairing alone.
|
|
#[tokio::test]
|
|
async fn the_default_relay_is_the_one_a_running_desktop_accepts() {
|
|
const OTHER: &str = "wss://other.example/";
|
|
let (good, _) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await;
|
|
let (other, _) = desktop(nostr::Keys::generate(), vec![OTHER.into()]).await;
|
|
let (revoked, _) =
|
|
provider(|_, _| serde_json::json!({"result": "", "error": "revoked"})).await;
|
|
let gone = Endpoint::parse("http://127.0.0.1:1/rpc").unwrap();
|
|
let store = MemoryStore::with(&[
|
|
("a", "ta", &[RELAY]),
|
|
("b", "tb", &[OTHER]),
|
|
("c", "tc", &[OTHER]),
|
|
("d", "td", &[OTHER]),
|
|
]);
|
|
let ad = |i: &str, e: &Endpoint| (i.to_owned(), e.clone());
|
|
|
|
let one = vec![ad("a", &good), ad("c", &revoked), ad("d", &gone)];
|
|
assert_eq!(
|
|
accepted_relay(&store, one).await.unwrap().as_deref(),
|
|
Some("wss://community.example/")
|
|
);
|
|
// Several accepted relays, or none, keep the built-in default.
|
|
let two = vec![ad("a", &good), ad("b", &other)];
|
|
assert_eq!(accepted_relay(&store, two).await.unwrap(), None);
|
|
let none = vec![ad("c", &revoked), ad("d", &gone)];
|
|
assert_eq!(accepted_relay(&store, none).await.unwrap(), None);
|
|
// A desktop that no longer binds the paired relay does not count.
|
|
let rebound = vec![ad("b", &good)];
|
|
assert_eq!(accepted_relay(&store, rebound).await.unwrap(), None);
|
|
|
|
assert_eq!(
|
|
paired_relays_in(&store),
|
|
["wss://community.example/", OTHER]
|
|
);
|
|
}
|
|
|
|
/// With no relay given, a desktop that refuses is final, and a check that
|
|
/// may pass on retry stays retryable, never a fallback to localhost.
|
|
#[tokio::test]
|
|
async fn the_default_relay_keeps_desktop_failures() {
|
|
let store = MemoryStore::with(&[("a", "ta", &[RELAY])]);
|
|
let (denied, _) = provider(
|
|
|_, _| serde_json::json!({"result": "", "error": "denied", "message": "locked"}),
|
|
)
|
|
.await;
|
|
let err = accepted_relay(&store, vec![("a".into(), denied)])
|
|
.await
|
|
.unwrap_err();
|
|
assert!(!crate::error::is_retryable_error(&err), "{err:?}");
|
|
assert!(err.to_string().contains("locked"), "{err}");
|
|
|
|
let (throttled, _) = provider(
|
|
|_, _| serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 1}),
|
|
)
|
|
.await;
|
|
let err = accepted_relay(&store, vec![("a".into(), throttled)])
|
|
.await
|
|
.unwrap_err();
|
|
assert!(crate::error::is_retryable_error(&err), "{err:?}");
|
|
|
|
let (good, _) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await;
|
|
let err = SAME_USER_HANGS
|
|
.scope((), accepted_relay(&store, vec![("a".into(), good.clone())]))
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(crate::error::exit_code(&err), 2, "{err:?}");
|
|
|
|
// A still-throttled desktop might add a second relay, so one
|
|
// accepted relay elsewhere does not settle the choice.
|
|
let (throttled, _) = provider(
|
|
|_, _| serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 1}),
|
|
)
|
|
.await;
|
|
let store = MemoryStore::with(&[("a", "ta", &[RELAY]), ("b", "tb", &[RELAY])]);
|
|
let ads = vec![("a".into(), good), ("b".into(), throttled)];
|
|
let err = accepted_relay(&store, ads).await.unwrap_err();
|
|
assert_eq!(crate::error::exit_code(&err), 2, "{err:?}");
|
|
}
|
|
|
|
/// With no usable relay, a retryable failure is reported over an
|
|
/// identity mismatch, in either order.
|
|
#[test]
|
|
fn a_retryable_failure_outranks_an_identity_mismatch() {
|
|
let mismatch = || ProviderError::OtherUser.into_cli("describe");
|
|
let timeout = || ProviderError::Unconfirmed.into_cli("describe");
|
|
for order in [[mismatch(), timeout()], [timeout(), mismatch()]] {
|
|
let mut kept = None;
|
|
for e in order {
|
|
keep_transient(&mut kept, e);
|
|
}
|
|
assert_eq!(crate::error::exit_code(&kept.unwrap()), 2);
|
|
}
|
|
let mut kept = None;
|
|
keep_transient(&mut kept, mismatch());
|
|
assert_eq!(crate::error::exit_code(&kept.unwrap()), 3);
|
|
}
|
|
|
|
/// Through the command's relay selection with no `--relay`: a desktop's
|
|
/// refusal and a retryable failure both fail the command.
|
|
#[tokio::test]
|
|
async fn a_command_given_no_relay_reports_desktop_failures() {
|
|
use clap::Parser;
|
|
let cli = crate::Cli::try_parse_from(["buzz", "users", "get"]).unwrap();
|
|
assert!(cli.relay.is_none());
|
|
let store = MemoryStore::with(&[("a", "ta", &[RELAY])]);
|
|
let (denied, _) = provider(
|
|
|_, _| serde_json::json!({"result": "", "error": "denied", "message": "locked"}),
|
|
)
|
|
.await;
|
|
let paired = accepted_relay(&store, vec![("a".into(), denied)]);
|
|
let err = crate::default_relay_with(&cli.command, false, async { Ok(None) }, paired)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(err.to_string().contains("locked"), "{err}");
|
|
|
|
let (good, _) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await;
|
|
let paired = accepted_relay(&store, vec![("a".into(), good.clone())]);
|
|
let err = SAME_USER_HANGS
|
|
.scope(
|
|
(),
|
|
crate::default_relay_with(&cli.command, false, async { Ok(None) }, paired),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(crate::error::exit_code(&err), 2, "{err:?}");
|
|
|
|
let paired = accepted_relay(&store, vec![("a".into(), good)]);
|
|
let relay = crate::default_relay_with(&cli.command, false, async { Ok(None) }, paired)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(relay, "wss://community.example/");
|
|
}
|
|
|
|
/// `auth desktop pair --instance b` with no `--relay`, through relay
|
|
/// selection and command dispatch: desktop A, which would refuse, is
|
|
/// never asked, and B gets exactly one pair request.
|
|
#[tokio::test]
|
|
async fn pairing_a_chosen_desktop_ignores_another_desktops_refusal() {
|
|
use clap::Parser;
|
|
let cli =
|
|
crate::Cli::try_parse_from(["buzz", "auth", "desktop", "pair", "--instance", "b"])
|
|
.unwrap();
|
|
let store = MemoryStore::with(&[("a", "ta", &[RELAY])]);
|
|
let (refusing, refused) = provider(
|
|
|_, _| serde_json::json!({"result": "", "error": "denied", "message": "locked"}),
|
|
)
|
|
.await;
|
|
let pubkey = nostr::Keys::generate().public_key().to_hex();
|
|
let answer = serde_json::json!({
|
|
"token": "tok", "instance": "b", "pubkey": pubkey, "relays": [RELAY],
|
|
});
|
|
let reply = answer.to_string();
|
|
let (b, seen) = provider(move |_, _| serde_json::json!({ "result": reply })).await;
|
|
let found = vec![("a".to_owned(), refusing.clone()), ("b".to_owned(), b)];
|
|
|
|
let paired = accepted_relay(&store, vec![("a".into(), refusing)]);
|
|
let relay = crate::default_relay_with(&cli.command, false, async { Ok(None) }, paired)
|
|
.await
|
|
.unwrap();
|
|
let result = std::cell::RefCell::new(None);
|
|
let connected = crate::connect(
|
|
&relay,
|
|
cli.command,
|
|
async |sub, relay| {
|
|
let crate::AuthCmd::Desktop(crate::DesktopAuthCmd::Pair { name, instance }) = sub
|
|
else {
|
|
unreachable!()
|
|
};
|
|
let out = pair_among(&store, found, relay, &name, instance.as_deref()).await?;
|
|
*result.borrow_mut() = Some(out);
|
|
Ok(())
|
|
},
|
|
async |_| unreachable!(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(connected.is_none());
|
|
|
|
assert!(refused.lock().unwrap().is_empty(), "A was asked");
|
|
let asked = seen.lock().unwrap();
|
|
assert_eq!(asked.len(), 1);
|
|
assert_eq!(asked[0].2["method"], "pair");
|
|
assert_eq!(
|
|
asked[0].2["params"],
|
|
serde_json::json!(["buzz CLI", "ws://localhost:3000/"])
|
|
);
|
|
assert_eq!(
|
|
result.into_inner().unwrap(),
|
|
serde_json::json!({"instance": "b", "pubkey": pubkey, "relays": [RELAY]})
|
|
);
|
|
}
|
|
|
|
/// A crashed desktop's leftover advertisement reads as not running, and
|
|
/// a throttled one is reported at once, not waited out.
|
|
#[tokio::test]
|
|
async fn list_tells_a_stale_advertisement_from_a_rejection() {
|
|
let closed = {
|
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
drop(listener);
|
|
Endpoint::parse(&format!("http://127.0.0.1:{port}/rpc")).unwrap()
|
|
};
|
|
let (throttled, seen) = provider(
|
|
|_, _| serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 30}),
|
|
)
|
|
.await;
|
|
let store = MemoryStore::with(&[("a", "ta", &[RELAY]), ("b", "tb", &[RELAY])]);
|
|
let ads = vec![("a".to_owned(), closed), ("b".to_owned(), throttled)];
|
|
let started = std::time::Instant::now();
|
|
let listed = list_with(&store, ads).await.unwrap();
|
|
assert!(started.elapsed() < Duration::from_secs(5));
|
|
assert_eq!(listed[0]["status"], "not running");
|
|
assert_eq!(listed[1]["status"], "rate limited");
|
|
assert_eq!(seen.lock().unwrap().len(), 1, "no retries");
|
|
}
|
|
|
|
/// The listing never shows a token and reports each pairing's state the
|
|
/// way the default relay judges it.
|
|
#[tokio::test]
|
|
async fn list_shows_each_pairing_without_its_token() {
|
|
assert!(list_with(&MemoryStore::default(), Vec::new())
|
|
.await
|
|
.unwrap()
|
|
.is_empty());
|
|
let (good, _) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await;
|
|
let (revoked, _) =
|
|
provider(|_, _| serde_json::json!({"result": "", "error": "revoked"})).await;
|
|
let store = MemoryStore::with(&[
|
|
("a", "secret-a", &[RELAY]),
|
|
("b", "secret-b", &[RELAY]),
|
|
("c", "secret-c", &[RELAY]),
|
|
]);
|
|
let ads = vec![("a".to_owned(), good), ("b".to_owned(), revoked)];
|
|
let listed = list_with(&store, ads).await.unwrap();
|
|
let text = serde_json::to_string(&listed).unwrap();
|
|
assert!(!text.contains("secret"), "{text}");
|
|
let status: Vec<_> = listed.iter().map(|p| p["status"].clone()).collect();
|
|
assert_eq!(status, ["accepted", "not accepted", "not running"]);
|
|
assert_eq!(listed[0]["relays"], serde_json::json!([RELAY]));
|
|
assert_eq!(
|
|
store.instances(),
|
|
["a", "b", "c"],
|
|
"listing changes nothing"
|
|
);
|
|
}
|
|
|
|
/// When this user cannot be confirmed on the server side, not one byte
|
|
/// (so no token) reaches the endpoint.
|
|
#[tokio::test]
|
|
async fn an_unconfirmed_user_gets_nothing_sent() {
|
|
let (endpoint, seen) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await;
|
|
let opened = CALLER_UID
|
|
.scope(
|
|
u32::MAX - 1,
|
|
Provider::open(endpoint, "secret".into(), true, RELAY),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(matches!(opened, Err(Skip::Unreachable)));
|
|
assert!(seen.lock().unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn signing_goes_through_the_desktop_and_is_verified() {
|
|
use nostr::NostrSigner;
|
|
let keys = nostr::Keys::generate();
|
|
let (endpoint, seen) = desktop(keys.clone(), vec![RELAY.into()]).await;
|
|
let p = Provider::open(endpoint, "t".into(), false, RELAY)
|
|
.await
|
|
.unwrap()
|
|
.ok()
|
|
.unwrap();
|
|
let unsigned =
|
|
nostr::EventBuilder::new(nostr::Kind::Custom(9), "hi").build(keys.public_key());
|
|
let event = p.sign_event(unsigned).await.unwrap();
|
|
assert!(event.verify().is_ok() && event.pubkey == keys.public_key());
|
|
assert_eq!(methods(&seen), ["describe", "sign_event"]);
|
|
}
|
|
|
|
/// The desktop's bare compact assertion reaches the relay with exactly
|
|
/// one `Bearer` scheme (the shape #2260's `federated_identity` returns).
|
|
#[tokio::test]
|
|
async fn the_desktop_assertion_is_sent_with_one_bearer_prefix() {
|
|
use axum::http::HeaderMap;
|
|
let seen: Arc<StdMutex<Vec<String>>> = Arc::default();
|
|
let log = seen.clone();
|
|
let app = axum::Router::new().fallback(move |headers: HeaderMap| {
|
|
let log = log.clone();
|
|
async move {
|
|
let value = headers
|
|
.get("nostr-federated-identity")
|
|
.and_then(|v| v.to_str().ok());
|
|
log.lock()
|
|
.unwrap()
|
|
.push(value.unwrap_or_default().to_owned());
|
|
axum::Json(serde_json::json!([]))
|
|
}
|
|
});
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let relay = format!("http://{}", listener.local_addr().unwrap());
|
|
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
|
let (endpoint, _) = desktop(nostr::Keys::generate(), vec![relay.clone()]).await;
|
|
let p = Provider::open(endpoint, "t".into(), false, &relay)
|
|
.await
|
|
.unwrap()
|
|
.ok()
|
|
.unwrap();
|
|
let credentials = crate::credentials::Credentials::from_desktop(p)
|
|
.await
|
|
.unwrap();
|
|
let client = crate::client::BuzzClient::new(relay, credentials).unwrap();
|
|
client
|
|
.query(&serde_json::json!({"kinds": [9]}))
|
|
.await
|
|
.unwrap();
|
|
let header = seen.lock().unwrap()[0].clone();
|
|
assert_eq!(header.matches("Bearer").count(), 1, "{header}");
|
|
assert_eq!(
|
|
header.strip_prefix("Bearer ").unwrap().split('.').count(),
|
|
3
|
|
);
|
|
}
|
|
}
|