Files
buzz/crates/buzz-cli/src/credentials.rs
Will Pfleger c8d736642e fix(cli): clearer desktop pairing errors, paired default relay, and auth desktop list (#9196)
Follow-ups to the CLI's Buzz desktop credential support, for a person
running the CLI with a desktop pairing.

- **The "no credentials" error names the relay.** It says which relay
the CLI tried and which relays this CLI is paired for, and tells you to
pass `--relay`. When a pairing exists for that relay but no running
desktop accepts it, it says so instead.
- **Default relay for a paired person.** With no `--relay`, no
`BUZZ_RELAY_URL`, no inherited agent credential and no explicit key, the
CLI uses the one relay that a running desktop still accepts this CLI's
pairing for, confirmed by `describe` on a same-user connection. Stored
pairings alone never count. With zero or several such relays, the
default stays `http://localhost:3000`. A desktop's refusal fails the
command with its message. When another paired desktop's check fails (its
same-user check times out or can't confirm the owner, or it stays rate
limited), the command fails rather than pick a relay, even if one other
relay was accepted, since that desktop may accept a relay of its own; a
timeout or exhausted throttle fails with a retryable error. `auth
desktop unpair` never runs discovery, and `auth desktop pair` never asks
other desktops for their pairings, so one desktop's refusal can't block
pairing another. Help text updated.
- **`buzz auth desktop list`.** Lists each pairing's instance, relays
and pubkey, with `accepted`, `not accepted` (revoked or no longer
bound), `not running`, `unconfirmed`, `rate limited` or `refused` (the
last three with an `error`), using the same check as the default relay.
Desktops are asked at once, without waiting out throttling. It never
prints tokens, needs no relay, prints `[]` with no pairings, and
`--format compact` drops the pubkey.
- **Retryable same-user timeout.** When a paired desktop's same-user
check times out and nothing else serves, the command now fails with a
retryable `unavailable` error (exit 2) instead of "no credentials" (exit
3).
- **Regression tests** for the NIP-42 AUTH event naming the relay with
its trailing slash, for skipping a paired desktop whose user can't be
confirmed, without sending it anything, and for relay selection never
asking desktops for pairings with an explicit key, `auth desktop pair`
or `auth desktop unpair`, or over an inherited agent relay.

---------

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-11-06 19:11:49 +00:00

861 lines
31 KiB
Rust

//! The CLI's credential chain: which identity it acts as, how it signs, and
//! which NIP-FI evidence it presents to the relay.
//!
//! Every relay request goes through [`Credentials`]. Signing and NIP-44 go
//! through a [`NostrSigner`], so a credential link never has to hand the CLI a
//! secret key. [`Credentials::evidence`] supplies the `Nostr-Federated-Identity`
//! assertion when an evidence source is configured and `None` otherwise, so one
//! code path serves relays with and without NIP-FI.
//!
//! [`resolve`] tries each link in order; the first configured link wins. An
//! explicit key (`--private-key` / `BUZZ_PRIVATE_KEY`) is the first link, so a
//! caller that chooses a key always gets that key.
use std::sync::Arc;
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use nostr::{Event, EventBuilder, JsonUtil, Keys, NostrSigner, PublicKey, Tag, UnsignedEvent};
use sha2::{Digest, Sha256};
use crate::error::CliError;
pub use crate::federated_identity::{
AdapterSession, FEDERATED_IDENTITY_ADAPTER_ENV, FEDERATED_IDENTITY_TOKEN_ENV,
};
/// Raw credential settings, from flags and the environment.
#[derive(Default)]
pub struct Settings {
/// `--private-key` / `BUZZ_PRIVATE_KEY`.
pub private_key: Option<String>,
/// `--auth-tag` / `BUZZ_AUTH_TAG` (NIP-OA owner attestation).
pub auth_tag: Option<String>,
/// `BUZZ_FEDERATED_IDENTITY_TOKEN` (NIP-FA adapter session token).
pub federated_identity_token: Option<String>,
/// `BUZZ_FEDERATED_IDENTITY_ADAPTER` (NIP-FA adapter base URL).
pub federated_identity_adapter: Option<String>,
}
impl Settings {
/// Read the evidence-source settings from the environment. Flags for the
/// session token are deliberately absent: command lines are visible to
/// other local users.
pub fn with_env_evidence(mut self) -> Self {
let non_empty = |name: &str| std::env::var(name).ok().filter(|v| !v.trim().is_empty());
self.federated_identity_token = non_empty(FEDERATED_IDENTITY_TOKEN_ENV);
self.federated_identity_adapter = non_empty(FEDERATED_IDENTITY_ADAPTER_ENV);
self
}
}
/// A verified NIP-OA auth tag and its canonical JSON for the `x-auth-tag` header.
pub struct AuthTag {
pub tag: Tag,
pub json: String,
}
/// The identity the CLI acts as for this process.
pub struct Credentials {
public_key: PublicKey,
signer: Arc<dyn NostrSigner>,
/// Key material, present only when the link holds the key locally.
local_keys: Option<Keys>,
auth_tag: Option<AuthTag>,
evidence: Option<AdapterSession>,
/// The Buzz desktop, when it is the link that holds the key.
#[cfg(unix)]
desktop: Option<Arc<crate::desktop_provider::Provider>>,
}
/// Walk the credential chain for `relay_url` and return the first link that
/// applies: the explicit key, then (on Unix) the Buzz desktop through an
/// inherited or paired token.
pub async fn resolve(settings: Settings, relay_url: &str) -> Result<Credentials, CliError> {
if let Some(credentials) = resolve_explicit(&settings, relay_url)? {
return Ok(credentials);
}
#[cfg(unix)]
if let Some(provider) = crate::desktop_provider::discover(relay_url).await? {
if let Some(warning) = ignored_headless_settings(&settings) {
eprintln!("{warning}");
}
return Credentials::from_desktop(provider).await;
}
#[cfg(unix)]
let paired = crate::desktop_provider::paired_relays();
#[cfg(not(unix))]
let paired = Vec::new();
Err(no_credentials(relay_url, &paired))
}
/// The error for a relay no link serves, naming it and the relays this CLI
/// is paired for, so a missing `--relay` is plain.
fn no_credentials(relay_url: &str, paired: &[String]) -> CliError {
let fix = "set BUZZ_PRIVATE_KEY, pass --private-key, or run `buzz auth desktop pair` \
with Buzz running";
#[cfg(unix)]
let this_one = crate::desktop_provider::provider_relay_url(relay_url)
.is_ok_and(|relay| paired.contains(&relay));
#[cfg(not(unix))]
let this_one = false;
CliError::Auth(if paired.is_empty() {
format!("no credentials for {relay_url}: {fix}")
} else if this_one {
format!(
"no credentials for {relay_url}: this CLI is paired for it, but no running Buzz \
desktop accepts the pairing; open Buzz and retry, or check `buzz auth desktop list`"
)
} else {
format!(
"no credentials for {relay_url}: this CLI is paired with Buzz only for {}; pass \
--relay (or set BUZZ_RELAY_URL) to use a pairing, or {fix}",
paired.join(", ")
)
})
}
/// The explicit-key link, with its headless evidence source. Without an
/// explicit key the evidence settings are not read: they belong to that key.
fn resolve_explicit(settings: &Settings, relay_url: &str) -> Result<Option<Credentials>, CliError> {
if settings.private_key.is_none() {
return Ok(None);
}
let evidence = evidence_source(settings, relay_url)?;
explicit_key(settings, evidence)
}
/// The warning for headless evidence settings a desktop link leaves unused.
#[cfg_attr(not(unix), allow(dead_code))]
fn ignored_headless_settings(settings: &Settings) -> Option<String> {
let ignored: Vec<&str> = [
(
settings.federated_identity_token.is_some(),
FEDERATED_IDENTITY_TOKEN_ENV,
),
(
settings.federated_identity_adapter.is_some(),
FEDERATED_IDENTITY_ADAPTER_ENV,
),
]
.into_iter()
.filter_map(|(set, name)| set.then_some(name))
.collect();
(!ignored.is_empty()).then(|| {
format!(
"warning: the Buzz desktop link supplies this identity's evidence; ignoring {} \
(set BUZZ_PRIVATE_KEY to use them)",
ignored.join(" and ")
)
})
}
/// Link 1: an explicit key from `--private-key` or `BUZZ_PRIVATE_KEY`.
fn explicit_key(
settings: &Settings,
evidence: Option<AdapterSession>,
) -> Result<Option<Credentials>, CliError> {
let Some(raw) = settings.private_key.as_deref() else {
return Ok(None);
};
let keys =
Keys::parse(raw).map_err(|e| CliError::Key(format!("invalid BUZZ_PRIVATE_KEY: {e}")))?;
let auth_tag = settings
.auth_tag
.as_deref()
.filter(|input| !input.is_empty())
.map(|input| parse_auth_tag(input, &keys.public_key()))
.transpose()?;
Ok(Some(Credentials {
auth_tag,
evidence,
..Credentials::from_keys(keys)
}))
}
/// The headless NIP-FI evidence source: a NIP-FA adapter session. Both
/// settings are required together; one without the other is a configuration
/// error rather than a silent fallback to no evidence.
fn evidence_source(
settings: &Settings,
relay_url: &str,
) -> Result<Option<AdapterSession>, CliError> {
match (
settings.federated_identity_token.as_deref(),
settings.federated_identity_adapter.as_deref(),
) {
(None, None) => Ok(None),
(Some(token), Some(adapter)) => AdapterSession::new(adapter, token, relay_url).map(Some),
(Some(_), None) => Err(CliError::Auth(format!(
"{FEDERATED_IDENTITY_TOKEN_ENV} is set but {FEDERATED_IDENTITY_ADAPTER_ENV} is not"
))),
(None, Some(_)) => Err(CliError::Auth(format!(
"{FEDERATED_IDENTITY_ADAPTER_ENV} is set but {FEDERATED_IDENTITY_TOKEN_ENV} is not"
))),
}
}
/// Parse and verify a NIP-OA auth tag for `public_key`.
///
/// `BUZZ_AUTH_TAG` is hand-authored configuration, so the unquoted raw
/// shorthand `[auth,hex,,hex]` is normalized to JSON here — at this input
/// edge only. The SDK grammar and the `x-auth-tag` wire format stay strict
/// JSON; all validation and signature verification happen on the strict path.
fn parse_auth_tag(input: &str, public_key: &PublicKey) -> Result<AuthTag, CliError> {
let json = crate::normalize_auth_tag_input(input);
let tag = buzz_sdk::nip_oa::parse_auth_tag(&json)
.map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG is malformed: {e}")))?;
buzz_sdk::nip_oa::verify_auth_tag(&json, public_key).map_err(|e| {
CliError::Auth(format!(
"BUZZ_AUTH_TAG verification failed for pubkey {}: {e}",
public_key.to_hex()
))
})?;
// Canonical wire form derives from the parsed-and-verified tag
// (same shape as buzz-acp's RestClient), never from raw input.
let canonical = serde_json::to_string(tag.as_slice())
.map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG serialization failed: {e}")))?;
Ok(AuthTag {
tag,
json: canonical,
})
}
/// The credentials' signer for NIP-42, keeping a desktop signing failure as
/// a [`CliError`]: protocol code carries only its text, and a throttled
/// desktop must stay retryable.
#[derive(Debug)]
pub struct CapturingSigner {
signer: Arc<dyn NostrSigner>,
#[cfg(unix)]
desktop: Option<Arc<crate::desktop_provider::Provider>>,
failure: std::sync::Mutex<Option<(CliError, Option<std::time::Duration>)>>,
}
impl CapturingSigner {
pub fn new(credentials: &Credentials) -> Self {
Self {
signer: credentials.signer.clone(),
#[cfg(unix)]
desktop: credentials.desktop.clone(),
failure: std::sync::Mutex::new(None),
}
}
/// The desktop signing failure, if one happened, with how long to wait
/// before a fresh sign-in when the desktop throttled one.
pub fn take_failure(&self) -> Option<(CliError, Option<std::time::Duration>)> {
self.failure.lock().ok()?.take()
}
}
impl NostrSigner for CapturingSigner {
fn backend(&self) -> nostr::signer::SignerBackend<'_> {
self.signer.backend()
}
fn get_public_key(
&self,
) -> nostr::util::BoxedFuture<'_, Result<PublicKey, nostr::signer::SignerError>> {
self.signer.get_public_key()
}
fn sign_event(
&self,
unsigned: UnsignedEvent,
) -> nostr::util::BoxedFuture<'_, Result<Event, nostr::signer::SignerError>> {
#[cfg(unix)]
if let Some(provider) = &self.desktop {
return Box::pin(async move {
provider.sign_or_wait(unsigned).await.map_err(|e| {
let text = nostr::signer::SignerError::from(e.0.to_string());
if let Ok(mut slot) = self.failure.lock() {
slot.get_or_insert(e);
}
text
})
});
}
self.signer.sign_event(unsigned)
}
fn nip04_encrypt<'b>(
&'b self,
peer: &'b PublicKey,
content: &'b str,
) -> nostr::util::BoxedFuture<'b, Result<String, nostr::signer::SignerError>> {
self.signer.nip04_encrypt(peer, content)
}
fn nip04_decrypt<'b>(
&'b self,
peer: &'b PublicKey,
payload: &'b str,
) -> nostr::util::BoxedFuture<'b, Result<String, nostr::signer::SignerError>> {
self.signer.nip04_decrypt(peer, payload)
}
fn nip44_encrypt<'b>(
&'b self,
peer: &'b PublicKey,
content: &'b str,
) -> nostr::util::BoxedFuture<'b, Result<String, nostr::signer::SignerError>> {
self.signer.nip44_encrypt(peer, content)
}
fn nip44_decrypt<'b>(
&'b self,
peer: &'b PublicKey,
payload: &'b str,
) -> nostr::util::BoxedFuture<'b, Result<String, nostr::signer::SignerError>> {
self.signer.nip44_decrypt(peer, payload)
}
}
impl Credentials {
/// Credentials for an in-memory key with no auth tag and no evidence source.
pub fn from_keys(keys: Keys) -> Self {
Self {
public_key: keys.public_key(),
signer: Arc::new(keys.clone()),
local_keys: Some(keys),
auth_tag: None,
evidence: None,
#[cfg(unix)]
desktop: None,
}
}
/// Credentials signing through `signer` alone, for tests.
#[cfg(test)]
pub(crate) fn from_signer(signer: Arc<dyn NostrSigner>, public_key: PublicKey) -> Self {
Self {
public_key,
signer,
local_keys: None,
auth_tag: None,
evidence: None,
#[cfg(unix)]
desktop: None,
}
}
/// Credentials backed by the Buzz desktop: it signs, encrypts and supplies
/// NIP-FI evidence; the private key never reaches this process.
#[cfg(unix)]
pub(crate) async fn from_desktop(
provider: Arc<crate::desktop_provider::Provider>,
) -> Result<Self, CliError> {
let public_key = provider.public_key();
let auth_tag = provider
.auth_tag()
.await?
.map(|json| parse_auth_tag(&json, &public_key))
.transpose()?;
Ok(Self {
public_key,
signer: provider.clone(),
local_keys: None,
auth_tag,
evidence: None,
desktop: Some(provider),
})
}
/// Attach a verified NIP-OA auth tag.
#[cfg(test)]
pub fn with_auth_tag(mut self, tag: Tag, json: String) -> Self {
self.auth_tag = Some(AuthTag { tag, json });
self
}
/// The local keys behind a [`Self::from_keys`] credential.
#[cfg(test)]
pub fn test_keys(&self) -> Keys {
self.local_keys
.clone()
.expect("test credentials hold local keys")
}
/// Attach a NIP-FA evidence source.
#[cfg(test)]
pub fn with_evidence(mut self, evidence: AdapterSession) -> Self {
self.evidence = Some(evidence);
self
}
/// The public key this process acts as.
pub fn public_key(&self) -> PublicKey {
self.public_key
}
/// The NIP-OA auth tag, if configured.
pub fn auth_tag(&self) -> Option<&AuthTag> {
self.auth_tag.as_ref()
}
/// The NIP-AE engram `d` tag for `slug` in the conversation with `peer`:
/// HMAC-SHA256 over the NIP-44 conversation key, so it needs the key
/// itself rather than a signature (`docs/nips/NIP-AE.md`).
pub async fn engram_d_tag(&self, peer: &PublicKey, slug: &str) -> Result<String, CliError> {
#[cfg(unix)]
if let Some(provider) = &self.desktop {
return provider.engram_d_tag(peer, slug).await;
}
let keys = self.local_keys.as_ref().ok_or_else(|| {
CliError::Auth(
"buzz mem needs a local key: set BUZZ_PRIVATE_KEY or pass --private-key".into(),
)
})?;
let k_c = buzz_core::engram::conversation_key(keys.secret_key(), peer);
Ok(buzz_core::engram::d_tag(&k_c, slug))
}
/// A stable, anonymous, relay-scoped id for the GIF provider (see
/// [`customer_id`]). A link without local key material gets a random
/// per-process id instead, as Buzz Desktop does per install.
pub fn relay_scoped_customer_id(&self, relay_url: &str) -> String {
match self.local_keys.as_ref() {
Some(keys) => customer_id(keys.secret_key().as_secret_bytes(), relay_url),
None => {
static PROCESS_ID: std::sync::OnceLock<String> = std::sync::OnceLock::new();
PROCESS_ID
.get_or_init(|| uuid::Uuid::new_v4().simple().to_string())
.clone()
}
}
}
/// Whether the Buzz desktop is the link that holds the key.
pub fn is_desktop(&self) -> bool {
#[cfg(unix)]
return self.desktop.is_some();
#[cfg(not(unix))]
false
}
/// Whether a NIP-FI evidence source is configured.
pub fn has_evidence_source(&self) -> bool {
#[cfg(unix)]
if self.desktop.is_some() {
return true;
}
self.evidence.is_some()
}
/// The NIP-FI assertion for the relay the credentials were resolved
/// for, or `None` when no evidence source is configured.
pub async fn evidence(&self) -> Result<Option<String>, CliError> {
#[cfg(unix)]
if let Some(provider) = &self.desktop {
return provider.federated_identity().await;
}
match &self.evidence {
Some(source) => {
let keys = self.local_keys.as_ref().ok_or_else(|| {
CliError::Auth("a headless evidence source needs a local key".into())
})?;
source.assertion(keys).await
}
None => Ok(None),
}
}
/// Sign `builder` as this identity. Callers own the tags; auth-tag
/// injection is [`crate::client::BuzzClient::sign_event`]'s job.
pub async fn sign(&self, builder: EventBuilder) -> Result<Event, CliError> {
self.sign_unsigned(builder.build(self.public_key)).await
}
/// Sign `unsigned` as this identity. A desktop failure keeps its own
/// error, so a throttled desktop stays retryable.
pub async fn sign_unsigned(&self, unsigned: UnsignedEvent) -> Result<Event, CliError> {
#[cfg(unix)]
if let Some(provider) = &self.desktop {
return provider.sign(unsigned).await;
}
let event = self
.signer
.sign_event(unsigned)
.await
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
if event.pubkey != self.public_key || event.verify().is_err() {
return Err(CliError::Auth(
"signer returned an event that is not validly signed by this identity".into(),
));
}
Ok(event)
}
/// NIP-44 encrypt `plaintext` to `peer` as this identity.
pub async fn nip44_encrypt(
&self,
peer: &PublicKey,
plaintext: &str,
) -> Result<String, CliError> {
#[cfg(unix)]
if let Some(provider) = &self.desktop {
return provider.nip44_encrypt(peer, plaintext).await;
}
self.signer
.nip44_encrypt(peer, plaintext)
.await
.map_err(|e| CliError::Other(format!("NIP-44 encryption failed: {e}")))
}
/// NIP-44 decrypt `payload` from `peer` as this identity.
/// NIP-44 decrypt `payload` from `peer`. `Ok(None)` means the payload is
/// not valid ciphertext for this key; `Err` means the signer itself failed.
/// A local key decides that itself, and the desktop reports it as
/// `invalid_request`; any other signer's failure is `Err`, since it cannot
/// be told apart from an unavailable credential.
pub async fn nip44_decrypt(
&self,
peer: &PublicKey,
payload: &str,
) -> Result<Option<String>, CliError> {
if let Some(keys) = &self.local_keys {
return Ok(nostr::nips::nip44::decrypt(keys.secret_key(), peer, payload).ok());
}
#[cfg(unix)]
if let Some(provider) = &self.desktop {
return provider.nip44_decrypt(peer, payload).await;
}
self.signer
.nip44_decrypt(peer, payload)
.await
.map(Some)
.map_err(|e| CliError::Auth(format!("NIP-44 decryption failed: {e}")))
}
/// Sign a NIP-98 HTTP auth event (kind:27235) for `method` + `url` and
/// return it base64-encoded, ready for a `Nostr <value>` header.
///
/// The event carries the `u`, `method` and (with a body) `payload` tags,
/// plus a nonce so rapid identical requests don't trip replay guards.
pub async fn nip98(
&self,
method: &str,
url: &str,
body: Option<&[u8]>,
) -> Result<String, CliError> {
let event = self
.sign(crate::federated_identity::nip98_event(method, url, body)?)
.await?;
Ok(B64.encode(event.as_json().as_bytes()))
}
}
/// Derive a stable, relay-scoped anonymous `customer_id` from secret key material.
///
/// KLIPY requires a per-installation identifier that is stable and anonymous.
/// Using SHA-256 of the *public* key would be stable but NOT anonymous — the
/// input is public, so the ID is computable by any observer, and the same value
/// would appear across all relays (cross-relay linkability).
///
/// Instead, we domain-separate with the relay URL and sign with the *secret* key:
/// `SHA-256(secret_key_bytes || '\0' || relay_url_bytes)`
/// This is:
/// - **stable**: deterministic given the same keypair + relay.
/// - **relay-scoped**: different relay → different ID, no cross-relay correlation.
/// - **not computable from public data**: requires secret key material.
/// - **stateless**: no file I/O, no storage.
///
/// The first 16 bytes (32 hex chars) give 128 bits of uniqueness, ample for
/// KLIPY's per-installation needs.
fn customer_id(secret_key_bytes: &[u8], relay_url: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(secret_key_bytes);
hasher.update(b"\0"); // domain separator
hasher.update(relay_url.as_bytes());
let hash = hasher.finalize();
hex::encode(&hash[..16]) // 16 bytes → 32 hex chars
}
#[cfg(test)]
mod tests {
use super::*;
// -----------------------------------------------------------------------
// customer_id
// -----------------------------------------------------------------------
#[test]
fn customer_id_is_32_hex_chars_and_stable() {
let sk = [0xab_u8; 32];
let id = customer_id(&sk, "https://relay.example");
assert_eq!(id.len(), 32);
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(id, customer_id(&sk, "https://relay.example"));
}
#[test]
fn customer_id_is_relay_scoped() {
let sk = [0xcd_u8; 32];
let id_a = customer_id(&sk, "https://relay-a.example");
let id_b = customer_id(&sk, "https://relay-b.example");
assert_ne!(
id_a, id_b,
"same key, different relay → different customer_id"
);
}
#[test]
fn customer_id_differs_for_different_keys() {
let id_a = customer_id(&[0xaa_u8; 32], "https://relay.example");
let id_b = customer_id(&[0xbb_u8; 32], "https://relay.example");
assert_ne!(id_a, id_b);
}
#[test]
fn customer_id_not_equal_to_pubkey_hash() {
// The customer_id must NOT be derivable from the public key alone.
let sk = [0xde_u8; 32];
// What the old pubkey-hash approach would have produced (approximately):
let naive_hash = hex::encode(&Sha256::digest(hex::encode(sk).as_bytes())[..16]);
let actual = customer_id(&sk, "https://relay.example");
assert_ne!(
actual, naive_hash,
"customer_id must not equal SHA-256(pubkey_hex)[..16]"
);
}
fn explicit(settings: Settings) -> Result<Credentials, CliError> {
resolve_explicit(&settings, "https://relay.example").map(|c| c.expect("explicit key"))
}
fn settings(key: Option<&str>, token: Option<&str>, adapter: Option<&str>) -> Settings {
Settings {
private_key: key.map(str::to_owned),
auth_tag: None,
federated_identity_token: token.map(str::to_owned),
federated_identity_adapter: adapter.map(str::to_owned),
}
}
#[test]
fn explicit_key_is_the_first_link() {
let keys = Keys::generate();
let nsec = keys.secret_key().to_secret_hex();
let credentials = explicit(settings(Some(&nsec), None, None)).unwrap();
assert_eq!(credentials.public_key(), keys.public_key());
assert!(!credentials.has_evidence_source());
}
#[test]
fn no_link_is_an_auth_error_naming_the_settings() {
assert!(
resolve_explicit(&settings(None, None, None), "https://relay.example")
.unwrap()
.is_none()
);
}
/// Headless evidence settings belong to the explicit key: without one
/// they are passed over (and named in a warning), not an error.
#[test]
fn headless_settings_without_a_key_are_passed_over_and_named() {
for (token, adapter) in [(Some("t"), None), (Some("t"), Some("https://a.example"))] {
let unused = settings(None, token, adapter);
assert!(resolve_explicit(&unused, "https://relay.example")
.unwrap()
.is_none());
let warning = ignored_headless_settings(&unused).unwrap();
assert!(warning.contains(FEDERATED_IDENTITY_TOKEN_ENV), "{warning}");
assert_eq!(
warning.contains(FEDERATED_IDENTITY_ADAPTER_ENV),
adapter.is_some(),
"{warning}"
);
}
assert!(ignored_headless_settings(&settings(None, None, None)).is_none());
}
/// The evidence source is bound to the relay when credentials are
/// resolved, so a relay NIP-FA cannot serve fails then, not mid-command.
#[test]
fn the_evidence_source_binds_its_relay_at_resolution() {
let nsec = Keys::generate().secret_key().to_secret_hex();
let headless = settings(Some(&nsec), Some("t"), Some("https://adapter.example"));
assert!(resolve_explicit(&headless, "https://relay.example").is_ok());
let err = resolve_explicit(&headless, "https://relay.example:80")
.err()
.unwrap();
assert!(matches!(err, CliError::Usage(_)), "{err:?}");
}
#[test]
fn evidence_settings_must_come_together() {
let nsec = Keys::generate().secret_key().to_secret_hex();
for (token, adapter, missing) in [
(Some("t"), None, FEDERATED_IDENTITY_ADAPTER_ENV),
(
None,
Some("https://adapter.example"),
FEDERATED_IDENTITY_TOKEN_ENV,
),
] {
let err = explicit(settings(Some(&nsec), token, adapter))
.err()
.unwrap();
assert!(
matches!(&err, CliError::Auth(m) if m.contains(missing)),
"{token:?} {adapter:?}: {err}"
);
}
let credentials = explicit(settings(
Some(&nsec),
Some("t"),
Some("https://adapter.example"),
))
.unwrap();
assert!(credentials.has_evidence_source());
}
#[tokio::test]
async fn key_derived_operations_match_the_protocol_definitions() {
let agent = Keys::generate();
let owner = Keys::generate();
let credentials = Credentials::from_keys(agent.clone());
// NIP-AE: either side of the pair derives the same d tag.
let owner_side = buzz_core::engram::d_tag(
&buzz_core::engram::conversation_key(owner.secret_key(), &agent.public_key()),
"core",
);
assert_eq!(
credentials
.engram_d_tag(&owner.public_key(), "core")
.await
.unwrap(),
owner_side
);
assert_eq!(
credentials.relay_scoped_customer_id("https://a.example"),
customer_id(agent.secret_key().as_secret_bytes(), "https://a.example")
);
}
#[tokio::test]
async fn sign_rejects_events_from_another_key() {
#[derive(Debug)]
struct WrongKey(Keys);
impl NostrSigner for WrongKey {
fn backend(&self) -> nostr::signer::SignerBackend<'_> {
nostr::signer::SignerBackend::Custom("test".into())
}
fn get_public_key(
&self,
) -> nostr::util::BoxedFuture<'_, Result<PublicKey, nostr::signer::SignerError>>
{
Box::pin(async move { Ok(self.0.public_key()) })
}
fn sign_event(
&self,
unsigned: nostr::UnsignedEvent,
) -> nostr::util::BoxedFuture<'_, Result<Event, nostr::signer::SignerError>>
{
let other = Keys::generate();
Box::pin(async move {
EventBuilder::new(unsigned.kind, unsigned.content)
.sign_with_keys(&other)
.map_err(nostr::signer::SignerError::backend)
})
}
fn nip04_encrypt<'a>(
&'a self,
_: &'a PublicKey,
_: &'a str,
) -> nostr::util::BoxedFuture<'a, Result<String, nostr::signer::SignerError>>
{
Box::pin(async { Err(nostr::signer::SignerError::from("unsupported")) })
}
fn nip04_decrypt<'a>(
&'a self,
_: &'a PublicKey,
_: &'a str,
) -> nostr::util::BoxedFuture<'a, Result<String, nostr::signer::SignerError>>
{
Box::pin(async { Err(nostr::signer::SignerError::from("unsupported")) })
}
fn nip44_encrypt<'a>(
&'a self,
_: &'a PublicKey,
_: &'a str,
) -> nostr::util::BoxedFuture<'a, Result<String, nostr::signer::SignerError>>
{
Box::pin(async { Err(nostr::signer::SignerError::from("unsupported")) })
}
fn nip44_decrypt<'a>(
&'a self,
_: &'a PublicKey,
_: &'a str,
) -> nostr::util::BoxedFuture<'a, Result<String, nostr::signer::SignerError>>
{
Box::pin(async { Err(nostr::signer::SignerError::from("unsupported")) })
}
}
let keys = Keys::generate();
let credentials = Credentials {
public_key: keys.public_key(),
signer: Arc::new(WrongKey(keys)),
local_keys: None,
auth_tag: None,
evidence: None,
#[cfg(unix)]
desktop: None,
};
let err = credentials
.sign(EventBuilder::text_note("hi"))
.await
.err()
.unwrap();
assert!(matches!(err, CliError::Auth(_)), "{err}");
// Without local key material: key-derived operations degrade or refuse.
let peer = Keys::generate().public_key();
assert!(credentials.engram_d_tag(&peer, "core").await.is_err());
let relay = "https://relay.example";
let id = credentials.relay_scoped_customer_id(relay);
assert_eq!(
id,
credentials.relay_scoped_customer_id(relay),
"one id per process"
);
assert_ne!(
id,
customer_id(&[0; 32], relay),
"no local key: a random id, not a key derivation"
);
assert_eq!(id.len(), 32);
}
#[test]
fn no_credentials_names_the_relay_and_the_paired_ones() {
let message = |relay: &str, paired: &[&str]| {
let paired: Vec<String> = paired.iter().map(|r| (*r).to_owned()).collect();
match no_credentials(relay, &paired) {
CliError::Auth(m) => m,
other => panic!("{other:?}"),
}
};
let none = message("http://localhost:3000", &[]);
assert!(none.starts_with("no credentials for http://localhost:3000: set BUZZ_PRIVATE_KEY"));
let elsewhere = message("http://localhost:3000", &["wss://team.example/"]);
assert!(
elsewhere.contains("paired with Buzz only for wss://team.example/"),
"{elsewhere}"
);
assert!(elsewhere.contains("pass --relay"), "{elsewhere}");
#[cfg(unix)]
{
let here = message("https://team.example", &["wss://team.example/"]);
assert!(
here.contains("no running Buzz desktop accepts the pairing"),
"{here}"
);
}
}
}