feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
//! NIP-AM: Agent Turn Metric — payload type and encrypt/decrypt helpers.
|
||||
//!
|
||||
//! One `kind:44200` event is published per completed agent turn. Its content
|
||||
//! is a NIP-44 v2 ciphertext (agent key → owner pubkey) that decodes to an
|
||||
//! [`AgentTurnMetricPayload`] JSON object.
|
||||
//!
|
||||
//! See `docs/nips/NIP-AM.md` for the full specification.
|
||||
|
||||
use nostr::{Event, Keys, PublicKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::observer::{decrypt_observer_payload, encrypt_observer_payload, ObserverPayloadError};
|
||||
|
||||
// Re-export for callers that only need the error type.
|
||||
pub use crate::observer::ObserverPayloadError as AgentTurnMetricError;
|
||||
|
||||
/// Token-usage counters for a single measurement window (one turn or cumulative).
|
||||
///
|
||||
/// All token fields are nullable — `None` means the harness did not report them,
|
||||
/// NOT that the count was zero. See NIP-AM §Numeric validity and token semantics.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TokenCounts {
|
||||
/// Input tokens (inclusive of cache reads/writes where applicable).
|
||||
pub input_tokens: Option<u64>,
|
||||
|
||||
/// Output tokens.
|
||||
pub output_tokens: Option<u64>,
|
||||
|
||||
/// Provider-reported total — NOT derived by summing input + output.
|
||||
/// `None` when the provider did not report a total.
|
||||
pub total_tokens: Option<u64>,
|
||||
|
||||
/// Estimated cost in USD. Must be finite and non-negative when present.
|
||||
pub cost_usd: Option<f64>,
|
||||
|
||||
/// Informational: cache-read tokens included in `input_tokens`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_tokens: Option<u64>,
|
||||
|
||||
/// Informational: cache-write tokens included in `input_tokens`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
/// Why a turn ended.
|
||||
///
|
||||
/// NIP-AM: consumers MUST treat unrecognized `stopReason` values as `Unknown`
|
||||
/// and keep the token counts valid. Custom deserialization maps any unrecognized
|
||||
/// string to `Unknown` instead of failing the whole payload.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StopReason {
|
||||
/// Model reached a natural end-of-turn.
|
||||
EndTurn,
|
||||
/// Model hit the max-tokens limit.
|
||||
MaxTokens,
|
||||
/// Turn was cancelled by the owner or harness.
|
||||
Cancelled,
|
||||
/// Turn ended with an error.
|
||||
Error,
|
||||
/// Stop reason is unknown or unrecognized.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for StopReason {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Ok(match s.as_str() {
|
||||
"end_turn" => StopReason::EndTurn,
|
||||
"max_tokens" => StopReason::MaxTokens,
|
||||
"cancelled" => StopReason::Cancelled,
|
||||
"error" => StopReason::Error,
|
||||
"unknown" => StopReason::Unknown,
|
||||
_ => StopReason::Unknown,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypted payload of a `kind:44200` Agent Turn Metric event.
|
||||
///
|
||||
/// `harness` and `timestamp` are REQUIRED. All other fields are optional or
|
||||
/// nullable unless constrained by the NIP (e.g. `session_id` + `turn_seq`
|
||||
/// are required whenever `cumulative` is present).
|
||||
///
|
||||
/// Consumers MUST ignore unknown fields (forward compatibility).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentTurnMetricPayload {
|
||||
/// Harness identifier (e.g. `"goose"`, `"buzz-agent"`). REQUIRED.
|
||||
pub harness: String,
|
||||
|
||||
/// Model identifier as reported by the harness, or `None` if unknown.
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Channel UUID the turn served, encrypted inside the payload.
|
||||
pub channel_id: Option<String>,
|
||||
|
||||
/// Session identifier. REQUIRED when `cumulative` is present.
|
||||
pub session_id: Option<String>,
|
||||
|
||||
/// Turn identifier (harness-internal).
|
||||
pub turn_id: Option<String>,
|
||||
|
||||
/// Monotonically increasing per-session sequence number.
|
||||
/// REQUIRED when `cumulative` is present; strictly increasing within one
|
||||
/// `session_id`. A publisher restart that loses the counter MUST start a
|
||||
/// new `session_id`.
|
||||
pub turn_seq: Option<u64>,
|
||||
|
||||
/// RFC 3339 timestamp (end-of-turn). REQUIRED.
|
||||
pub timestamp: String,
|
||||
|
||||
/// Usage for this turn (computed delta). Null fields mean not reported.
|
||||
pub turn: Option<TokenCounts>,
|
||||
|
||||
/// Session-cumulative usage as reported at end of this turn.
|
||||
pub cumulative: Option<TokenCounts>,
|
||||
|
||||
/// `false` when the publisher could not observe the previous cumulative
|
||||
/// baseline (e.g. harness restart mid-session), making `turn` unreliable.
|
||||
/// Defaults to `true` on the wire when not explicitly set.
|
||||
#[serde(default = "default_delta_reliable")]
|
||||
pub delta_reliable: bool,
|
||||
|
||||
/// Why the turn ended. Unrecognized values MUST be treated as `Unknown`.
|
||||
pub stop_reason: Option<StopReason>,
|
||||
}
|
||||
|
||||
fn default_delta_reliable() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl AgentTurnMetricPayload {
|
||||
/// Validate numeric constraints from NIP-AM §Numeric validity.
|
||||
///
|
||||
/// Returns `Err` when any `cost_usd` field (in `turn` or `cumulative`) is
|
||||
/// present but negative or non-finite (NaN or infinity). Token counts are
|
||||
/// typed as `Option<u64>` and therefore cannot be negative by construction.
|
||||
pub fn validate(&self) -> Result<(), ObserverPayloadError> {
|
||||
fn check_cost(cost: Option<f64>, field: &str) -> Result<(), ObserverPayloadError> {
|
||||
if let Some(c) = cost {
|
||||
if !c.is_finite() || c < 0.0 {
|
||||
return Err(ObserverPayloadError::InvalidPayload(format!(
|
||||
"{field} must be finite and non-negative (got {c})"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
if let Some(t) = &self.turn {
|
||||
check_cost(t.cost_usd, "turn.costUsd")?;
|
||||
}
|
||||
if let Some(c) = &self.cumulative {
|
||||
check_cost(c.cost_usd, "cumulative.costUsd")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt an [`AgentTurnMetricPayload`] into a NIP-44 v2 ciphertext string
|
||||
/// using the agent's key pair and the owner's public key.
|
||||
///
|
||||
/// Returns `Err(ObserverPayloadError::InvalidPayload)` if any `cost_usd` field
|
||||
/// is negative or non-finite (NaN/inf), in accordance with NIP-AM §Numeric
|
||||
/// validity.
|
||||
///
|
||||
/// This is the content field of a `kind:44200` event.
|
||||
pub fn encrypt_agent_turn_metric(
|
||||
agent_keys: &Keys,
|
||||
owner_pubkey: &PublicKey,
|
||||
payload: &AgentTurnMetricPayload,
|
||||
) -> Result<String, ObserverPayloadError> {
|
||||
payload.validate()?;
|
||||
encrypt_observer_payload(agent_keys, owner_pubkey, payload)
|
||||
}
|
||||
|
||||
/// Decrypt and deserialize an [`AgentTurnMetricPayload`] from a `kind:44200` event.
|
||||
///
|
||||
/// `recipient_keys` is the owner's key pair.
|
||||
///
|
||||
/// Returns `Err(ObserverPayloadError::InvalidPayload)` if the decrypted payload
|
||||
/// fails numeric validation (e.g. negative or non-finite `costUsd`), mirroring
|
||||
/// the fail-closed contract of [`encrypt_agent_turn_metric`].
|
||||
pub fn decrypt_agent_turn_metric(
|
||||
recipient_keys: &Keys,
|
||||
event: &Event,
|
||||
) -> Result<AgentTurnMetricPayload, ObserverPayloadError> {
|
||||
let payload: AgentTurnMetricPayload = decrypt_observer_payload(recipient_keys, event)?;
|
||||
payload.validate()?;
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, Kind, Tag};
|
||||
|
||||
fn sample_payload() -> AgentTurnMetricPayload {
|
||||
AgentTurnMetricPayload {
|
||||
harness: "goose".to_string(),
|
||||
model: Some("claude-sonnet-4-5".to_string()),
|
||||
channel_id: Some("12345678-1234-1234-1234-123456789abc".to_string()),
|
||||
session_id: Some("sess-abc".to_string()),
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
turn_seq: Some(1),
|
||||
timestamp: "2026-07-01T20:11:03.213Z".to_string(),
|
||||
turn: Some(TokenCounts {
|
||||
input_tokens: Some(1234),
|
||||
output_tokens: Some(567),
|
||||
total_tokens: Some(1801),
|
||||
cost_usd: Some(0.0123),
|
||||
cache_read_tokens: None,
|
||||
cache_write_tokens: None,
|
||||
}),
|
||||
cumulative: Some(TokenCounts {
|
||||
input_tokens: Some(45210),
|
||||
output_tokens: Some(9876),
|
||||
total_tokens: Some(55086),
|
||||
cost_usd: Some(0.41),
|
||||
cache_read_tokens: None,
|
||||
cache_write_tokens: None,
|
||||
}),
|
||||
delta_reliable: true,
|
||||
stop_reason: Some(StopReason::EndTurn),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_encrypt_decrypt() {
|
||||
let agent_keys = Keys::generate();
|
||||
let owner_keys = Keys::generate();
|
||||
|
||||
let payload = sample_payload();
|
||||
let ciphertext = encrypt_agent_turn_metric(&agent_keys, &owner_keys.public_key(), &payload)
|
||||
.expect("encrypt");
|
||||
|
||||
// Build a minimal event envelope so decrypt_observer_payload can use event.pubkey.
|
||||
let event = EventBuilder::new(Kind::Custom(44200), ciphertext)
|
||||
.tags([
|
||||
Tag::parse(["p", &owner_keys.public_key().to_hex()]).unwrap(),
|
||||
Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.expect("sign");
|
||||
|
||||
let decoded = decrypt_agent_turn_metric(&owner_keys, &event).expect("decrypt");
|
||||
|
||||
assert_eq!(decoded, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_key_decrypt_fails() {
|
||||
let agent_keys = Keys::generate();
|
||||
let owner_keys = Keys::generate();
|
||||
let wrong_keys = Keys::generate();
|
||||
|
||||
let payload = sample_payload();
|
||||
let ciphertext = encrypt_agent_turn_metric(&agent_keys, &owner_keys.public_key(), &payload)
|
||||
.expect("encrypt");
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(44200), ciphertext)
|
||||
.tags([
|
||||
Tag::parse(["p", &owner_keys.public_key().to_hex()]).unwrap(),
|
||||
Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.expect("sign");
|
||||
|
||||
let result = decrypt_agent_turn_metric(&wrong_keys, &event);
|
||||
assert!(result.is_err(), "expected decrypt error with wrong key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delta_reliable_defaults_to_true_when_absent() {
|
||||
let json = r#"{"harness":"goose","timestamp":"2026-07-01T20:11:03Z"}"#;
|
||||
let payload: AgentTurnMetricPayload = serde_json::from_str(json).expect("parse");
|
||||
assert!(
|
||||
payload.delta_reliable,
|
||||
"deltaReliable should default to true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_reason_round_trips() {
|
||||
for (variant, json_val) in [
|
||||
(StopReason::EndTurn, "\"end_turn\""),
|
||||
(StopReason::MaxTokens, "\"max_tokens\""),
|
||||
(StopReason::Cancelled, "\"cancelled\""),
|
||||
(StopReason::Error, "\"error\""),
|
||||
(StopReason::Unknown, "\"unknown\""),
|
||||
] {
|
||||
let serialized = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(serialized, json_val);
|
||||
let deserialized: StopReason = serde_json::from_str(json_val).unwrap();
|
||||
assert_eq!(deserialized, variant);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_token_counts_round_trip() {
|
||||
// Verify that None fields serialize to `null` (not absent), as required
|
||||
// by the NIP — consumers must distinguish "not reported" from "zero".
|
||||
let counts = TokenCounts {
|
||||
input_tokens: None,
|
||||
output_tokens: None,
|
||||
total_tokens: None,
|
||||
cost_usd: None,
|
||||
cache_read_tokens: None,
|
||||
cache_write_tokens: None,
|
||||
};
|
||||
let json = serde_json::to_string(&counts).unwrap();
|
||||
// cache_* are skip_serializing_if = None, others serialize as null
|
||||
assert!(json.contains("\"inputTokens\":null"));
|
||||
assert!(json.contains("\"outputTokens\":null"));
|
||||
let back: TokenCounts = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, counts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_stop_reason_maps_to_unknown_not_error() {
|
||||
// NIP-AM: consumers MUST treat unrecognized stopReason values as Unknown;
|
||||
// the token counts remain valid and the whole payload must not be rejected.
|
||||
let json = r#"{
|
||||
"harness": "goose",
|
||||
"timestamp": "2026-07-01T20:11:03Z",
|
||||
"stopReason": "tool_limit",
|
||||
"turn": {
|
||||
"inputTokens": 1234,
|
||||
"outputTokens": 567,
|
||||
"totalTokens": 1801,
|
||||
"costUsd": null
|
||||
}
|
||||
}"#;
|
||||
let payload: AgentTurnMetricPayload =
|
||||
serde_json::from_str(json).expect("payload with future stopReason must parse");
|
||||
assert_eq!(
|
||||
payload.stop_reason,
|
||||
Some(StopReason::Unknown),
|
||||
"unrecognized stopReason must map to Unknown"
|
||||
);
|
||||
// Token counts must be preserved.
|
||||
let turn = payload.turn.expect("turn must be present");
|
||||
assert_eq!(turn.input_tokens, Some(1234));
|
||||
assert_eq!(turn.output_tokens, Some(567));
|
||||
assert_eq!(turn.total_tokens, Some(1801));
|
||||
}
|
||||
|
||||
// ── validate() — negative / non-finite costUsd ─────────────────────────
|
||||
|
||||
fn make_payload_with_turn_cost(cost: Option<f64>) -> AgentTurnMetricPayload {
|
||||
AgentTurnMetricPayload {
|
||||
harness: "test".to_string(),
|
||||
model: None,
|
||||
channel_id: None,
|
||||
session_id: None,
|
||||
turn_id: None,
|
||||
turn_seq: None,
|
||||
timestamp: "2026-07-01T00:00:00Z".to_string(),
|
||||
turn: Some(TokenCounts {
|
||||
input_tokens: Some(100),
|
||||
output_tokens: Some(50),
|
||||
total_tokens: None,
|
||||
cost_usd: cost,
|
||||
cache_read_tokens: None,
|
||||
cache_write_tokens: None,
|
||||
}),
|
||||
cumulative: None,
|
||||
delta_reliable: true,
|
||||
stop_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_payload_with_cumulative_cost(cost: Option<f64>) -> AgentTurnMetricPayload {
|
||||
AgentTurnMetricPayload {
|
||||
harness: "test".to_string(),
|
||||
model: None,
|
||||
channel_id: None,
|
||||
session_id: None,
|
||||
turn_id: None,
|
||||
turn_seq: None,
|
||||
timestamp: "2026-07-01T00:00:00Z".to_string(),
|
||||
turn: None,
|
||||
cumulative: Some(TokenCounts {
|
||||
input_tokens: Some(500),
|
||||
output_tokens: Some(200),
|
||||
total_tokens: None,
|
||||
cost_usd: cost,
|
||||
cache_read_tokens: None,
|
||||
cache_write_tokens: None,
|
||||
}),
|
||||
delta_reliable: true,
|
||||
stop_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_negative_turn_cost() {
|
||||
let payload = make_payload_with_turn_cost(Some(-0.001));
|
||||
assert!(
|
||||
matches!(
|
||||
payload.validate(),
|
||||
Err(ObserverPayloadError::InvalidPayload(_))
|
||||
),
|
||||
"negative turn.costUsd must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_nan_turn_cost() {
|
||||
let payload = make_payload_with_turn_cost(Some(f64::NAN));
|
||||
assert!(
|
||||
matches!(
|
||||
payload.validate(),
|
||||
Err(ObserverPayloadError::InvalidPayload(_))
|
||||
),
|
||||
"NaN turn.costUsd must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_infinite_turn_cost() {
|
||||
let payload = make_payload_with_turn_cost(Some(f64::INFINITY));
|
||||
assert!(
|
||||
matches!(
|
||||
payload.validate(),
|
||||
Err(ObserverPayloadError::InvalidPayload(_))
|
||||
),
|
||||
"infinite turn.costUsd must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_negative_cumulative_cost() {
|
||||
let payload = make_payload_with_cumulative_cost(Some(-1.0));
|
||||
assert!(
|
||||
matches!(
|
||||
payload.validate(),
|
||||
Err(ObserverPayloadError::InvalidPayload(_))
|
||||
),
|
||||
"negative cumulative.costUsd must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_accepts_finite_non_negative_cost() {
|
||||
// Zero, small, and larger values are all valid.
|
||||
for cost in [0.0_f64, 0.001, 1.0, 999.99] {
|
||||
let payload = make_payload_with_turn_cost(Some(cost));
|
||||
assert!(payload.validate().is_ok(), "cost {cost} should be accepted");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_accepts_absent_cost() {
|
||||
let payload = make_payload_with_turn_cost(None);
|
||||
assert!(
|
||||
payload.validate().is_ok(),
|
||||
"absent costUsd must be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_agent_turn_metric_rejects_negative_cost() {
|
||||
let agent_keys = Keys::generate();
|
||||
let owner_keys = Keys::generate();
|
||||
let payload = make_payload_with_turn_cost(Some(-0.5));
|
||||
let result = encrypt_agent_turn_metric(&agent_keys, &owner_keys.public_key(), &payload);
|
||||
assert!(
|
||||
matches!(result, Err(ObserverPayloadError::InvalidPayload(_))),
|
||||
"encrypt must reject payload with negative costUsd"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_agent_turn_metric_rejects_negative_cost_bypassing_encrypt() {
|
||||
// Regression: a raw/misbehaving agent can persist a syntactically valid
|
||||
// NIP-44 payload with costUsd: -1 by calling encrypt_observer_payload
|
||||
// directly (bypassing the validating encrypt_agent_turn_metric helper).
|
||||
// decrypt_agent_turn_metric must reject it symmetrically.
|
||||
use crate::observer::encrypt_observer_payload;
|
||||
|
||||
let agent_keys = Keys::generate();
|
||||
let owner_keys = Keys::generate();
|
||||
|
||||
// Build a payload with negative costUsd and encrypt via the lower-level
|
||||
// path, bypassing encrypt_agent_turn_metric's validate() call.
|
||||
let bad_payload = make_payload_with_turn_cost(Some(-1.0));
|
||||
let ciphertext =
|
||||
encrypt_observer_payload(&agent_keys, &owner_keys.public_key(), &bad_payload)
|
||||
.expect("lower-level encrypt should succeed without validation");
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(44200), ciphertext)
|
||||
.tags([
|
||||
Tag::parse(["p", &owner_keys.public_key().to_hex()]).unwrap(),
|
||||
Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.expect("sign");
|
||||
|
||||
let result = decrypt_agent_turn_metric(&owner_keys, &event);
|
||||
assert!(
|
||||
matches!(result, Err(ObserverPayloadError::InvalidPayload(_))),
|
||||
"decrypt must reject a payload with negative costUsd even when \
|
||||
encrypted via the lower-level path"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Channel and membership enums shared across crates.
|
||||
//!
|
||||
//! These live in `buzz-core` (zero I/O deps) so both the SDK (client-side)
|
||||
//! and the DB layer (server-side) can use the same types without pulling in
|
||||
//! sqlx/tokio.
|
||||
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Returns the canonical display name for a channel.
|
||||
///
|
||||
/// Channel names are rendered with a leading `#` by clients, so surrounding
|
||||
/// whitespace and user-supplied hash prefixes are removed here to keep the
|
||||
/// stored name prefix-free.
|
||||
pub fn canonical_channel_name(name: &str) -> &str {
|
||||
name.trim_start_matches(|c: char| c == '#' || c.is_whitespace())
|
||||
.trim_end()
|
||||
}
|
||||
|
||||
/// Whether a channel is publicly visible or invite-only.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChannelVisibility {
|
||||
/// Searchable; anyone can join without an invite.
|
||||
Open,
|
||||
/// Hidden; requires an invite to join.
|
||||
Private,
|
||||
}
|
||||
|
||||
impl ChannelVisibility {
|
||||
/// Canonical string representation (matches DB enum and Nostr tags).
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Open => "open",
|
||||
Self::Private => "private",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ChannelVisibility {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ChannelVisibility {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"open" => Ok(Self::Open),
|
||||
"private" => Ok(Self::Private),
|
||||
other => Err(format!("unknown channel visibility: {other:?}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The functional type of a channel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChannelType {
|
||||
/// Linear message stream (the default).
|
||||
Stream,
|
||||
/// Threaded forum-style discussion.
|
||||
Forum,
|
||||
/// Direct message conversation.
|
||||
Dm,
|
||||
/// Internal workflow execution channel.
|
||||
Workflow,
|
||||
}
|
||||
|
||||
impl ChannelType {
|
||||
/// Canonical string representation (matches DB enum and Nostr tags).
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Stream => "stream",
|
||||
Self::Forum => "forum",
|
||||
Self::Dm => "dm",
|
||||
Self::Workflow => "workflow",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ChannelType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ChannelType {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"stream" => Ok(Self::Stream),
|
||||
"forum" => Ok(Self::Forum),
|
||||
"dm" => Ok(Self::Dm),
|
||||
"workflow" => Ok(Self::Workflow),
|
||||
other => Err(format!("unknown channel type: {other:?}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A member's role within a channel.
|
||||
///
|
||||
/// The hierarchy for permission checks is: Owner > Admin > Member > Guest.
|
||||
/// Bot is a **separate designation** — it is not part of the linear hierarchy.
|
||||
/// Use [`MemberRole::permission_level`] for numeric comparisons in authorization.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MemberRole {
|
||||
/// Full control — can manage members and delete the channel.
|
||||
Owner,
|
||||
/// Can manage members and channel settings.
|
||||
Admin,
|
||||
/// Standard participant.
|
||||
Member,
|
||||
/// Read-only external participant.
|
||||
Guest,
|
||||
/// Automated agent or integration (not in the role hierarchy).
|
||||
Bot,
|
||||
}
|
||||
|
||||
impl MemberRole {
|
||||
/// Canonical string representation (matches DB enum and Nostr tags).
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Owner => "owner",
|
||||
Self::Admin => "admin",
|
||||
Self::Member => "member",
|
||||
Self::Guest => "guest",
|
||||
Self::Bot => "bot",
|
||||
}
|
||||
}
|
||||
|
||||
/// Elevated roles that only existing owners/admins may grant.
|
||||
pub fn is_elevated(&self) -> bool {
|
||||
matches!(self, Self::Owner | Self::Admin)
|
||||
}
|
||||
|
||||
/// Numeric permission level for authorization comparisons.
|
||||
///
|
||||
/// Higher = more privileged. Bot returns 0 (must use explicit grants).
|
||||
/// Use `role.permission_level() >= required.permission_level()` for checks.
|
||||
pub fn permission_level(self) -> u8 {
|
||||
match self {
|
||||
Self::Owner => 4,
|
||||
Self::Admin => 3,
|
||||
Self::Member => 2,
|
||||
Self::Guest => 1,
|
||||
Self::Bot => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this role meets or exceeds the required role's permission level.
|
||||
///
|
||||
/// Bot never meets any requirement (returns false for all non-Bot requirements).
|
||||
pub fn has_at_least(self, required: MemberRole) -> bool {
|
||||
self.permission_level() >= required.permission_level()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MemberRole {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for MemberRole {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"owner" => Ok(Self::Owner),
|
||||
"admin" => Ok(Self::Admin),
|
||||
"member" => Ok(Self::Member),
|
||||
"guest" => Ok(Self::Guest),
|
||||
"bot" => Ok(Self::Bot),
|
||||
other => Err(format!("unknown member role: {other:?}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::canonical_channel_name;
|
||||
|
||||
#[test]
|
||||
fn channel_names_trim_whitespace_and_drop_all_leading_hashes() {
|
||||
assert_eq!(canonical_channel_name("channel"), "channel");
|
||||
assert_eq!(canonical_channel_name("#channel"), "channel");
|
||||
assert_eq!(canonical_channel_name("###channel"), "channel");
|
||||
assert_eq!(canonical_channel_name(" ###channel "), "channel");
|
||||
assert_eq!(canonical_channel_name("# channel"), "channel");
|
||||
assert_eq!(canonical_channel_name("### channel "), "channel");
|
||||
assert_eq!(canonical_channel_name(" ### "), "");
|
||||
assert_eq!(canonical_channel_name("# #"), "");
|
||||
assert_eq!(canonical_channel_name("### ###"), "");
|
||||
assert_eq!(canonical_channel_name("channel#topic"), "channel#topic");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
/// Errors that can occur during Nostr event verification.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VerificationError {
|
||||
/// The event ID does not match the canonical hash of the event fields.
|
||||
#[error("invalid event id: computed {computed}, got {got}")]
|
||||
InvalidId {
|
||||
/// The ID we computed from the event fields.
|
||||
computed: String,
|
||||
/// The ID present in the event.
|
||||
got: String,
|
||||
},
|
||||
|
||||
/// The Schnorr signature over the event ID is invalid.
|
||||
#[error("invalid schnorr signature")]
|
||||
InvalidSignature,
|
||||
|
||||
/// Low-level secp256k1 cryptographic error.
|
||||
#[error("secp256k1 error: {0}")]
|
||||
Secp(#[from] nostr::secp256k1::Error),
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Relay-side event wrapper.
|
||||
//!
|
||||
//! [`StoredEvent`] wraps a [`nostr::Event`] with relay-assigned metadata
|
||||
//! (receive time, channel scope, verification status).
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A Nostr event with relay-assigned metadata.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoredEvent {
|
||||
/// The underlying Nostr event.
|
||||
pub event: nostr::Event,
|
||||
/// Wall-clock time the relay received this event.
|
||||
pub received_at: DateTime<Utc>,
|
||||
/// Channel scope; `None` for global/DM events.
|
||||
pub channel_id: Option<Uuid>,
|
||||
verified: bool,
|
||||
}
|
||||
|
||||
impl StoredEvent {
|
||||
/// Creates a new `StoredEvent` with `received_at` set to now and `verified = false`.
|
||||
pub fn new(event: nostr::Event, channel_id: Option<Uuid>) -> Self {
|
||||
Self {
|
||||
event,
|
||||
received_at: Utc::now(),
|
||||
channel_id,
|
||||
verified: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether this event's signature has been verified.
|
||||
pub fn is_verified(&self) -> bool {
|
||||
self.verified
|
||||
}
|
||||
|
||||
/// Creates a `StoredEvent` with an explicit `received_at` timestamp and verification status.
|
||||
pub fn with_received_at(
|
||||
event: nostr::Event,
|
||||
received_at: DateTime<Utc>,
|
||||
channel_id: Option<Uuid>,
|
||||
verified: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
event,
|
||||
received_at,
|
||||
channel_id,
|
||||
verified,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nostr::{EventBuilder, JsonUtil, Keys, Kind};
|
||||
|
||||
fn make_event() -> nostr::Event {
|
||||
let keys = Keys::generate();
|
||||
EventBuilder::new(Kind::TextNote, "hello buzz")
|
||||
.tags([])
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_signature_fails_verify() {
|
||||
let event = make_event();
|
||||
let mut json: serde_json::Value = serde_json::from_str(&event.as_json()).expect("parse");
|
||||
json["sig"] = serde_json::Value::String("0".repeat(128));
|
||||
let tampered = nostr::Event::from_json(json.to_string()).expect("parse");
|
||||
assert!(tampered.verify_id());
|
||||
assert!(!tampered.verify_signature());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
//! NIP-01 filter matching.
|
||||
//!
|
||||
//! Multiple filters are OR-ed; fields within one filter are AND-ed.
|
||||
|
||||
use nostr::Filter;
|
||||
|
||||
use crate::event::StoredEvent;
|
||||
|
||||
/// Returns `true` if the event matches any of the provided NIP-01 filters.
|
||||
pub fn filters_match(filters: &[Filter], event: &StoredEvent) -> bool {
|
||||
filters.iter().any(|f| filter_match_one(f, event))
|
||||
}
|
||||
|
||||
/// Result-level read authorization for relay-signed events whose content is
|
||||
/// private to a single viewer. Currently gates `KIND_DM_VISIBILITY` and
|
||||
/// `KIND_AGENT_TURN_METRIC`: the reader MUST equal the event's `#p` tag
|
||||
/// (owner). Returns `true` for every other kind.
|
||||
///
|
||||
/// This guards every delivery surface — WS historical pull (`req.rs`), HTTP
|
||||
/// bridge (`bridge.rs`), and live fan-out (`event.rs`) — so a query that
|
||||
/// bypasses the filter-level `#p` gate (e.g. a kindless `ids:[…]` lookup of
|
||||
/// a known event id) still cannot read another user's private event.
|
||||
pub fn reader_authorized_for_event(event: &nostr::Event, reader_pubkey_hex: &str) -> bool {
|
||||
let kind = crate::kind::event_kind_u32(event);
|
||||
if kind != crate::kind::KIND_DM_VISIBILITY && kind != crate::kind::KIND_AGENT_TURN_METRIC {
|
||||
return true;
|
||||
}
|
||||
let p = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P);
|
||||
event
|
||||
.tags
|
||||
.filter(nostr::TagKind::SingleLetter(p))
|
||||
.any(|t| t.content() == Some(reader_pubkey_hex))
|
||||
}
|
||||
|
||||
fn filter_match_one(f: &Filter, ev: &StoredEvent) -> bool {
|
||||
if let Some(kinds) = &f.kinds {
|
||||
if !kinds.contains(&ev.event.kind) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(authors) = &f.authors {
|
||||
if !authors.contains(&ev.event.pubkey) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(since) = f.since {
|
||||
if ev.event.created_at < since {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(until) = f.until {
|
||||
if ev.event.created_at > until {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// NIP-01 allows prefix matching on event IDs.
|
||||
if let Some(ids) = &f.ids {
|
||||
let event_id_hex = ev.event.id.to_hex();
|
||||
if !ids.iter().any(|id| event_id_hex.starts_with(&id.to_hex())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (tag_key, tag_values) in f.generic_tags.iter() {
|
||||
let tag_key_str = tag_key.to_string();
|
||||
let has_match = tag_values.iter().any(|filter_val| {
|
||||
ev.event
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|t| t.kind().to_string() == tag_key_str)
|
||||
.filter_map(|t| t.content())
|
||||
.any(|event_val| event_val == filter_val.as_str())
|
||||
});
|
||||
// Fallback for #h (channel) filters: some events (reactions kind:7,
|
||||
// deletions kind:5) derive their channel from the target event and
|
||||
// don't carry an h-tag themselves. Use StoredEvent.channel_id as a
|
||||
// fallback ONLY when the event has no h-tags at all — if the event
|
||||
// has explicit h-tags, those are authoritative and must match.
|
||||
if !has_match && tag_key_str == "h" {
|
||||
let event_has_h_tags = ev.event.tags.iter().any(|t| t.kind().to_string() == "h");
|
||||
if !event_has_h_tags {
|
||||
if let Some(ch_id) = ev.channel_id {
|
||||
let ch_str = ch_id.to_string();
|
||||
if !tag_values.iter().any(|v| v.as_str() == ch_str) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Event has h-tags but none matched — strict rejection.
|
||||
return false;
|
||||
}
|
||||
} else if !has_match {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_helpers::{make_event_with_keys, make_stored_event};
|
||||
use chrono::Utc;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||||
|
||||
fn stored_with_tag(tag: Tag) -> StoredEvent {
|
||||
let keys = Keys::generate();
|
||||
let event = EventBuilder::new(Kind::TextNote, "test")
|
||||
.tags([tag])
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign");
|
||||
StoredEvent::with_received_at(event, Utc::now(), None, true)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_author_since_until_tag_matching() {
|
||||
let keys = Keys::generate();
|
||||
let ev = StoredEvent::with_received_at(
|
||||
make_event_with_keys(&keys, Kind::TextNote),
|
||||
Utc::now(),
|
||||
None,
|
||||
true,
|
||||
);
|
||||
let pubkey = keys.public_key();
|
||||
let now_ts = nostr::Timestamp::now();
|
||||
let past = Timestamp::from(now_ts.as_secs() - 3600);
|
||||
let future = Timestamp::from(now_ts.as_secs() + 3600);
|
||||
|
||||
assert!(filters_match(&[Filter::new().kind(Kind::TextNote)], &ev));
|
||||
assert!(!filters_match(
|
||||
&[Filter::new().kind(Kind::ContactList)],
|
||||
&ev
|
||||
));
|
||||
|
||||
assert!(filters_match(&[Filter::new().author(pubkey)], &ev));
|
||||
assert!(!filters_match(
|
||||
&[Filter::new().author(Keys::generate().public_key())],
|
||||
&ev
|
||||
));
|
||||
|
||||
assert!(filters_match(
|
||||
&[Filter::new().kind(Kind::TextNote).author(pubkey)],
|
||||
&ev
|
||||
));
|
||||
assert!(!filters_match(
|
||||
&[Filter::new().kind(Kind::ContactList).author(pubkey)],
|
||||
&ev
|
||||
));
|
||||
|
||||
assert!(filters_match(&[Filter::new().since(past)], &ev));
|
||||
assert!(!filters_match(&[Filter::new().since(future)], &ev));
|
||||
assert!(filters_match(&[Filter::new().until(future)], &ev));
|
||||
assert!(!filters_match(&[Filter::new().until(past)], &ev));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn or_semantics() {
|
||||
let ev = make_stored_event(Kind::TextNote, None);
|
||||
let miss = Filter::new().kind(Kind::ContactList);
|
||||
let hit = Filter::new().kind(Kind::TextNote);
|
||||
assert!(filters_match(&[miss.clone(), hit], &ev));
|
||||
assert!(!filters_match(
|
||||
&[miss, Filter::new().kind(Kind::EventDeletion)],
|
||||
&ev
|
||||
));
|
||||
assert!(!filters_match(&[], &ev));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_matching() {
|
||||
let target_id = nostr::EventId::all_zeros();
|
||||
let ev = stored_with_tag(Tag::event(target_id));
|
||||
assert!(filters_match(&[Filter::new().event(target_id)], &ev));
|
||||
assert!(!filters_match(
|
||||
&[Filter::new().event(nostr::EventId::from_byte_array([1u8; 32]))],
|
||||
&ev
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h_tag_fallback_uses_stored_channel_id() {
|
||||
// Reactions (kind:7) and deletions (kind:5) don't carry h-tags —
|
||||
// they derive their channel from the target event. The filter
|
||||
// should fall back to StoredEvent.channel_id for #h matching.
|
||||
let channel_id = uuid::Uuid::new_v4();
|
||||
let keys = Keys::generate();
|
||||
|
||||
// Event with NO h-tag but with a stored channel_id.
|
||||
let reaction = EventBuilder::new(Kind::Reaction, "👍")
|
||||
.tags([Tag::event(nostr::EventId::all_zeros())])
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign");
|
||||
let stored = StoredEvent::with_received_at(reaction, Utc::now(), Some(channel_id), true);
|
||||
|
||||
let h_filter = Filter::new().kind(Kind::Reaction).custom_tags(
|
||||
nostr::SingleLetterTag::lowercase(nostr::Alphabet::H),
|
||||
[channel_id.to_string()],
|
||||
);
|
||||
|
||||
// Should match via channel_id fallback.
|
||||
assert!(filters_match(std::slice::from_ref(&h_filter), &stored));
|
||||
|
||||
// Wrong channel should NOT match.
|
||||
let wrong_channel = Filter::new().kind(Kind::Reaction).custom_tags(
|
||||
nostr::SingleLetterTag::lowercase(nostr::Alphabet::H),
|
||||
[uuid::Uuid::new_v4().to_string()],
|
||||
);
|
||||
assert!(!filters_match(&[wrong_channel], &stored));
|
||||
|
||||
// No stored channel_id should NOT match.
|
||||
let no_channel =
|
||||
StoredEvent::with_received_at(stored.event.clone(), Utc::now(), None, true);
|
||||
assert!(!filters_match(std::slice::from_ref(&h_filter), &no_channel));
|
||||
|
||||
// Event WITH an explicit h-tag: tag is authoritative, channel_id fallback
|
||||
// must NOT override it. Prevents cross-channel leakage.
|
||||
let other_channel = uuid::Uuid::new_v4();
|
||||
let msg_with_h = EventBuilder::new(Kind::Custom(9), "hello")
|
||||
.tags([Tag::parse(["h", &other_channel.to_string()]).unwrap()])
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign");
|
||||
// channel_id matches the filter, but the h-tag points elsewhere.
|
||||
let stored_with_h =
|
||||
StoredEvent::with_received_at(msg_with_h, Utc::now(), Some(channel_id), true);
|
||||
assert!(
|
||||
!filters_match(std::slice::from_ref(&h_filter), &stored_with_h),
|
||||
"explicit h-tag must be authoritative — channel_id fallback must not override it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reader_authorized_for_event_gates_dm_visibility_by_p() {
|
||||
let relay = Keys::generate();
|
||||
let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
let snapshot = EventBuilder::new(Kind::Custom(crate::kind::KIND_DM_VISIBILITY as u16), "")
|
||||
.tags([
|
||||
Tag::parse(["d", owner]).unwrap(),
|
||||
Tag::parse(["p", owner]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&relay)
|
||||
.expect("sign");
|
||||
|
||||
assert!(
|
||||
reader_authorized_for_event(&snapshot, owner),
|
||||
"owner must be authorized to read their own snapshot"
|
||||
);
|
||||
assert!(
|
||||
!reader_authorized_for_event(&snapshot, other),
|
||||
"a third party must NOT be authorized to read another viewer's snapshot"
|
||||
);
|
||||
|
||||
// Non-DV events are unaffected by this gate.
|
||||
let note = EventBuilder::new(Kind::TextNote, "hi")
|
||||
.sign_with_keys(&relay)
|
||||
.expect("sign");
|
||||
assert!(reader_authorized_for_event(¬e, other));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reader_authorized_for_event_gates_agent_turn_metric_by_p() {
|
||||
let agent_keys = Keys::generate();
|
||||
let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
let attacker = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
|
||||
|
||||
// Agent turn metric event: pubkey=agent, p tag=owner (NIP-AM envelope shape).
|
||||
let metric = EventBuilder::new(
|
||||
Kind::Custom(crate::kind::KIND_AGENT_TURN_METRIC as u16),
|
||||
"encrypted-payload",
|
||||
)
|
||||
.tags([
|
||||
Tag::parse(["p", owner]).unwrap(),
|
||||
Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.expect("sign");
|
||||
|
||||
assert!(
|
||||
reader_authorized_for_event(&metric, owner),
|
||||
"owner must be authorized to read their own agent turn metric"
|
||||
);
|
||||
assert!(
|
||||
!reader_authorized_for_event(&metric, attacker),
|
||||
"non-owner must NOT be authorized to read an agent turn metric via kindless ids"
|
||||
);
|
||||
// The authoring agent also does not get read-back (NIP-AM: owner-only read).
|
||||
assert!(
|
||||
!reader_authorized_for_event(&metric, &agent_keys.public_key().to_hex()),
|
||||
"the authoring agent must NOT be authorized to read its own metric event (owner-only)"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
||||
//! Shared pure contracts for relay invite links.
|
||||
//!
|
||||
//! The relay transport and database persistence layers both depend on
|
||||
//! `buzz-core`. Protocol constants and deterministic v2 code operations live
|
||||
//! here so neither layer becomes the accidental source of truth.
|
||||
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine as _;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Minimum invite lifetime accepted by the mint API: 60 seconds.
|
||||
pub const MIN_INVITE_TTL_SECS: u64 = 60;
|
||||
|
||||
/// Default invite lifetime when the mint request omits `ttl_secs`: 72 hours.
|
||||
pub const DEFAULT_INVITE_TTL_SECS: u64 = 72 * 60 * 60;
|
||||
|
||||
/// Maximum invite lifetime accepted by the mint API: 30 days.
|
||||
pub const MAX_INVITE_TTL_SECS: u64 = 30 * 24 * 60 * 60;
|
||||
|
||||
/// Maximum supported `max_uses` value. Matches the database constraint.
|
||||
pub const MAX_INVITE_USES: i32 = 10_000;
|
||||
|
||||
/// Prefix that distinguishes v2 opaque database-backed codes from v1 tokens.
|
||||
pub const V2_PREFIX: &str = "v2.";
|
||||
|
||||
/// Number of random bytes encoded in a v2 invite code.
|
||||
pub const V2_SECRET_LEN: usize = 32;
|
||||
|
||||
/// A malformed v2 opaque invite code.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct InvalidV2InviteCode;
|
||||
|
||||
/// Build a canonical v2 code from its random secret.
|
||||
pub fn encode_v2_code(secret: &[u8; V2_SECRET_LEN]) -> String {
|
||||
format!("{V2_PREFIX}{}", URL_SAFE_NO_PAD.encode(secret))
|
||||
}
|
||||
|
||||
/// Validate the canonical v2 opaque-code shape without consulting storage.
|
||||
///
|
||||
/// A valid code is exactly `v2.` followed by the unpadded base64url encoding
|
||||
/// of a 32-byte secret. The decode/re-encode comparison rejects aliases such
|
||||
/// as padded or otherwise non-canonical encodings.
|
||||
pub fn validate_v2_code(code: &str) -> Result<(), InvalidV2InviteCode> {
|
||||
let encoded = code.strip_prefix(V2_PREFIX).ok_or(InvalidV2InviteCode)?;
|
||||
let secret = URL_SAFE_NO_PAD
|
||||
.decode(encoded)
|
||||
.map_err(|_| InvalidV2InviteCode)?;
|
||||
if secret.len() != V2_SECRET_LEN || URL_SAFE_NO_PAD.encode(&secret) != encoded {
|
||||
return Err(InvalidV2InviteCode);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hash the complete v2 code to the digest persisted by the database.
|
||||
pub fn hash_v2_code(code: &str) -> [u8; 32] {
|
||||
Sha256::digest(code.as_bytes()).into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn v2_code_round_trip_is_canonical() {
|
||||
let secret = [7_u8; V2_SECRET_LEN];
|
||||
let code = encode_v2_code(&secret);
|
||||
|
||||
assert_eq!(validate_v2_code(&code), Ok(()));
|
||||
assert_eq!(
|
||||
code,
|
||||
format!("{V2_PREFIX}{}", URL_SAFE_NO_PAD.encode(secret))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_validation_rejects_malformed_and_noncanonical_codes() {
|
||||
let valid = encode_v2_code(&[7_u8; V2_SECRET_LEN]);
|
||||
let short = format!(
|
||||
"{V2_PREFIX}{}",
|
||||
URL_SAFE_NO_PAD.encode([7_u8; V2_SECRET_LEN - 1])
|
||||
);
|
||||
|
||||
for malformed in [
|
||||
"v2.",
|
||||
"v2.not-base64!",
|
||||
short.as_str(),
|
||||
&format!("{valid}="),
|
||||
] {
|
||||
assert_eq!(
|
||||
validate_v2_code(malformed),
|
||||
Err(InvalidV2InviteCode),
|
||||
"accepted malformed v2 code: {malformed}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_hash_covers_the_complete_code() {
|
||||
let code = encode_v2_code(&[7_u8; V2_SECRET_LEN]);
|
||||
|
||||
let expected: [u8; 32] = Sha256::digest(code.as_bytes()).into();
|
||||
|
||||
assert_eq!(hash_v2_code(&code), expected);
|
||||
assert_ne!(
|
||||
hash_v2_code(&code),
|
||||
hash_v2_code(code.trim_start_matches(V2_PREFIX))
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
#![deny(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
//! `buzz-core` — zero-I/O foundation types for the Buzz relay.
|
||||
//!
|
||||
//! Provides [`StoredEvent`], filter matching, kind constants, and event
|
||||
//! verification. All other Buzz crates depend on this one.
|
||||
|
||||
/// NIP-AM: Agent Turn Metric — payload type and encrypt/decrypt helpers.
|
||||
pub mod agent_turn_metric;
|
||||
/// Channel and membership enums shared across crates.
|
||||
pub mod channel;
|
||||
/// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation,
|
||||
/// body parse/serialize, envelope build/validate, head selection.
|
||||
pub mod engram;
|
||||
/// Relay-side error types.
|
||||
pub mod error;
|
||||
/// Relay-side event wrapper with verification tracking.
|
||||
pub mod event;
|
||||
/// NIP-01 subscription filter matching.
|
||||
pub mod filter;
|
||||
/// Git permission types — ref patterns, protection rules, policy evaluation.
|
||||
pub mod git_perms;
|
||||
/// Shared invite-link contract constants.
|
||||
pub mod invite;
|
||||
/// Buzz kind number registry — custom event type constants.
|
||||
pub mod kind;
|
||||
/// Network utilities — SSRF-safe IP classification.
|
||||
pub mod network;
|
||||
/// Agent observer frame helpers.
|
||||
pub mod observer;
|
||||
/// NIP-AB device pairing — crypto primitives, message types, and errors.
|
||||
pub mod pairing;
|
||||
/// Presence status types shared across crates.
|
||||
pub mod presence;
|
||||
/// NIP-PMA owner-encrypted private managed-agent wire codec.
|
||||
pub mod private_managed_agent;
|
||||
/// Canonical relay runtime identities.
|
||||
pub mod relay;
|
||||
/// Tenant identity — the server-resolved community key carried on scoped paths.
|
||||
pub mod tenant;
|
||||
/// Schnorr signature and event ID verification.
|
||||
pub mod verification;
|
||||
|
||||
pub use error::VerificationError;
|
||||
pub use event::StoredEvent;
|
||||
pub use nostr::{Event, EventId, Filter, Keys, Kind, PublicKey};
|
||||
pub use presence::PresenceStatus;
|
||||
pub use tenant::{normalize_host, CommunityId, TenantContext};
|
||||
pub use verification::verify_event;
|
||||
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
/// Test helper utilities for creating events and stored events.
|
||||
pub mod test_helpers {
|
||||
use crate::StoredEvent;
|
||||
use chrono::Utc;
|
||||
use nostr::{EventBuilder, Keys, Kind};
|
||||
|
||||
/// Create a signed test event with the given kind and random keys.
|
||||
pub fn make_event(kind: Kind) -> nostr::Event {
|
||||
let keys = Keys::generate();
|
||||
EventBuilder::new(kind, "test")
|
||||
.tags([])
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign")
|
||||
}
|
||||
|
||||
/// Create a signed test event with the given keys and kind.
|
||||
pub fn make_event_with_keys(keys: &Keys, kind: Kind) -> nostr::Event {
|
||||
EventBuilder::new(kind, "test")
|
||||
.tags([])
|
||||
.sign_with_keys(keys)
|
||||
.expect("sign")
|
||||
}
|
||||
|
||||
/// Create a [`StoredEvent`] wrapper around a test event.
|
||||
pub fn make_stored_event(kind: Kind, channel_id: Option<uuid::Uuid>) -> StoredEvent {
|
||||
StoredEvent::with_received_at(make_event(kind), Utc::now(), channel_id, true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
//! Network utility functions for Buzz.
|
||||
//!
|
||||
//! Provides shared helpers used across crates for SSRF protection and
|
||||
//! IP address classification.
|
||||
|
||||
// RFC 6052 well-known NAT64 prefix (64:ff9b::/96).
|
||||
const NAT64_WELL_KNOWN_PREFIX: [u8; 12] = [0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
|
||||
// Legacy SIIT IPv4-translated prefix (::ffff:0:0:0/96).
|
||||
const IPV4_TRANSLATED_PREFIX: [u8; 12] = [0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0, 0];
|
||||
|
||||
/// Extract an IPv4 address stored in the final four octets under a `/96` prefix.
|
||||
///
|
||||
/// Using network-order octets directly avoids error-prone segment shifting.
|
||||
fn embedded_ipv4(v6: &std::net::Ipv6Addr, prefix: &[u8; 12]) -> Option<std::net::Ipv4Addr> {
|
||||
let octets = v6.octets();
|
||||
octets
|
||||
.starts_with(prefix)
|
||||
.then(|| std::net::Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15]))
|
||||
}
|
||||
|
||||
/// Returns `true` if the IP address is in a private, reserved, or
|
||||
/// loopback range. Used for SSRF protection — webhook targets must
|
||||
/// not resolve to these addresses.
|
||||
///
|
||||
/// Blocked ranges:
|
||||
/// - IPv4 loopback 127.0.0.0/8
|
||||
/// - IPv4 private 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
||||
/// - IPv4 link-local 169.254.0.0/16
|
||||
/// - IPv4 unspecified 0.0.0.0/8
|
||||
/// - IPv4 broadcast 255.255.255.255
|
||||
/// - IPv4 CGNAT 100.64.0.0/10 (RFC 6598) — cloud metadata risk
|
||||
/// - IPv4 benchmarking 198.18.0.0/15 (RFC 2544)
|
||||
/// - IPv6 loopback ::1
|
||||
/// - IPv6 unspecified ::
|
||||
/// - IPv6 ULA fc00::/7
|
||||
/// - IPv6 link-local fe80::/10
|
||||
/// - IPv6 multicast ff00::/8
|
||||
/// - IPv6 documentation 2001:db8::/32 (RFC 3849) — should never appear in production
|
||||
/// - IPv4-compatible and mapped IPv6 (checked recursively against IPv4 rules)
|
||||
/// - IPv4-translated ::ffff:0:0:0/96 (embedded IPv4 checked recursively)
|
||||
/// - NAT64 well-known 64:ff9b::/96 (embedded IPv4 checked recursively)
|
||||
/// - NAT64 local-use 64:ff9b:1::/48 (RFC 8215)
|
||||
/// - Teredo 2001::/32 (RFC 4380)
|
||||
/// - 6to4 2002::/16 (RFC 3056)
|
||||
pub fn is_private_ip(ip: &std::net::IpAddr) -> bool {
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
let octets = v4.octets();
|
||||
v4.is_loopback()
|
||||
|| v4.is_private()
|
||||
|| v4.is_link_local()
|
||||
|| octets[0] == 0
|
||||
|| v4.is_broadcast()
|
||||
// Carrier-Grade NAT (RFC 6598) — 100.64.0.0/10
|
||||
// Dangerous in cloud environments (AWS, GCP) where CGNAT can route to metadata services.
|
||||
|| (octets[0] == 100 && (octets[1] & 0xC0) == 64)
|
||||
// Benchmarking (RFC 2544) — 198.18.0.0/15
|
||||
|| (octets[0] == 198 && (octets[1] & 0xFE) == 18)
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
// Check IPv4-compatible and mapped addresses against IPv4 rules.
|
||||
if let Some(v4) = v6.to_ipv4() {
|
||||
return is_private_ip(&std::net::IpAddr::V4(v4));
|
||||
}
|
||||
|
||||
let segments = v6.segments();
|
||||
|
||||
// NAT64 well-known prefix (RFC 6052). Preserve access to public IPv4
|
||||
// destinations while rejecting embedded private/reserved addresses.
|
||||
if let Some(v4) = embedded_ipv4(v6, &NAT64_WELL_KNOWN_PREFIX) {
|
||||
return is_private_ip(&std::net::IpAddr::V4(v4));
|
||||
}
|
||||
|
||||
// Legacy SIIT IPv4-translated addresses can route to the IPv4 value
|
||||
// in their final four octets but are not recognized by `to_ipv4()`.
|
||||
if let Some(v4) = embedded_ipv4(v6, &IPV4_TRANSLATED_PREFIX) {
|
||||
return is_private_ip(&std::net::IpAddr::V4(v4));
|
||||
}
|
||||
|
||||
v6.is_loopback()
|
||||
|| v6.is_unspecified()
|
||||
|| segments[0] & 0xfe00 == 0xfc00 // fc00::/7 ULA
|
||||
|| segments[0] & 0xffc0 == 0xfe80 // fe80::/10 link-local
|
||||
|| segments[0] & 0xff00 == 0xff00 // ff00::/8 multicast
|
||||
|| (segments[0] == 0x0064
|
||||
&& segments[1] == 0xff9b
|
||||
&& segments[2] == 1) // 64:ff9b:1::/48 local-use NAT64
|
||||
|| (segments[0] == 0x2001 && segments[1] == 0) // 2001::/32 Teredo
|
||||
|| segments[0] == 0x2002 // 2002::/16 6to4
|
||||
// RFC 3849 — documentation range, should never appear in production
|
||||
|| (segments[0] == 0x2001 && segments[1] == 0x0db8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::IpAddr;
|
||||
|
||||
#[test]
|
||||
fn test_loopback_v4() {
|
||||
assert!(is_private_ip(&"127.0.0.1".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_private_10() {
|
||||
assert!(is_private_ip(&"10.0.0.1".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_private_172() {
|
||||
assert!(is_private_ip(&"172.16.0.1".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_private_192() {
|
||||
assert!(is_private_ip(&"192.168.1.1".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_link_local() {
|
||||
assert!(is_private_ip(&"169.254.1.1".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_unspecified() {
|
||||
assert!(is_private_ip(&"0.0.0.0".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_broadcast() {
|
||||
assert!(is_private_ip(&"255.255.255.255".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_public_v4() {
|
||||
assert!(!is_private_ip(&"8.8.8.8".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_loopback_v6() {
|
||||
assert!(is_private_ip(&"::1".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_unspecified_v6() {
|
||||
assert!(is_private_ip(&"::".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_ula_v6() {
|
||||
assert!(is_private_ip(&"fd00::1".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_link_local_v6() {
|
||||
assert!(is_private_ip(&"fe80::1".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_public_v6() {
|
||||
assert!(!is_private_ip(&"2606:4700::1".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_documentation_range_v6() {
|
||||
// 2001:db8::/32 — RFC 3849 documentation range, must be blocked
|
||||
assert!(is_private_ip(&"2001:db8::1".parse::<IpAddr>().unwrap()));
|
||||
assert!(is_private_ip(
|
||||
&"2001:db8:ffff::1".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn test_ipv4_mapped_v6_private() {
|
||||
// ::ffff:10.0.0.1 is an IPv4-mapped IPv6 address pointing to a private IPv4
|
||||
assert!(is_private_ip(&"::ffff:10.0.0.1".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_ipv4_mapped_v6_loopback() {
|
||||
assert!(is_private_ip(
|
||||
&"::ffff:127.0.0.1".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn test_ipv4_mapped_v6_public() {
|
||||
assert!(!is_private_ip(&"::ffff:8.8.8.8".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_ipv4_compatible_v6_private() {
|
||||
assert!(is_private_ip(&"::10.0.0.1".parse::<IpAddr>().unwrap()));
|
||||
assert!(is_private_ip(&"::127.0.0.1".parse::<IpAddr>().unwrap()));
|
||||
assert!(is_private_ip(
|
||||
&"::169.254.169.254".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(&"::8.8.8.8".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_nat64_well_known_prefix() {
|
||||
let first = "64:ff9b::".parse().unwrap();
|
||||
let last = "64:ff9b::ffff:ffff".parse().unwrap();
|
||||
assert_eq!(
|
||||
embedded_ipv4(&first, &NAT64_WELL_KNOWN_PREFIX),
|
||||
Some("0.0.0.0".parse().unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
embedded_ipv4(&last, &NAT64_WELL_KNOWN_PREFIX),
|
||||
Some("255.255.255.255".parse().unwrap())
|
||||
);
|
||||
let embedded = "64:ff9b::172.16.1.2".parse().unwrap();
|
||||
assert_eq!(
|
||||
embedded_ipv4(&embedded, &NAT64_WELL_KNOWN_PREFIX),
|
||||
Some("172.16.1.2".parse().unwrap())
|
||||
);
|
||||
assert!(is_private_ip(
|
||||
&"64:ff9b::10.0.0.1".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(is_private_ip(
|
||||
&"64:ff9b::127.0.0.1".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(is_private_ip(
|
||||
&"64:ff9b::169.254.169.254".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(
|
||||
&"64:ff9b::8.8.8.8".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(
|
||||
&"64:ff9a:ffff:ffff:ffff:ffff:ffff:ffff"
|
||||
.parse::<IpAddr>()
|
||||
.unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(&"64:ff9b::1:0:0".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_ipv4_translated_prefix() {
|
||||
let first = "0:0:0:0:ffff:0:0:0".parse().unwrap();
|
||||
let last = "0:0:0:0:ffff:0:ffff:ffff".parse().unwrap();
|
||||
assert_eq!(
|
||||
embedded_ipv4(&first, &IPV4_TRANSLATED_PREFIX),
|
||||
Some("0.0.0.0".parse().unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
embedded_ipv4(&last, &IPV4_TRANSLATED_PREFIX),
|
||||
Some("255.255.255.255".parse().unwrap())
|
||||
);
|
||||
assert!(is_private_ip(
|
||||
&"::ffff:0:10.0.0.1".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(is_private_ip(
|
||||
&"::ffff:0:127.0.0.1".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(is_private_ip(
|
||||
&"::ffff:0:169.254.169.254".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(
|
||||
&"::ffff:0:8.8.8.8".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(
|
||||
&"0:0:0:0:fffe:ffff:ffff:ffff".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(
|
||||
&"0:0:0:0:ffff:1:0:0".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn test_nat64_local_use_prefix_boundaries() {
|
||||
assert!(is_private_ip(&"64:ff9b:1::".parse::<IpAddr>().unwrap()));
|
||||
assert!(is_private_ip(
|
||||
&"64:ff9b:1:ffff:ffff:ffff:ffff:ffff"
|
||||
.parse::<IpAddr>()
|
||||
.unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(
|
||||
&"64:ff9b::ffff:ffff:ffff:ffff:ffff"
|
||||
.parse::<IpAddr>()
|
||||
.unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(&"64:ff9b:2::".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_teredo_prefix_boundaries() {
|
||||
assert!(is_private_ip(&"2001::".parse::<IpAddr>().unwrap()));
|
||||
assert!(is_private_ip(
|
||||
&"2001:0:ffff:ffff:ffff:ffff:ffff:ffff"
|
||||
.parse::<IpAddr>()
|
||||
.unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(
|
||||
&"2000:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
|
||||
.parse::<IpAddr>()
|
||||
.unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(&"2001:1::1".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_6to4_prefix_boundaries() {
|
||||
assert!(is_private_ip(&"2002::".parse::<IpAddr>().unwrap()));
|
||||
assert!(is_private_ip(
|
||||
&"2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
|
||||
.parse::<IpAddr>()
|
||||
.unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(
|
||||
&"2001:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
|
||||
.parse::<IpAddr>()
|
||||
.unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(&"2003::1".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
|
||||
// CGNAT (RFC 6598) — 100.64.0.0/10
|
||||
#[test]
|
||||
fn test_cgnat_start() {
|
||||
// 100.64.0.1 — start of CGNAT range
|
||||
assert!(is_private_ip(&"100.64.0.1".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_cgnat_end() {
|
||||
// 100.127.255.254 — end of CGNAT range
|
||||
assert!(is_private_ip(&"100.127.255.254".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_cgnat_below_range() {
|
||||
// 100.63.255.255 — just below CGNAT range (100.0–100.63 is public)
|
||||
assert!(!is_private_ip(&"100.63.255.255".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_cgnat_above_range() {
|
||||
// 100.128.0.0 — just above CGNAT range (100.128+ is public)
|
||||
assert!(!is_private_ip(&"100.128.0.0".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
|
||||
// Benchmarking (RFC 2544) — 198.18.0.0/15
|
||||
#[test]
|
||||
fn test_benchmarking_start() {
|
||||
assert!(is_private_ip(&"198.18.0.1".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_benchmarking_end() {
|
||||
assert!(is_private_ip(&"198.19.255.254".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_benchmarking_below_range() {
|
||||
// 198.17.255.255 — just below benchmarking range
|
||||
assert!(!is_private_ip(&"198.17.255.255".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_benchmarking_above_range() {
|
||||
// 198.20.0.0 — just above benchmarking range
|
||||
assert!(!is_private_ip(&"198.20.0.0".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
|
||||
// IPv6 multicast — ff00::/8
|
||||
#[test]
|
||||
fn test_ipv6_multicast_all_nodes() {
|
||||
// ff02::1 — all-nodes multicast
|
||||
assert!(is_private_ip(&"ff02::1".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_ipv6_multicast_all_routers() {
|
||||
// ff02::2 — all-routers multicast
|
||||
assert!(is_private_ip(&"ff02::2".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_ipv6_multicast_high() {
|
||||
// ffff::1 — still in ff00::/8
|
||||
assert!(is_private_ip(&"ffff::1".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_ipv6_not_multicast() {
|
||||
// fe00:: — just below ff00::/8 (not multicast, not link-local, not ULA)
|
||||
assert!(!is_private_ip(&"fe00::1".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Agent observer frame helpers.
|
||||
//!
|
||||
//! Observer frames are transient, owner-scoped agent telemetry/control messages.
|
||||
//! They use a Buzz ephemeral event kind and carry NIP-44 encrypted JSON in the
|
||||
//! event content so relays can route frames without reading ACP internals.
|
||||
|
||||
use nostr::{nips::nip44, Event, Keys, PublicKey};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use thiserror::Error;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
/// Tag name that identifies the agent pubkey the observer frame belongs to.
|
||||
pub const OBSERVER_AGENT_TAG: &str = "agent";
|
||||
/// Tag name that identifies the cleartext frame direction.
|
||||
pub const OBSERVER_FRAME_TAG: &str = "frame";
|
||||
/// Frame value for agent-to-owner observer telemetry.
|
||||
pub const OBSERVER_FRAME_TELEMETRY: &str = "telemetry";
|
||||
/// Frame value for owner-to-agent observer control commands.
|
||||
pub const OBSERVER_FRAME_CONTROL: &str = "control";
|
||||
/// Minimum plausible NIP-44 v2 ciphertext length.
|
||||
pub const NIP44_MIN_CONTENT_LEN: usize = 132;
|
||||
/// Maximum NIP-44 v2 ciphertext length.
|
||||
pub const NIP44_MAX_CONTENT_LEN: usize = 87_472;
|
||||
/// Maximum observer plaintext JSON size accepted by helpers.
|
||||
pub const OBSERVER_MAX_PLAINTEXT_LEN: usize = 65_535;
|
||||
|
||||
/// Errors returned by observer payload encryption/decryption helpers.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ObserverPayloadError {
|
||||
/// NIP-44 encryption or decryption failed.
|
||||
#[error("NIP-44 error: {0}")]
|
||||
Nip44(#[from] nip44::Error),
|
||||
/// JSON serialization or deserialization failed.
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
/// Ciphertext did not fit the expected NIP-44 v2 length envelope.
|
||||
#[error("invalid NIP-44 ciphertext length: {0}")]
|
||||
InvalidCiphertextLength(usize),
|
||||
/// Decrypted JSON exceeded the observer plaintext size limit.
|
||||
#[error("observer plaintext exceeds {max} bytes (got {got})")]
|
||||
PlaintextTooLarge {
|
||||
/// Maximum accepted plaintext bytes.
|
||||
max: usize,
|
||||
/// Actual plaintext byte count.
|
||||
got: usize,
|
||||
},
|
||||
/// A payload field violated a NIP-AM numeric constraint.
|
||||
#[error("invalid payload field: {0}")]
|
||||
InvalidPayload(String),
|
||||
}
|
||||
|
||||
/// Returns true when `content` fits the NIP-44 v2 ciphertext length envelope.
|
||||
pub fn content_looks_like_nip44(content: &str) -> bool {
|
||||
(NIP44_MIN_CONTENT_LEN..=NIP44_MAX_CONTENT_LEN).contains(&content.len())
|
||||
}
|
||||
|
||||
/// Serialize and NIP-44 encrypt an observer payload for `recipient`.
|
||||
pub fn encrypt_observer_payload<T: Serialize>(
|
||||
sender_keys: &Keys,
|
||||
recipient: &PublicKey,
|
||||
payload: &T,
|
||||
) -> Result<String, ObserverPayloadError> {
|
||||
let mut plaintext = serde_json::to_string(payload)?;
|
||||
if plaintext.len() > OBSERVER_MAX_PLAINTEXT_LEN {
|
||||
let got = plaintext.len();
|
||||
plaintext.zeroize();
|
||||
return Err(ObserverPayloadError::PlaintextTooLarge {
|
||||
max: OBSERVER_MAX_PLAINTEXT_LEN,
|
||||
got,
|
||||
});
|
||||
}
|
||||
|
||||
let encrypted = nip44::encrypt(
|
||||
sender_keys.secret_key(),
|
||||
recipient,
|
||||
&plaintext,
|
||||
nip44::Version::V2,
|
||||
)?;
|
||||
plaintext.zeroize();
|
||||
Ok(encrypted)
|
||||
}
|
||||
|
||||
/// NIP-44 decrypt and deserialize an observer payload from `event`.
|
||||
pub fn decrypt_observer_payload<T: DeserializeOwned>(
|
||||
recipient_keys: &Keys,
|
||||
event: &Event,
|
||||
) -> Result<T, ObserverPayloadError> {
|
||||
if !content_looks_like_nip44(&event.content) {
|
||||
return Err(ObserverPayloadError::InvalidCiphertextLength(
|
||||
event.content.len(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut plaintext = nip44::decrypt(
|
||||
recipient_keys.secret_key(),
|
||||
&event.pubkey,
|
||||
event.content.as_str(),
|
||||
)?;
|
||||
if plaintext.len() > OBSERVER_MAX_PLAINTEXT_LEN {
|
||||
let got = plaintext.len();
|
||||
plaintext.zeroize();
|
||||
return Err(ObserverPayloadError::PlaintextTooLarge {
|
||||
max: OBSERVER_MAX_PLAINTEXT_LEN,
|
||||
got,
|
||||
});
|
||||
}
|
||||
|
||||
let result = serde_json::from_str(&plaintext);
|
||||
plaintext.zeroize();
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, Kind, Tag};
|
||||
|
||||
#[test]
|
||||
fn observer_payload_round_trips_with_nip44() {
|
||||
let sender = Keys::generate();
|
||||
let recipient = Keys::generate();
|
||||
let payload = serde_json::json!({
|
||||
"type": "turn_started",
|
||||
"turnId": "turn-1"
|
||||
});
|
||||
let encrypted = encrypt_observer_payload(&sender, &recipient.public_key(), &payload)
|
||||
.expect("encrypt payload");
|
||||
assert!(content_looks_like_nip44(&encrypted));
|
||||
|
||||
let event = EventBuilder::new(
|
||||
Kind::Custom(crate::kind::KIND_AGENT_OBSERVER_FRAME as u16),
|
||||
encrypted,
|
||||
)
|
||||
.tags([Tag::public_key(recipient.public_key())])
|
||||
.sign_with_keys(&sender)
|
||||
.expect("sign event");
|
||||
let decrypted: serde_json::Value =
|
||||
decrypt_observer_payload(&recipient, &event).expect("decrypt payload");
|
||||
assert_eq!(decrypted, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observer_payload_rejects_short_ciphertext() {
|
||||
let sender = Keys::generate();
|
||||
let recipient = Keys::generate();
|
||||
let event = EventBuilder::new(
|
||||
Kind::Custom(crate::kind::KIND_AGENT_OBSERVER_FRAME as u16),
|
||||
"not encrypted",
|
||||
)
|
||||
.tags([Tag::public_key(recipient.public_key())])
|
||||
.sign_with_keys(&sender)
|
||||
.expect("sign event");
|
||||
|
||||
assert!(matches!(
|
||||
decrypt_observer_payload::<serde_json::Value>(&recipient, &event),
|
||||
Err(ObserverPayloadError::InvalidCiphertextLength(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,824 @@
|
||||
NIP-AB
|
||||
======
|
||||
|
||||
Device Pairing
|
||||
--------------
|
||||
|
||||
`draft` `optional`
|
||||
|
||||
## Versions
|
||||
|
||||
This NIP is versioned to allow future algorithm upgrades without breaking existing implementations.
|
||||
|
||||
Currently defined versions:
|
||||
|
||||
| Version | Status | Description |
|
||||
|---------|--------|-------------|
|
||||
| `1` | Active | secp256k1 ECDH, HKDF-SHA256, SAS-6digit, NIP-44 v2 encryption |
|
||||
|
||||
The version is communicated in two places:
|
||||
|
||||
1. **QR URI**: `nostrpair://<pubkey>?secret=<hex>&relay=<url>&v=1`
|
||||
- The `v` parameter defaults to `1` if absent (backward compatibility).
|
||||
- _target_ MUST reject URIs with an unrecognized `v` value and display a human-readable error: "This QR code requires a newer version of [App]. Please update."
|
||||
|
||||
2. **Offer message**: the `offer` JSON MUST include a `version` field:
|
||||
```jsonc
|
||||
{
|
||||
"type": "offer",
|
||||
"version": 1,
|
||||
"session_id": "<hex, 32 bytes>"
|
||||
}
|
||||
```
|
||||
_source_ MUST reject offers with a `version` it does not support.
|
||||
|
||||
Implementations MUST NOT silently ignore an unrecognized version — they MUST surface an error to the user.
|
||||
|
||||
This NIP defines a protocol for securely transferring secrets between two devices over standard Nostr relays using QR-code-initiated, end-to-end encrypted channels with visual confirmation.
|
||||
|
||||
## Motivation
|
||||
|
||||
Users need their Nostr identity on multiple devices. Today the options are:
|
||||
|
||||
- Paste a raw `nsec` — insecure, no authentication, no encryption in transit
|
||||
- Use [NIP-46](46.md) remote signing — requires the signer device to be online for every operation
|
||||
- Enter a [NIP-06](06.md) mnemonic — manual, error-prone, not all clients support it
|
||||
|
||||
NIP-46 solves *ongoing delegation*: the key stays on one device and signs remotely. This NIP solves *one-time transfer*: the key moves to the new device, which then operates independently. They are complementary — this NIP can even bootstrap a NIP-46 session as one of its payload types.
|
||||
|
||||
This NIP provides a secure, authenticated channel between two devices that can carry any secret payload — a private key, a [NIP-46](46.md) session bootstrap, or application-specific data — without trusting the relay.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **source**: The device that holds the secret and initiates pairing (e.g., a desktop app).
|
||||
- **target**: The device that wants to receive the secret (e.g., a mobile phone).
|
||||
- **pairing relay**: Any [NIP-01](01.md) compliant relay used to route pairing events. The relay learns nothing about the payload.
|
||||
- **session secret**: A 32-byte random value shared via QR code, used to derive encryption keys.
|
||||
- **SAS (Short Authentication String)**: A short code displayed on both devices for the user to visually confirm, preventing man-in-the-middle attacks.
|
||||
|
||||
## Overview
|
||||
|
||||
1. _source_ generates an ephemeral keypair and a session secret, encodes them in a QR code.
|
||||
2. _target_ scans the QR code, generates its own ephemeral keypair.
|
||||
3. Both devices connect to the pairing relay and exchange ephemeral public keys via `kind:24134` events.
|
||||
4. Both devices derive a shared secret via ECDH and display a SAS code for the user to confirm.
|
||||
5. After confirmation, _source_ sends the encrypted payload via a `kind:24134` event.
|
||||
6. _target_ decrypts and imports the payload.
|
||||
|
||||
All events use ephemeral keypairs that are discarded after the session. The relay sees only opaque ciphertext addressed to throwaway public keys.
|
||||
|
||||
## Limitations
|
||||
|
||||
This NIP provides a secure one-time transfer channel. It does not provide:
|
||||
|
||||
- **No ongoing security**: once the payload is transferred, this NIP's security guarantees end. The transferred key's security depends entirely on the receiving device's storage and the user's operational security.
|
||||
- **No key revocation**: there is no mechanism to invalidate a completed pairing. If the _target_ device is later compromised, the transferred key is compromised.
|
||||
- **No multi-device coordination**: this NIP transfers a key to one device at a time. Managing keys across N devices requires N separate pairing sessions.
|
||||
- **No relay confidentiality**: the pairing relay learns the timing and approximate frequency of pairing events, even though it cannot read the payload. For high-risk users, a private relay is recommended.
|
||||
- **No post-quantum security**: the ECDH key exchange is vulnerable to a sufficiently powerful quantum computer. The NIP-44 encryption layer inherits the same limitation.
|
||||
- **Physical presence assumption**: SAS verification requires the user to visually compare codes on two physical screens. An attacker with physical access to both devices simultaneously can bypass this check.
|
||||
- **QR code window**: the session secret is exposed in the QR code for up to 120 seconds. Screen capture, shoulder surfing, or a compromised camera can expose it.
|
||||
- **Single-use only**: this protocol is not designed for repeated or automated transfers. Each transfer requires a new QR scan and user confirmation.
|
||||
|
||||
For ongoing remote signing without key transfer, use [NIP-46](46.md) instead.
|
||||
|
||||
## QR Code Format
|
||||
|
||||
The _source_ generates:
|
||||
|
||||
- An ephemeral secp256k1 keypair (`source_ephemeral_privkey`, `source_ephemeral_pubkey`)
|
||||
- A 32-byte cryptographically random `session_secret`
|
||||
|
||||
The QR code encodes a URI:
|
||||
|
||||
```
|
||||
nostrpair://<source_ephemeral_pubkey_hex>?secret=<session_secret_hex>&relay=<wss://relay.example.com>&v=1
|
||||
```
|
||||
|
||||
- `source_ephemeral_pubkey_hex`: 64-character lowercase hex-encoded 32-byte x-only public key (as used throughout Nostr per [BIP-340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki))
|
||||
- `session_secret_hex`: 64-character lowercase hex-encoded 32 random bytes
|
||||
- `relay`: percent-encoded WebSocket URL of the pairing relay. MUST appear at least once. MAY appear multiple times (see §Multi-Relay Considerations).
|
||||
- `v`: protocol version integer (see §Versions). Defaults to `1` if absent.
|
||||
|
||||
The total URI length MUST NOT exceed 2048 characters. Reject any URI that exceeds this limit (prevents DoS via QR scanning).
|
||||
|
||||
Implementations MUST validate the QR URI before processing:
|
||||
- `source_ephemeral_pubkey_hex` MUST be exactly 64 lowercase hex characters (32 bytes). Reject if not.
|
||||
- `session_secret_hex` MUST be exactly 64 lowercase hex characters (32 bytes). Reject if not.
|
||||
- `relay` MUST be a valid WebSocket URL beginning with `wss://` or `ws://`. Reject if not.
|
||||
- Implementations MUST NOT process a `nostrpair://` URI that fails any of the above checks.
|
||||
|
||||
Both _source_ and _target_ connect to the relay specified in the QR URI. If the relay is unreachable, the session MUST be aborted. There is no relay discovery mechanism; the QR code is the authoritative relay list.
|
||||
|
||||
The QR code MUST NOT contain any private key material. If intercepted, an attacker obtains only an ephemeral public key and a session secret, which are useless without completing the handshake within the session timeout.
|
||||
|
||||
Clients MAY support additional query parameters for forward compatibility. Unknown parameters MUST be ignored.
|
||||
|
||||
## Event Kind
|
||||
|
||||
All pairing messages use a single event kind:
|
||||
|
||||
```
|
||||
kind: 24134
|
||||
```
|
||||
|
||||
This kind is in the ephemeral event range. Relays SHOULD treat these events as ephemeral and MAY delete them after delivery or after a short TTL (e.g., 5 minutes). Relays do not need any special handling for this kind — standard NIP-01 event routing is sufficient.
|
||||
|
||||
## Event Structure
|
||||
|
||||
All `kind:24134` events follow this structure:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "<sha256 hash per NIP-01>",
|
||||
"pubkey": "<sender's ephemeral pubkey>",
|
||||
"kind": 24134,
|
||||
"content": "<NIP-44 encrypted JSON>",
|
||||
"tags": [["p", "<recipient's ephemeral pubkey>"]],
|
||||
"created_at": <unix timestamp>,
|
||||
"sig": "<schnorr signature per NIP-01>"
|
||||
}
|
||||
```
|
||||
|
||||
The `content` field is always encrypted using **NIP-44 version 2** (the `0x02` algorithm: secp256k1 ECDH, HKDF, padding, ChaCha20, HMAC-SHA256), as specified in [NIP-44](44.md). The conversation key is derived from the sender's ephemeral private key and the recipient's ephemeral public key. Implementations MUST use NIP-44 v2 and MUST reject events whose NIP-44 version byte is not `0x02`.
|
||||
|
||||
NIP-AB does not negotiate encryption versions. If a future NIP-44 version is required, this NIP will be updated with a new version indicator. Implementations MUST NOT silently fall back to an older NIP-44 version.
|
||||
|
||||
The encrypted plaintext is always a JSON object containing a `type` field that identifies the message:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "<message_type>",
|
||||
// ... type-specific fields
|
||||
}
|
||||
```
|
||||
|
||||
Message types are: `offer`, `sas-confirm`, `payload`, `complete`, `abort`.
|
||||
|
||||
There are no unencrypted type indicators in tags or other visible fields. The relay sees only the `p` tag (an ephemeral pubkey with no link to any real identity) and opaque ciphertext.
|
||||
|
||||
## Event Validation
|
||||
|
||||
Before processing any `kind:24134` event, implementations MUST:
|
||||
|
||||
1. Validate the event `id` and `sig` per [NIP-01](01.md).
|
||||
2. Validate that `pubkey` is a valid, non-zero secp256k1 curve point per [BIP-340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki).
|
||||
3. Validate that the event contains a `p` tag whose value matches the local device's ephemeral public key. This guards against misdelivery by a malicious or buggy relay.
|
||||
4. Validate that `pubkey` matches the expected peer for the current session state:
|
||||
- _source_ expects events from `target_ephemeral_pubkey` (learned from the first valid `offer`).
|
||||
- _target_ expects events from `source_ephemeral_pubkey` (learned from the QR code).
|
||||
- Before the first valid `offer`, _source_ accepts events from any `pubkey` (since `target_ephemeral_pubkey` is not yet known), but MUST lock to that pubkey after accepting.
|
||||
5. Decrypt `content` per [NIP-44](44.md). The `content` field MUST be a valid NIP-44 v2 payload (base64, 132–87472 characters per NIP-44). Events with `content` outside this range MUST be silently discarded.
|
||||
6. Parse the decrypted JSON and validate the `type` field against the expected message for the current state.
|
||||
7. **Out-of-order messages**: A message whose `type` does not match the expected message for the current protocol state is considered out-of-order. Out-of-order messages MUST be silently discarded; the session state MUST NOT advance. Implementations MUST NOT send an `abort` in response to an out-of-order message, as doing so would allow a relay to probe session state.
|
||||
|
||||
The valid `type` for each state is:
|
||||
|
||||
| State | Role | Expected `type` |
|
||||
|-------|------|-----------------|
|
||||
| `Waiting` | Source | `offer` |
|
||||
| `Confirming` | Source | *(awaiting user; no inbound expected)* |
|
||||
| `Confirming` | Target | `sas-confirm` |
|
||||
| `AwaitingConfirmation` | Target | `payload` *(buffer until user confirms SAS; do not process until state advances to `Transferring`)* |
|
||||
| `Transferring` | Target | `payload` |
|
||||
| `PayloadExchanged` | Source | `complete` |
|
||||
|
||||
`abort` is valid in any non-terminal state from a known peer (see §Abort). All other combinations are out-of-order and MUST be discarded.
|
||||
|
||||
Events that fail any validation step MUST be silently discarded. Implementations MUST NOT reveal validation failure details to the relay or to the sender.
|
||||
|
||||
### Duplicate Event Handling
|
||||
|
||||
Relays MAY deliver the same event more than once (e.g., on reconnect or when multiple relay connections are active). Implementations MUST handle duplicate delivery idempotently.
|
||||
|
||||
An event is a duplicate if its `id` matches an event already successfully processed in the current session. Implementations MUST track the `id` of each successfully processed event and MUST silently discard any event whose `id` has already been processed.
|
||||
|
||||
Implementations SHOULD maintain a per-session set of processed event IDs. This set need not persist beyond the session lifetime (120 seconds maximum).
|
||||
|
||||
A duplicate `offer` event (same `id`) received after the source has already accepted an offer MUST be discarded, not treated as a new session attempt. A duplicate `payload` event received after the target has already imported the payload MUST be discarded; the target MUST NOT re-import or re-send `complete`.
|
||||
|
||||
## Pairing Protocol
|
||||
|
||||
### Step 1: Source Subscribes
|
||||
|
||||
After displaying the QR code, _source_ subscribes to the pairing relay for events tagged to its ephemeral public key:
|
||||
|
||||
```json
|
||||
["REQ", "<sub_id>", {"kinds": [24134], "#p": ["<source_ephemeral_pubkey>"]}]
|
||||
```
|
||||
|
||||
### Step 2: Target Sends Offer
|
||||
|
||||
_target_ scans the QR code, generates its own ephemeral secp256k1 keypair (`target_ephemeral_privkey`, `target_ephemeral_pubkey`), and publishes an `offer` event:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 24134,
|
||||
"pubkey": "<target_ephemeral_pubkey>",
|
||||
"content": "<NIP-44 encrypted>",
|
||||
"tags": [["p", "<source_ephemeral_pubkey>"]],
|
||||
"created_at": <unix_timestamp>,
|
||||
// id, sig per NIP-01
|
||||
}
|
||||
```
|
||||
|
||||
Encrypted plaintext:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "offer",
|
||||
"version": 1,
|
||||
"session_id": "<hex, 32 bytes>"
|
||||
}
|
||||
```
|
||||
|
||||
Where `session_id` is derived as:
|
||||
|
||||
```
|
||||
session_id = HKDF-SHA256(
|
||||
IKM = session_secret, // 32 bytes from QR code
|
||||
salt = "", // empty
|
||||
info = "nostr-pair-session-id",
|
||||
L = 32
|
||||
)
|
||||
```
|
||||
|
||||
The `session_id` proves the _target_ possesses the QR code's `session_secret` without revealing the secret on the wire.
|
||||
|
||||
_source_ MUST verify the `session_id` matches its own derivation. _source_ MUST accept at most one valid `offer` per session. After accepting an offer, _source_ MUST ignore all subsequent `offer` events and MUST record `target_ephemeral_pubkey` as the only valid peer for the remainder of the session.
|
||||
|
||||
### Step 3: SAS Verification
|
||||
|
||||
Both devices now have each other's ephemeral public keys. Both compute:
|
||||
|
||||
```
|
||||
ecdh_shared = ECDH(own_ephemeral_privkey, other_ephemeral_pubkey)
|
||||
```
|
||||
|
||||
Where `ecdh_shared` is the 32-byte x-coordinate of the shared point (unhashed), as produced by standard secp256k1 scalar multiplication.
|
||||
|
||||
Then:
|
||||
|
||||
```
|
||||
sas_input = HKDF-SHA256(
|
||||
IKM = ecdh_shared, // 32 bytes
|
||||
salt = session_secret, // 32 bytes from QR code
|
||||
info = "nostr-pair-sas-v1",
|
||||
L = 32
|
||||
)
|
||||
|
||||
sas_code = be_u32(sas_input[0..4]) mod 1000000
|
||||
```
|
||||
|
||||
Where `be_u32(bytes)` interprets the first 4 bytes of `sas_input` as a big-endian unsigned 32-bit integer.
|
||||
|
||||
Both devices display the `sas_code` as a zero-padded 6-digit decimal string (e.g., `"047291"`). The user MUST visually confirm the codes match on both screens before proceeding.
|
||||
|
||||
**UX requirement**: The confirmation prompt MUST clearly state what is being authorized. Example: *"You are about to transfer your Nostr identity to another device. Does your other device show: **047291**?"* with prominent Confirm and Deny buttons. If the user denies the SAS on either device, that device MUST immediately send `abort` with reason `"user_denied"`, discard all session state, and terminate the session. SAS denial is the primary MITM defense — implementations MUST NOT allow the protocol to continue after a denial.
|
||||
|
||||
After the user confirms on the _source_ device, _source_ publishes a `sas-confirm` event:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 24134,
|
||||
"pubkey": "<source_ephemeral_pubkey>",
|
||||
"content": "<NIP-44 encrypted>",
|
||||
"tags": [["p", "<target_ephemeral_pubkey>"]],
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Encrypted plaintext:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "sas-confirm",
|
||||
"transcript_hash": "<hex, 32 bytes>"
|
||||
}
|
||||
```
|
||||
|
||||
Where `transcript_hash` binds the confirmation to the full session transcript:
|
||||
|
||||
```
|
||||
transcript = session_id
|
||||
|| source_ephemeral_pubkey // 32 bytes, x-coordinate
|
||||
|| target_ephemeral_pubkey // 32 bytes, x-coordinate
|
||||
|| sas_input // 32 bytes
|
||||
|
||||
transcript_hash = HKDF-SHA256(
|
||||
IKM = transcript, // 128 bytes
|
||||
salt = session_secret,
|
||||
info = "nostr-pair-transcript-v1",
|
||||
L = 32
|
||||
)
|
||||
```
|
||||
|
||||
_target_ MUST compute the same `transcript_hash` and verify it matches before proceeding. Implementations MUST use constant-time comparison when checking `transcript_hash` to prevent timing side-channels. A mismatch indicates session inconsistency or parameter tampering; _target_ MUST send `abort` with reason `"sas_mismatch"`, discard any payload received in this session, and terminate. Note: because _source_ sends the payload immediately after `sas-confirm` (without waiting for an acknowledgment), the payload may already be in transit or delivered when the mismatch is detected. The transcript hash is a **detection** mechanism, not a prevention gate — MITM prevention relies on the user's visual SAS comparison on the _source_ device *before* the source confirms and sends the payload.
|
||||
|
||||
After verifying the transcript hash, _target_ enters the `AwaitingConfirmation` state. _target_ transitions to `Transferring` when the user confirms the SAS on the target device. _target_ MUST NOT import, process, or act on the secret material within any received `payload` event until **both** the transcript hash has been verified **and** the user has confirmed the SAS on the target device. (Implementations may NIP-44-decrypt the event content to validate the message `type` for state-machine routing. However, implementations MUST NOT deserialize, extract, log, persist, or act on the `payload` field within a `payload`-type message until both conditions are met. If early decryption is used, the decrypted content MUST be treated as opaque for all purposes other than `type` classification, and MUST be zeroized if the session is aborted before dual consent. The safest implementation strategy — and the one closest to the formal proof — is to buffer the raw NIP-44 ciphertext and defer all decryption until after dual consent.)
|
||||
|
||||
### Step 4: Payload Transfer
|
||||
|
||||
After the user confirms the SAS on the _source_ device, _source_ publishes the `sas-confirm` event (Step 3) followed immediately by a `payload` event:
|
||||
|
||||
Encrypted plaintext:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "payload",
|
||||
"payload_type": "<string>",
|
||||
"payload": "<string>"
|
||||
}
|
||||
```
|
||||
|
||||
Defined payload types:
|
||||
|
||||
| `payload_type` | Description | `payload` format |
|
||||
|----------------|-------------|------------------|
|
||||
| `nsec` | Private key transfer | [NIP-49](49.md) `ncryptsec1...` string (recommended) or `nsec1...` bech32 |
|
||||
| `bunker` | NIP-46 signer-initiated session | `bunker://...` URI as defined in [NIP-46](46.md) |
|
||||
| `connect` | NIP-46 client-initiated session | `nostrconnect://...` URI as defined in [NIP-46](46.md) |
|
||||
| `custom` | Application-specific data | String (see §Custom Payloads) |
|
||||
|
||||
**Payload size limits**: The total serialized JSON plaintext of a `kind:24134` event's decrypted content MUST NOT exceed 65,535 bytes (the NIP-44 v2 plaintext limit). For `payload` messages, this means the `payload` field plus JSON envelope overhead (typically 50–80 bytes depending on `payload_type` and JSON escaping) must fit within this limit. In practice, `payload` values up to 65,400 bytes are safe. Implementations MUST reject (silently discard) `payload` events where the decrypted plaintext JSON exceeds 65,535 bytes.
|
||||
|
||||
For the defined payload types (`nsec`, `bunker`, `connect`), payloads are expected to be well under 1,024 bytes. Implementations MAY enforce a stricter limit of 4,096 bytes for these types and SHOULD document any custom limit for `custom` payloads.
|
||||
|
||||
_Source_ implementations MUST NOT construct a `payload` event whose plaintext JSON exceeds 65,535 bytes; doing so will cause NIP-44 encryption to fail.
|
||||
|
||||
### Custom Payloads
|
||||
|
||||
The `custom` payload type carries application-defined data. The `payload` field MUST be a string. Applications that need to transfer structured data SHOULD encode it as JSON and then serialize the JSON object to a string (i.e., JSON-in-string, consistent with Nostr convention).
|
||||
|
||||
To prevent cross-application misinterpretation, applications using `custom` payloads SHOULD include an application identifier in the payload. The RECOMMENDED format is:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "payload",
|
||||
"payload_type": "custom",
|
||||
"payload": "{\"app\":\"com.example.myapp\",\"version\":1,\"data\":\"...\"}"
|
||||
}
|
||||
```
|
||||
|
||||
The `app` field SHOULD use reverse-DNS notation to namespace the payload. Implementations that receive a `custom` payload with an unrecognized `app` value SHOULD surface this to the user rather than silently discarding it.
|
||||
|
||||
`custom` payloads are subject to the general 65,535-byte plaintext limit (65,400 bytes is a safe practical bound for the `payload` field). Applications SHOULD document their expected payload size. Applications with payloads larger than 4,096 bytes SHOULD consider whether NIP-AB is the appropriate transport — NIP-AB is designed for short secrets, not bulk data transfer.
|
||||
|
||||
NIP-AB does not provide a mechanism for _target_ to reject a `custom` payload based on its content. If _target_ does not understand the payload, it SHOULD send `complete` with `success: false` and inform the user.
|
||||
|
||||
For `nsec` payloads using [NIP-49](49.md) `ncryptsec` format, clients SHOULD set `KEY_SECURITY_BYTE = 0x02` (client does not track provenance) unless the client can positively assert the key has never been handled insecurely, in which case `0x01` MAY be used.
|
||||
|
||||
### Step 5: Completion
|
||||
|
||||
_target_ decrypts the payload, imports the secret into secure storage, and SHOULD publish a `complete` event:
|
||||
|
||||
```jsonc
|
||||
{ "type": "complete", "success": true }
|
||||
```
|
||||
|
||||
**`complete` is advisory, not required for security.** The payload transfer is complete when _target_ successfully decrypts and stores the payload. `complete` is a best-effort acknowledgment that allows _source_ to display a success confirmation to the user.
|
||||
|
||||
**If _target_ crashes or disconnects after importing but before sending `complete`**: The import has succeeded. _target_ MUST NOT re-request the payload. On next launch, _target_ SHOULD display a success state (the key is present in storage). _source_ will time out waiting for `complete` and MAY display an ambiguous state ("Transfer may have succeeded — check your other device").
|
||||
|
||||
**`success: false`**: _target_ SHOULD send `complete` with `success: false` if it successfully received and decrypted the payload but failed to import it into secure storage (e.g., keychain write failed). This allows _source_ to inform the user of a partial failure. _source_ MUST NOT retry sending the payload in response to `success: false` — the session is over. The user must initiate a new pairing.
|
||||
|
||||
**Source timeout for `complete`**: _source_ SHOULD wait up to 30 seconds for `complete` after sending `payload`. If `complete` is not received within this window, _source_ SHOULD display an ambiguous confirmation ("Transfer sent — verify on your other device") rather than an error. _source_ MUST NOT re-send `payload`.
|
||||
|
||||
_source_ MUST process at most one `complete` event per session. Subsequent `complete` events MUST be silently discarded.
|
||||
|
||||
Both devices MUST close their subscriptions and discard their ephemeral keypairs after either (a) receiving `complete`, (b) the per-step timeout expires, or (c) the session timeout (120 seconds) expires. Implementations MUST zero the ephemeral private keys, session secret, and any decrypted payload plaintext from memory before freeing. On the _target_ side, the decrypted payload MUST be zeroed from working memory once it has been committed to platform-secure storage.
|
||||
|
||||
### Implementation Pseudocode
|
||||
|
||||
The following Python-like pseudocode is normative. Implementations MUST produce identical outputs for identical inputs.
|
||||
|
||||
```python
|
||||
# --- Key Derivation ---
|
||||
|
||||
def derive_session_id(session_secret: bytes) -> bytes:
|
||||
# session_secret: 32 bytes from QR code
|
||||
assert len(session_secret) == 32
|
||||
return hkdf_sha256(IKM=session_secret, salt=b"", info=b"nostr-pair-session-id", L=32)
|
||||
|
||||
def derive_sas_input(ecdh_shared: bytes, session_secret: bytes) -> bytes:
|
||||
# ecdh_shared: 32-byte x-coordinate of secp256k1 shared point (unhashed)
|
||||
assert len(ecdh_shared) == 32
|
||||
assert len(session_secret) == 32
|
||||
return hkdf_sha256(IKM=ecdh_shared, salt=session_secret, info=b"nostr-pair-sas-v1", L=32)
|
||||
|
||||
def derive_sas_code(sas_input: bytes) -> str:
|
||||
# Returns zero-padded 6-digit decimal string
|
||||
n = int.from_bytes(sas_input[0:4], byteorder='big')
|
||||
return str(n % 1_000_000).zfill(6)
|
||||
|
||||
def derive_transcript_hash(
|
||||
session_id: bytes,
|
||||
source_pubkey: bytes, # 32-byte x-coordinate
|
||||
target_pubkey: bytes, # 32-byte x-coordinate
|
||||
sas_input: bytes,
|
||||
session_secret: bytes
|
||||
) -> bytes:
|
||||
assert all(len(x) == 32 for x in [session_id, source_pubkey, target_pubkey, sas_input, session_secret])
|
||||
transcript = session_id + source_pubkey + target_pubkey + sas_input # 128 bytes
|
||||
return hkdf_sha256(IKM=transcript, salt=session_secret, info=b"nostr-pair-transcript-v1", L=32)
|
||||
|
||||
# --- Message Encryption (wraps NIP-44) ---
|
||||
|
||||
def encrypt_message(msg: dict, sender_privkey: bytes, recipient_pubkey: bytes) -> str:
|
||||
# msg: dict with "type" field and type-specific fields
|
||||
plaintext = json_encode(msg) # UTF-8 JSON, no trailing whitespace
|
||||
conversation_key = nip44_get_conversation_key(sender_privkey, recipient_pubkey)
|
||||
nonce = secure_random_bytes(32)
|
||||
return nip44_encrypt(plaintext, conversation_key, nonce)
|
||||
|
||||
def decrypt_message(ciphertext: str, recipient_privkey: bytes, sender_pubkey: bytes) -> dict:
|
||||
conversation_key = nip44_get_conversation_key(recipient_privkey, sender_pubkey)
|
||||
plaintext = nip44_decrypt(ciphertext, conversation_key)
|
||||
return json_decode(plaintext)
|
||||
|
||||
# --- Usage example ---
|
||||
# session_secret = secure_random_bytes(32)
|
||||
# session_id = derive_session_id(session_secret)
|
||||
# ecdh_shared = secp256k1_ecdh(own_privkey, peer_pubkey) # x-coordinate, unhashed
|
||||
# sas_input = derive_sas_input(ecdh_shared, session_secret)
|
||||
# sas_code = derive_sas_code(sas_input) # display to user, e.g. "047291"
|
||||
# transcript_hash = derive_transcript_hash(session_id, source_pub, target_pub, sas_input, session_secret)
|
||||
|
||||
# --- Transcript Verification (target side) ---
|
||||
# After receiving sas-confirm:
|
||||
# expected = derive_transcript_hash(session_id, source_pub, target_pub, sas_input, session_secret)
|
||||
# if not constant_time_equal(received_hash, expected):
|
||||
# discard_buffered_payload() # payload may have arrived early
|
||||
# send_abort(reason="sas_mismatch")
|
||||
# raise TranscriptMismatchError
|
||||
```
|
||||
|
||||
### Abort
|
||||
|
||||
Either device MAY send an `abort` message at any point during the protocol:
|
||||
|
||||
Encrypted plaintext:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "abort",
|
||||
"reason": "<string>"
|
||||
}
|
||||
```
|
||||
|
||||
Defined reason strings:
|
||||
|
||||
| `reason` | Meaning |
|
||||
|----------|---------|
|
||||
| `"sas_mismatch"` | SAS codes did not match, or transcript hash verification failed |
|
||||
| `"user_denied"` | User explicitly denied the pairing |
|
||||
| `"timeout"` | Session timed out |
|
||||
| `"protocol_error"` | Local fatal condition (e.g., internal state corruption, unrecoverable implementation error). MUST NOT be sent in response to a peer's out-of-order or validation-failing event — those MUST be silently discarded per §Event Validation. |
|
||||
|
||||
Upon receiving an `abort`, the other device MUST terminate the session, discard ephemeral keys, and inform the user. Implementations MAY define additional reason strings; unknown reasons SHOULD be treated as `"protocol_error"`.
|
||||
|
||||
## Protocol Diagram
|
||||
|
||||
```
|
||||
Source (Desktop) Relay Target (Phone)
|
||||
──────────────── ───── ───────────────
|
||||
Generate ephemeral keypair
|
||||
Generate session_secret
|
||||
Display QR code
|
||||
Subscribe: kind:24134
|
||||
#p: source_ephemeral_pubkey ──────►
|
||||
Scan QR code
|
||||
Generate ephemeral keypair
|
||||
◄─────────────────────── Publish offer
|
||||
{type:"offer", session_id}
|
||||
◄──────────────────────────────────
|
||||
Validate sig, pubkey, session_id
|
||||
Accept offer, lock to this peer
|
||||
Compute SAS code ◄─────────────────────────────────────────► Compute SAS code
|
||||
Display: "047291" Display: "047291"
|
||||
|
||||
[User confirms SAS on source]
|
||||
|
||||
Publish sas-confirm ──────────────►
|
||||
{type:"sas-confirm", ──────────────────────► Verify transcript_hash
|
||||
transcript_hash}
|
||||
Publish payload ──────────────────► (sent immediately;
|
||||
{type:"payload", source does not wait
|
||||
payload_type:"nsec", for target)
|
||||
payload:"ncryptsec1..."} ──────────────────────► Buffer payload
|
||||
|
||||
[User confirms SAS on target]
|
||||
|
||||
Decrypt payload
|
||||
Import to secure storage
|
||||
◄─────────────────────── Publish complete
|
||||
◄────────────────────────────────── {type:"complete"}
|
||||
|
||||
Discard ephemeral keys Discard ephemeral keys
|
||||
Zero session_secret Zero session_secret
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Man-in-the-Middle Attacks
|
||||
|
||||
An attacker who intercepts the QR code (e.g., by photographing the screen or creating a fake QR code) could attempt to race the legitimate _target_ and establish their own session. The SAS verification step prevents this: the attacker's ECDH shared secret will differ from the legitimate pair, producing a different SAS code. The user will observe mismatched codes and abort.
|
||||
|
||||
This is the same defense used by Matrix (emoji verification), Bluetooth Secure Simple Pairing, and ZRTP. Signal's device linking omitted SAS verification and was subsequently exploited by state-level attackers who created fake QR codes to silently link unauthorized devices.
|
||||
|
||||
Clients MUST display an unambiguous confirmation prompt. The prompt MUST explicitly state what is being authorized and display the SAS code prominently with a clear option to deny.
|
||||
|
||||
### Relay Compromise
|
||||
|
||||
A compromised relay can:
|
||||
- **Drop events** (denial of service) — mitigated by session timeout and retry with alternate relays
|
||||
- **Delay events** — mitigated by session timeout
|
||||
- **Attempt MITM** — defeated by SAS verification (relay does not possess ephemeral private keys)
|
||||
|
||||
A compromised relay **cannot**:
|
||||
- Read the payload (NIP-44 encrypted with ECDH keys the relay does not possess)
|
||||
- Forge events (events are signed by ephemeral keys; signatures are validated before processing)
|
||||
- Correlate pairing sessions with real user identities (ephemeral keys are unlinked to real identities)
|
||||
|
||||
### QR Code Exposure
|
||||
|
||||
The QR code contains only an ephemeral public key and a session secret. If an attacker captures the QR code and races the legitimate _target_ to send the first `offer`, the _source_ will accept the attacker's offer and compute a SAS using the attacker's ephemeral key. However:
|
||||
|
||||
1. The _source_ displays a SAS code derived from the ECDH shared secret with the attacker.
|
||||
2. The user's physical phone (the legitimate _target_) either (a) failed to connect (if the attacker's offer was accepted first) and shows an error, or (b) is not displaying any SAS code at all.
|
||||
3. The user observes that their phone does not show the expected SAS code and denies the pairing on the _source_.
|
||||
|
||||
The defense is **user verification against their physical device**, not cryptographic impossibility. This is the same security model as Bluetooth Secure Simple Pairing and ZRTP: the SAS step converts a network-level MITM into a physical-presence requirement.
|
||||
|
||||
The _source_ MUST reject additional `offer` events after accepting one. If the legitimate _target_'s offer arrives after an attacker's, the _target_ will receive no response and SHOULD time out.
|
||||
|
||||
### Session Timeout
|
||||
|
||||
Implementations MUST enforce a session timeout (recommended: 120 seconds from QR display). After timeout, the _source_ MUST discard the ephemeral keypair and session secret. A new QR code MUST be generated for a new attempt.
|
||||
|
||||
### Key Material on Two Devices
|
||||
|
||||
After an `nsec` transfer, the private key exists on both devices. This is an inherent tradeoff of key transfer versus remote signing ([NIP-46](46.md)). Clients MUST store imported keys in platform-secure storage (iOS Keychain, Android Keystore, OS-level credential managers).
|
||||
|
||||
### Replay Protection
|
||||
|
||||
Session secrets are random and single-use. Ephemeral keypairs are generated per session. Two independent mechanisms prevent cross-session replay:
|
||||
|
||||
**1. `p` tag binding**: Every event carries a `p` tag containing the recipient's ephemeral public key. The recipient validates that this tag matches their own ephemeral public key (§Event Validation, step 3). A replayed event from session A has `p` = `source_A_ephemeral_pubkey`; session B's source has a different ephemeral key and will reject it at the `p` tag check, before any decryption is attempted.
|
||||
|
||||
**2. NIP-44 key binding**: Even if the `p` tag check were bypassed, NIP-44 decryption would fail. The conversation key is derived from `ECDH(own_ephemeral_privkey, sender_pubkey)`. A replayed event encrypted for session A's keypair cannot be decrypted by session B's keypair.
|
||||
|
||||
These two mechanisms are independent; either alone is sufficient to prevent cross-session replay. Together they provide defense in depth.
|
||||
|
||||
**Within-session replay**: The state machine provides within-session replay protection. Once a message type has been processed and the state has advanced, a replayed copy of the same message is out-of-order and MUST be discarded (§Event Validation, item 7). The duplicate event ID check (§Duplicate Event Handling) provides an additional layer.
|
||||
|
||||
### Metadata Privacy
|
||||
|
||||
All pairing events use ephemeral pubkeys that are unlinked to the user's real Nostr identity. The relay cannot determine which real user is pairing devices.
|
||||
|
||||
Implementations SHOULD set `created_at` to the current time minus a random value between 0 and 30 seconds. This provides metadata privacy (obscuring the exact time of each protocol step) while remaining within the timestamp acceptance window of all known relay implementations.
|
||||
|
||||
Implementations MUST NOT set `created_at` to a future time. Implementations MUST NOT set `created_at` more than 60 seconds in the past, as some relays enforce a `created_at_lower_limit` (per NIP-11) and may reject events with timestamps too far in the past.
|
||||
|
||||
If a relay rejects an event with an `invalid: event creation date` error (NIP-01 `OK` message), the implementation SHOULD retry with `created_at` set to the current time (no jitter). The privacy benefit of jitter is secondary to successful delivery.
|
||||
|
||||
## Design Rationale
|
||||
|
||||
### Why HKDF for `session_id` instead of a direct hash?
|
||||
|
||||
`session_id = HKDF(session_secret, ...)` rather than `SHA256(session_secret)` provides domain separation. Using HKDF with a labeled `info` string ensures that the `session_id` output is cryptographically independent from any other value derived from `session_secret` (e.g., `sas_input`). This prevents cross-protocol attacks where an attacker tricks one derivation path into producing a value valid for another.
|
||||
|
||||
### Why 6-digit decimal SAS?
|
||||
|
||||
6 decimal digits provide ~20 bits of entropy (10^6 = ~2^20). An attacker who can race the legitimate target has a 1-in-1,000,000 chance of a matching SAS per attempt. The session timeout (120 seconds) and single-offer acceptance limit make brute force infeasible. Decimal was chosen over emoji (Matrix) for cross-client compatibility — emoji sets vary by platform and font, causing display inconsistencies. Decimal was chosen over 4-digit (Bluetooth) because 4 digits (1-in-10,000) is considered insufficient against targeted attacks.
|
||||
|
||||
### Why `session_secret` in the QR code instead of deriving it from the ephemeral keypair?
|
||||
|
||||
The `session_secret` is independent of the ephemeral keypair. This means that even if an attacker somehow learns the ephemeral private key (e.g., via a side-channel), they cannot compute the `session_id` or `sas_input` without also knowing `session_secret`. The QR code is a separate out-of-band channel; requiring knowledge of both the QR code AND the ECDH handshake provides defense-in-depth for session establishment (offer authentication and SAS derivation). Note: the payload encryption key is derived purely from ECDH and does not depend on `session_secret`, so this defense-in-depth applies to the pairing handshake, not to payload confidentiality directly.
|
||||
|
||||
### Why transcript binding (`transcript_hash`)?
|
||||
|
||||
The `transcript_hash` in `sas-confirm` commits the source to the exact session parameters: the `session_id`, both ephemeral public keys, and the `sas_input`. This gives the _target_ a cryptographic consistency check that detects session inconsistency or parameter tampering. (Cross-session replay is already prevented independently by `p`-tag binding and NIP-44 key binding — see §Replay Protection.) The transcript hash is **not** the MITM prevention mechanism — that role belongs to the user's visual SAS comparison on the _source_ device, which gates whether `sas-confirm` and the payload are sent at all.
|
||||
|
||||
### Why NIP-44 for event encryption instead of a custom scheme?
|
||||
|
||||
NIP-44 is the Nostr standard for authenticated encryption. Using it here means NIP-AB inherits NIP-44's security audit, test vectors, and broad implementation support. A custom scheme would require separate review and implementation work in every client.
|
||||
|
||||
### Audit
|
||||
|
||||
An independent security audit of this protocol is planned. Until an audit is completed, implementations in high-security contexts should treat this NIP as `draft` and conduct their own review.
|
||||
|
||||
## Formal Verification
|
||||
|
||||
A Tamarin model of the protocol lives at [NIP-AB.spthy](NIP-AB.spthy). The model focuses on the security-critical core of the protocol:
|
||||
|
||||
- QR distribution of `session_secret` and `source_ephemeral_pubkey`
|
||||
- `offer` authentication via possession of the QR secret
|
||||
- SAS comparison as an explicit user-mediated gate
|
||||
- `sas-confirm` transcript binding
|
||||
- encrypted `payload` delivery
|
||||
- advisory `complete` acknowledgment
|
||||
|
||||
The model treats the relay and network as a full **Dolev-Yao attacker**: the adversary can intercept, reorder, replay, drop, and fabricate messages. It also includes explicit compromise rules for:
|
||||
|
||||
- QR-code exposure (`session_secret` leaks out-of-band)
|
||||
- source-session compromise
|
||||
- target-session compromise
|
||||
|
||||
Under those assumptions, the proved lemmas are:
|
||||
|
||||
**Core security invariants:**
|
||||
|
||||
- **`executable_core_flow`** *(executability)*: the happy-path protocol completes — both sides reach `complete` with the same session and payload.
|
||||
- **`payload_requires_successful_sas_match`** *(SAS gate)*: an honest source can only send `payload` after a successful SAS match.
|
||||
- **`payload_secrecy_without_endpoint_compromise`** *(payload secrecy)*: the payload remains unknown to the attacker unless one endpoint session is compromised. QR-code exposure alone does not break secrecy, because the SAS gate pins delivery to an honest target-role execution in the model. (This assumes correct SAS verification — the model treats SAS comparison as perfect; the ~20-bit collision bound is a separate computational argument, see §Design Rationale.)
|
||||
- **`target_completion_agrees_on_source_payload`** *(target agreement)*: under no-compromise assumptions, if the target completes, then the source previously sent that exact payload in the same session.
|
||||
- **`source_completion_implies_prior_target_completion_without_compromise`** *(source completion soundness)*: under the same no-compromise assumptions, if the source accepts `complete`, the target previously sent `complete` for the same session. (The model abstracts away `success:true/false` semantics — this proves the `complete` event is authentic, not that import succeeded.)
|
||||
|
||||
- **`injective_target_source_agreement`** *(injective agreement, target → source)*: each target completion corresponds to a unique prior source payload send with the same `(sid, pkS, pkT, payload)`, and that send is itself unique. This is a one-directional injective mapping; the reverse (every send leads to a completion) is a liveness property not provable under Dolev-Yao scheduling.
|
||||
|
||||
**MITM resistance:**
|
||||
|
||||
- **`sas_match_implies_genuine_target`**: every SAS match is bound to a `pkT` that an honest target-role instance in the model actually generated (i.e., from `Target_Scan_QR_And_Send_Offer` with a fresh ephemeral). A network adversary that substitutes the offer's ephemeral key with an attacker-chosen value cannot cause the SAS-match rule to fire. This proves resistance to network key-substitution, not physical-device authenticity — the latter relies on the user's physical verification of the SAS code and is outside the symbolic model's scope.
|
||||
- **`payload_delivery_requires_genuine_target`** *(composition)*: no payload is ever sent under a `pkT` that lacks a prior honest target-role execution. Follows from the SAS gate combined with the genuine-target lemma.
|
||||
|
||||
**Dual consent and payload buffering:**
|
||||
|
||||
- **`target_decrypts_payload_only_after_dual_consent`**: the target never decrypts the payload without **both** transcript verification **and** an explicit user-approval step. The model proves a stronger abstraction than the spec requires: payload plaintext is not made available to protocol logic before both conditions are met. (The spec permits implementations to NIP-44-decrypt the event content early for message-type classification, but the model conservatively defers all decryption — this is strictly stronger. Early type-field decryption on the target is a local operation that does not emit network-observable messages or alter protocol flow; since the Dolev-Yao attacker already possesses the ciphertext, local decryption reveals nothing new to the adversary, and all proved properties (secrecy, agreement, MITM resistance) hold a fortiori for the spec's more permissive buffering model.)
|
||||
- **`decryption_requires_prior_buffering`**: every decryption is preceded by buffering — the intended two-phase flow (buffer ciphertext, then decrypt after approval) is explicit in the proof surface.
|
||||
- **`executable_payload_buffered_before_approval`** *(sanity)*: the payload **can** arrive and be buffered before the target user approves, proving the buffering path is reachable and the dual-consent gate is not vacuously enforced by message ordering alone.
|
||||
|
||||
**Reachability and anti-vacuousness:**
|
||||
|
||||
- **`executable_with_qr_leak`**, **`executable_with_source_compromise`**, **`executable_with_target_compromise`**: each compromise rule is reachable from protocol state (i.e., the compromise rules are not dead code), so the no-compromise guards in the secrecy and agreement lemmas are non-trivial.
|
||||
- **`source_compromise_can_leak_payload`**, **`target_compromise_can_leak_payload`**: there exist traces where endpoint compromise (leakage of session-ephemeral private keys) leads to attacker knowledge of the payload, confirming that the no-compromise guards in the secrecy lemma are load-bearing.
|
||||
|
||||
The Tamarin model intentionally abstracts away details that are orthogonal to the cryptographic proof:
|
||||
|
||||
- exact NIP-01 event IDs / Schnorr signatures — relay anti-forgery relies on these but is not proved symbolically
|
||||
- exact NIP-44 ciphertext framing, padding, version bytes, and nonce handling — modeled as ideal authenticated encryption (`senc`) over a DH-derived key
|
||||
- HKDF-SHA256 — collapsed to tagged hashes (`h(< label, inputs >)`) preserving domain separation but not RFC 5869 internals
|
||||
- ECDH — modeled as symbolic Diffie-Hellman, not exact secp256k1 x-coordinate extraction
|
||||
- SAS comparison — modeled as perfect (requiring actual key agreement); the ~20-bit collision bound (1/10^6) is a separate computational argument (see §Design Rationale)
|
||||
- timeout and abort branches
|
||||
- duplicate-event bookkeeping
|
||||
- `p`-tag validation and within-session replay protection — these are state-machine / implementation requirements, not Tamarin results
|
||||
- version negotiation (`version` field in `offer`)
|
||||
- `complete` success/failure semantics
|
||||
- payload typing (`nsec` / `bunker` / `connect` / `custom`)
|
||||
|
||||
Those behaviors remain normative in this document and in the Rust implementation; they are simply not the focus of the symbolic proof.
|
||||
|
||||
Run the proof with:
|
||||
|
||||
```bash
|
||||
tamarin-prover --prove crates/buzz-core/src/pairing/NIP-AB.spthy
|
||||
```
|
||||
|
||||
## Cryptographic Primitives
|
||||
|
||||
### ECDH
|
||||
|
||||
`secp256k1_ecdh(priv, pub)` is scalar multiplication of point `pub` by scalar `priv`, as defined in [BIP-340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki). The result is the shared point `P`; this function returns the 32-byte x-coordinate of `P` using BIP-340's `bytes(P)` encoding. The result is **not hashed**.
|
||||
|
||||
⚠️ **Implementation warning**: many secp256k1 libraries (including some bindings to libsecp256k1) hash the ECDH output with SHA-256 by default. This NIP requires the **unhashed** x-coordinate. Verify your library's behavior before shipping.
|
||||
|
||||
Private keys MUST be validated as scalars in range `[1, secp256k1_order - 1]`. Public keys MUST be validated as valid, non-zero curve points per BIP-340.
|
||||
|
||||
### HKDF-SHA256
|
||||
|
||||
[RFC 5869](https://datatracker.ietf.org/doc/html/rfc5869) with SHA-256.
|
||||
|
||||
- **Extract**: `PRK = HMAC-SHA256(salt, IKM)`. When `salt` is specified as `""` (empty string), use a zero-length byte array (not the string literal).
|
||||
- **Expand**: `OKM = HKDF-Expand(PRK, info, L)` where `info` is the UTF-8 encoding of the specified string and `L` is the output length in bytes.
|
||||
|
||||
### Operators and Notation
|
||||
|
||||
- `||` denotes byte array concatenation with no length prefixes or delimiters.
|
||||
- `x[i:j]` where `x` is a byte array returns bytes `i` (inclusive) through `j` (exclusive).
|
||||
- `be_u32(x)` interprets the first 4 bytes of `x` as a big-endian unsigned 32-bit integer.
|
||||
|
||||
### Constants
|
||||
|
||||
| Name | Value | Description |
|
||||
|------|-------|-------------|
|
||||
| `SESSION_TIMEOUT` | 120 seconds | Maximum time from QR display to session completion |
|
||||
| `STEP_TIMEOUT` | 30 seconds | Maximum time to wait for each protocol step |
|
||||
| `SAS_DIGITS` | 6 | Number of decimal digits in SAS code |
|
||||
| `SAS_MODULUS` | 1,000,000 | `10^SAS_DIGITS` |
|
||||
| `SESSION_SECRET_LEN` | 32 bytes | Length of session secret |
|
||||
| `MAX_URI_LEN` | 2048 characters | Maximum total length of the `nostrpair://` URI |
|
||||
| `MAX_PAYLOAD_LEN` | 65,400 bytes | Safe practical maximum for the `payload` field (65,535-byte NIP-44 limit minus JSON envelope overhead) |
|
||||
|
||||
## Test Vectors
|
||||
|
||||
```
|
||||
session_secret (hex):
|
||||
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2
|
||||
|
||||
source_ephemeral_privkey (hex):
|
||||
7f4c11a9c9d1e3b5a7f2e4d6c8b0a2f4e6d8c0b2a4f6e8d0c2b4a6f8e0d2c4b5
|
||||
|
||||
source_ephemeral_pubkey (hex):
|
||||
199e64ca60662cb2d6e91d16cb065be51ad74a6ee5f8c5b0fdc53d246611ed9a
|
||||
|
||||
target_ephemeral_privkey (hex):
|
||||
3a5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b3c5d7e9f1a3b5c7d9e1f3a5b
|
||||
|
||||
target_ephemeral_pubkey (hex):
|
||||
89a9fa762105d0aee2b19678246fe7b823aabbc4f4bf691a1ce8a70fcd36d6e4
|
||||
|
||||
session_id = HKDF-SHA256(IKM=session_secret, salt="", info="nostr-pair-session-id", L=32):
|
||||
fb357d0f8e8d5a5ba3b2a91cb18c119e1567b07ffa38cdebb73e68df78f5a380
|
||||
|
||||
ecdh_shared = ECDH(source_priv, target_pub) x-coordinate:
|
||||
9b4b6d6990713d89d6d9982e506ee1bbcde6f05c54d9d2978696e8a7274d4408
|
||||
|
||||
sas_input = HKDF-SHA256(IKM=ecdh_shared, salt=session_secret, info="nostr-pair-sas-v1", L=32):
|
||||
e8b03a329f3a0ac37fe7fbe929171e14b72812be67e33c5d6e193543c41798d3
|
||||
|
||||
sas_code = be_u32(sas_input[0..4]) mod 1000000:
|
||||
863346
|
||||
|
||||
transcript = session_id || source_pubkey || target_pubkey || sas_input (128 bytes)
|
||||
|
||||
transcript_hash = HKDF-SHA256(IKM=transcript, salt=session_secret, info="nostr-pair-transcript-v1", L=32):
|
||||
d662818ff8911fc60a2d025f8b8b4756107104e85888dd202d28db5ca2cf28d3
|
||||
```
|
||||
|
||||
Implementations MUST validate against these vectors. They can be reproduced with `buzz-pair test-vectors`.
|
||||
|
||||
A future external vector file (`nip-ab.vectors.json`) with a sha256 checksum committed in this document is planned. When published, it will include categorized intermediate-value vectors for each derivation step and negative/invalid test cases. The sha256 checksum will be the canonical commitment; implementations MUST verify against the checksum before using the file.
|
||||
|
||||
Implementations MUST also test rejection of invalid inputs. Examples of what to test:
|
||||
|
||||
- `session_secret` with wrong length (< 32 or > 32 bytes) → MUST be rejected
|
||||
- `session_secret` that is all zeros → MUST be rejected
|
||||
- `offer` with `session_id` that does not match the derived value → MUST be silently discarded
|
||||
- `sas-confirm` with a mismatched `transcript_hash` → MUST trigger `abort` with reason `"sas_mismatch"`
|
||||
- NIP-44 ciphertext with version byte ≠ `0x02` → MUST be silently discarded
|
||||
- `content` field outside the 132–87472 character range → MUST be silently discarded
|
||||
- decrypted plaintext JSON exceeding 65,535 bytes → MUST be silently discarded
|
||||
- Duplicate event `id` within a session → MUST be silently discarded
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Choosing a Pairing Relay
|
||||
|
||||
The _source_ encodes the relay URL in the QR code. Implementations MAY:
|
||||
- Use the user's preferred relay from [NIP-65](65.md)
|
||||
- Use a hardcoded default relay
|
||||
- Allow the user to choose
|
||||
|
||||
The protocol is secure regardless of relay trustworthiness. For additional metadata privacy, a relay that supports [NIP-42](42.md) AUTH is preferred but not required.
|
||||
|
||||
### SAS Display
|
||||
|
||||
Implementations MUST display the SAS code as a zero-padded 6-digit decimal number (e.g., `047291`). Implementations MAY additionally display an emoji representation for improved usability, but the 6-digit decimal MUST always be shown as the canonical representation to ensure cross-client compatibility.
|
||||
|
||||
### Secure Storage
|
||||
|
||||
After importing a key, clients MUST store it in platform-secure storage:
|
||||
- **iOS**: Keychain Services with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`
|
||||
- **Android**: Android Keystore or EncryptedSharedPreferences
|
||||
- **Desktop**: OS credential manager or encrypted keyring
|
||||
|
||||
### Error Handling
|
||||
|
||||
If _source_ receives an `offer` with an invalid `session_id`, it MUST silently ignore it and continue waiting for a valid offer (up to the session timeout).
|
||||
|
||||
If either device receives an event with an unexpected `type` for the current state, it MUST silently discard it (see §Event Validation, item 7 — out-of-order messages). Implementations MUST NOT send `abort` in response to an out-of-order message.
|
||||
|
||||
If either device does not receive the expected next message within a reasonable time (recommended: 30 seconds per step), it SHOULD send an `abort` with reason `"timeout"` and terminate the session.
|
||||
|
||||
### Concurrent Sessions
|
||||
|
||||
**Source**: A _source_ implementation MAY run multiple pairing sessions simultaneously. Each session MUST use a distinct ephemeral keypair and session secret, and therefore a distinct QR code. Sessions are fully independent — an event addressed to one session's ephemeral pubkey cannot affect another session. Implementations SHOULD limit the number of concurrent active sessions to a small number (recommended: 3) to prevent resource exhaustion.
|
||||
|
||||
**Target**: A _target_ implementation MAY scan multiple QR codes and run multiple pairing sessions simultaneously. Each session is independent. However, importing the same payload type (e.g., `nsec`) from two concurrent sessions is application-defined behavior; implementations SHOULD prompt the user to confirm each import individually.
|
||||
|
||||
**Session isolation**: Because each session uses independent ephemeral keypairs, there is no cryptographic interaction between concurrent sessions. A compromised or malicious session cannot affect the security of other sessions.
|
||||
|
||||
**UX recommendation**: Implementations SHOULD display each active session distinctly (e.g., by SAS code) so the user can match the correct QR code to the correct device.
|
||||
|
||||
## Multi-Relay Considerations
|
||||
|
||||
The QR URI format supports multiple `relay` parameters for redundancy. Multi-relay support is OPTIONAL — implementations that use a single relay are fully conformant. The guidance below is for implementations that choose to support multiple relays.
|
||||
|
||||
**Recommended relay count**: 1–3 relay URLs. More than 3 increases QR code size and connection overhead without proportional benefit.
|
||||
|
||||
**Source behavior**: _source_ SHOULD subscribe to **all** listed relays simultaneously. This ensures _target_ can reach _source_ regardless of which relay _target_ connects to first. Subscribing to all relays has no privacy cost since all events use ephemeral pubkeys.
|
||||
|
||||
**Target behavior**: _target_ SHOULD attempt to connect to listed relays in parallel and use the first relay that both (a) accepts the WebSocket connection and (b) successfully delivers the subscription (confirmed by receiving an `EOSE` or the first event). If a relay connection fails after the session is underway, _target_ MAY attempt the next relay in the list; however, _target_ MUST NOT construct a new `offer` event. If _target_ needs to reach _source_ via a different relay, _target_ SHOULD re-publish the **same signed `offer` event** (identical bytes, same event ID) to the new relay. This is safe because the event is already signed and addressed to `source_ephemeral_pubkey`; _source_ will deduplicate by event ID if it receives the offer on multiple relays.
|
||||
|
||||
**Cross-relay delivery**: Because _source_ subscribes to all listed relays, events published by _target_ to any listed relay will be received by _source_. The protocol is relay-agnostic: _source_ and _target_ do not need to be connected to the same relay simultaneously.
|
||||
|
||||
**Fallback**: If all listed relays fail, the session MUST be aborted. There is no relay discovery mechanism; the QR code is the authoritative relay list.
|
||||
|
||||
## Relation to Other NIPs
|
||||
|
||||
- [NIP-01](01.md): All pairing events are valid NIP-01 events.
|
||||
- [NIP-44](44.md): Used for all encryption within pairing events.
|
||||
- [NIP-46](46.md): This NIP can bootstrap a NIP-46 session via the `bunker` or `connect` payload types. NIP-46 provides ongoing remote signing; this NIP provides one-time secure transfer. They are complementary.
|
||||
- [NIP-49](49.md): Recommended format for `nsec` payloads.
|
||||
- [NIP-59](59.md): Gift Wrap uses ephemeral keys for metadata privacy; this NIP uses ephemeral keys for session isolation. Both demonstrate the pattern of throwaway Nostr identities for protocol-level operations.
|
||||
@@ -0,0 +1,455 @@
|
||||
theory NIP_AB
|
||||
begin
|
||||
|
||||
builtins: diffie-hellman, hashing, symmetric-encryption
|
||||
|
||||
rule Source_Start:
|
||||
[ Fr(~qr), Fr(~xs) ]
|
||||
--[
|
||||
SourceStarted(h(< 'session-id', ~qr >), 'g'^~xs)
|
||||
]->
|
||||
[
|
||||
SrcWaiting(~qr, ~xs),
|
||||
!QrVisible(~qr, 'g'^~xs),
|
||||
!SourceSecrets(~qr, ~xs)
|
||||
]
|
||||
|
||||
rule Leak_QR:
|
||||
[ !QrVisible(qr, pkS) ]
|
||||
--[
|
||||
QrLeaked(h(< 'session-id', qr >), pkS)
|
||||
]->
|
||||
[ Out(< qr, pkS >) ]
|
||||
|
||||
rule Compromise_Source_Session:
|
||||
[ !SourceSecrets(qr, xs) ]
|
||||
--[
|
||||
SourceCompromised(h(< 'session-id', qr >), 'g'^xs)
|
||||
]->
|
||||
[ Out(< qr, xs >) ]
|
||||
|
||||
rule Target_Scan_QR_And_Send_Offer:
|
||||
[ !QrVisible(qr, pkS), Fr(~xt) ]
|
||||
--[
|
||||
TargetStarted(h(< 'session-id', qr >), pkS, 'g'^~xt)
|
||||
]->
|
||||
[
|
||||
TgtOfferSent(qr, pkS, ~xt, 'g'^~xt),
|
||||
!TargetSecrets(qr, pkS, ~xt),
|
||||
Out(
|
||||
<
|
||||
'offer_evt',
|
||||
'g'^~xt,
|
||||
senc(
|
||||
< 'offer', h(< 'session-id', qr >) >,
|
||||
h(< 'pair-key', pkS^~xt >)
|
||||
)
|
||||
>
|
||||
)
|
||||
]
|
||||
|
||||
rule Compromise_Target_Session:
|
||||
[ !TargetSecrets(qr, pkS, xt) ]
|
||||
--[
|
||||
TargetCompromised(h(< 'session-id', qr >), pkS, 'g'^xt)
|
||||
]->
|
||||
[ Out(< qr, xt >) ]
|
||||
|
||||
rule Source_Accepts_Offer:
|
||||
[ SrcWaiting(qr, xs),
|
||||
In(
|
||||
<
|
||||
'offer_evt',
|
||||
pkT,
|
||||
senc(
|
||||
< 'offer', h(< 'session-id', qr >) >,
|
||||
h(< 'pair-key', pkT^xs >)
|
||||
)
|
||||
>
|
||||
)
|
||||
]
|
||||
--[
|
||||
SourceAcceptedOffer(h(< 'session-id', qr >), 'g'^xs, pkT)
|
||||
]->
|
||||
[
|
||||
SrcSasReady(qr, xs, pkT)
|
||||
]
|
||||
|
||||
// SAS comparison is modeled as perfect: the rule requires both devices'
|
||||
// state facts with matching cryptographic material, so it only fires when
|
||||
// the ECDH shared secret (and therefore the SAS code) genuinely agrees.
|
||||
// In reality SAS provides ~20 bits of entropy (1/10^6 collision); that
|
||||
// computational bound is argued separately in §Design Rationale.
|
||||
rule User_Compares_Matching_SAS:
|
||||
[ SrcSasReady(qr, xs, pkT),
|
||||
TgtOfferSent(qr, 'g'^xs, xt, pkT)
|
||||
]
|
||||
--[
|
||||
SasMatched(
|
||||
h(< 'session-id', qr >),
|
||||
'g'^xs,
|
||||
pkT,
|
||||
h(< 'sas', pkT^xs, qr >)
|
||||
)
|
||||
]->
|
||||
[
|
||||
SrcUserConfirmed(qr, xs, pkT),
|
||||
TgtAwaitingSasConfirm(qr, 'g'^xs, xt)
|
||||
]
|
||||
|
||||
// Transcript hash matches spec §Step 3 (see also PR #346 clarifications):
|
||||
// transcript_hash = HKDF(IKM = session_id || pkS || pkT || sas_input,
|
||||
// salt = session_secret, info = "nostr-pair-transcript-v1")
|
||||
// Symbolically we collapse HKDF to h(.) and rely on collision resistance;
|
||||
// qr (session_secret) is already committed via session_id and sas_input, so
|
||||
// we do not include it again here.
|
||||
//
|
||||
// Per §Step 3, the transcript hash is a detection mechanism for session
|
||||
// inconsistency, not the MITM prevention gate (that role belongs to the
|
||||
// user's SAS comparison, modeled by User_Compares_Matching_SAS above).
|
||||
rule Source_Sends_SAS_Confirm:
|
||||
[ SrcUserConfirmed(qr, xs, pkT) ]
|
||||
--[
|
||||
SourceSentSasConfirm(h(< 'session-id', qr >), 'g'^xs, pkT)
|
||||
]->
|
||||
[
|
||||
SrcReadyPayload(qr, xs, pkT),
|
||||
Out(
|
||||
<
|
||||
'sas_confirm_evt',
|
||||
senc(
|
||||
<
|
||||
'sas-confirm',
|
||||
h(
|
||||
<
|
||||
'transcript',
|
||||
h(< 'session-id', qr >),
|
||||
'g'^xs,
|
||||
pkT,
|
||||
h(< 'sas', pkT^xs, qr >)
|
||||
>
|
||||
)
|
||||
>,
|
||||
h(< 'pair-key', pkT^xs >)
|
||||
)
|
||||
>
|
||||
)
|
||||
]
|
||||
|
||||
rule Target_Receives_SAS_Confirm:
|
||||
[ TgtAwaitingSasConfirm(qr, pkS, xt),
|
||||
In(
|
||||
<
|
||||
'sas_confirm_evt',
|
||||
senc(
|
||||
<
|
||||
'sas-confirm',
|
||||
h(
|
||||
<
|
||||
'transcript',
|
||||
h(< 'session-id', qr >),
|
||||
pkS,
|
||||
'g'^xt,
|
||||
h(< 'sas', pkS^xt, qr >)
|
||||
>
|
||||
)
|
||||
>,
|
||||
h(< 'pair-key', pkS^xt >)
|
||||
)
|
||||
>
|
||||
)
|
||||
]
|
||||
--[
|
||||
TargetVerifiedTranscript(h(< 'session-id', qr >), pkS, 'g'^xt)
|
||||
]->
|
||||
[
|
||||
TgtAwaitingUserApproval(qr, pkS, xt),
|
||||
TgtCanBuffer(qr, pkS, xt)
|
||||
]
|
||||
|
||||
// Target user approval: AwaitingConfirmation -> Transferring (spec §Step 3).
|
||||
// This fires only after transcript verification (Target_Receives_SAS_Confirm).
|
||||
rule Target_User_Approves_After_Transcript:
|
||||
[ TgtAwaitingUserApproval(qr, pkS, xt) ]
|
||||
--[
|
||||
TargetUserApproved(h(< 'session-id', qr >), pkS, 'g'^xt)
|
||||
]->
|
||||
[
|
||||
TgtTransferring(qr, pkS, xt)
|
||||
]
|
||||
|
||||
rule Source_Sends_Payload:
|
||||
[ SrcReadyPayload(qr, xs, pkT), Fr(~payload) ]
|
||||
--[
|
||||
SourceSentPayload(h(< 'session-id', qr >), 'g'^xs, pkT, ~payload),
|
||||
PayloadMarkedSecret(h(< 'session-id', qr >), ~payload)
|
||||
]->
|
||||
[
|
||||
SrcAwaitingComplete(qr, xs, pkT, ~payload),
|
||||
Out(
|
||||
<
|
||||
'payload_evt',
|
||||
senc(
|
||||
< 'payload', ~payload >,
|
||||
h(< 'pair-key', pkT^xs >)
|
||||
)
|
||||
>
|
||||
)
|
||||
]
|
||||
|
||||
// --- Payload buffering (spec §Event Validation, §Step 3-4) ---
|
||||
//
|
||||
// Per #346, the source sends payload immediately after sas-confirm without
|
||||
// waiting for the target. The target may therefore receive the encrypted
|
||||
// payload while still in AwaitingConfirmation (before user approval).
|
||||
// The spec requires: buffer the ciphertext, do NOT decrypt or import until
|
||||
// both transcript_hash is verified AND the user confirms SAS on the target.
|
||||
//
|
||||
// We model this as two rules:
|
||||
// 1. Target_Buffers_Payload — receives ciphertext into a holding fact
|
||||
// WITHOUT extracting the plaintext. The rule validates that the
|
||||
// ciphertext is encrypted under the session's DH-derived key
|
||||
// (h(< 'pair-key', pkS^xt >)), matching the spec's requirement that
|
||||
// invalid events are silently discarded without advancing state.
|
||||
// Only fires after transcript verification (linear TgtCanBuffer),
|
||||
// matching the spec's state table where `payload` is valid only in
|
||||
// AwaitingConfirmation or Transferring (both post-transcript-verify).
|
||||
// The linear fact is consumed, so at most one payload can be buffered
|
||||
// per session — matching the spec's single-payload semantics.
|
||||
// 2. Target_Decrypts_Payload — pattern-matches senc() to extract plaintext.
|
||||
// Requires both TgtTransferring (post-approval) and the buffered
|
||||
// ciphertext. This is the dual-consent gate: decryption only happens
|
||||
// after transcript verification + user approval.
|
||||
|
||||
rule Target_Buffers_Payload:
|
||||
[ TgtCanBuffer(qr, pkS, xt),
|
||||
In(< 'payload_evt', senc(msg, h(< 'pair-key', pkS^xt >)) >)
|
||||
]
|
||||
--[
|
||||
TargetBufferedPayload(h(< 'session-id', qr >), pkS, 'g'^xt)
|
||||
]->
|
||||
[
|
||||
TgtPayloadBuffer(qr, pkS, xt, senc(msg, h(< 'pair-key', pkS^xt >)))
|
||||
]
|
||||
|
||||
// Target decrypts the payload only after entering Transferring state
|
||||
// (transcript verified + user approved). This is the dual-consent gate:
|
||||
// the senc() pattern match here is the symbolic decryption operation.
|
||||
rule Target_Decrypts_Payload:
|
||||
[ TgtTransferring(qr, pkS, xt),
|
||||
TgtPayloadBuffer(qr, pkS, xt,
|
||||
senc(
|
||||
< 'payload', payload >,
|
||||
h(< 'pair-key', pkS^xt >)
|
||||
)
|
||||
)
|
||||
]
|
||||
--[
|
||||
TargetDecryptedPayload(h(< 'session-id', qr >), pkS, 'g'^xt, payload)
|
||||
]->
|
||||
[
|
||||
TgtHasPayload(qr, pkS, xt, payload)
|
||||
]
|
||||
|
||||
rule Target_Sends_Complete:
|
||||
[ TgtHasPayload(qr, pkS, xt, payload) ]
|
||||
--[
|
||||
TargetCompleted(h(< 'session-id', qr >), pkS, 'g'^xt, payload)
|
||||
]->
|
||||
[
|
||||
TgtDone(qr, pkS, xt, payload),
|
||||
Out(
|
||||
<
|
||||
'complete_evt',
|
||||
senc('complete', h(< 'pair-key', pkS^xt >))
|
||||
>
|
||||
)
|
||||
]
|
||||
|
||||
rule Source_Receives_Complete:
|
||||
[ SrcAwaitingComplete(qr, xs, pkT, payload),
|
||||
In(
|
||||
<
|
||||
'complete_evt',
|
||||
senc('complete', h(< 'pair-key', pkT^xs >))
|
||||
>
|
||||
)
|
||||
]
|
||||
--[
|
||||
SourceCompleted(h(< 'session-id', qr >), 'g'^xs, pkT, payload)
|
||||
]->
|
||||
[
|
||||
SrcDone(qr, xs, pkT, payload)
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// Core security lemmas (invariants)
|
||||
// ============================================================================
|
||||
|
||||
// Happy path: both sides complete with the same session and payload.
|
||||
lemma executable_core_flow:
|
||||
exists-trace
|
||||
"Ex sid pkS pkT payload #i #j.
|
||||
TargetCompleted(sid, pkS, pkT, payload) @ i
|
||||
& SourceCompleted(sid, pkS, pkT, payload) @ j"
|
||||
|
||||
// SAS gate: source never sends the payload without a prior SAS match.
|
||||
lemma payload_requires_successful_sas_match:
|
||||
"All sid pkS pkT payload #i.
|
||||
SourceSentPayload(sid, pkS, pkT, payload) @ i
|
||||
==> (Ex sas #j.
|
||||
SasMatched(sid, pkS, pkT, sas) @ j
|
||||
& #j < #i)"
|
||||
|
||||
// Payload secrecy: without endpoint compromise, the payload is secret.
|
||||
// Note: this holds even under QR-code leak. The SAS gate prevents a MITM
|
||||
// from causing the source to send the payload under the attacker's key,
|
||||
// because the SAS rule only fires when the source's accepted pkT matches
|
||||
// a genuine fresh target ephemeral (see sas_match_implies_genuine_target).
|
||||
lemma payload_secrecy_without_endpoint_compromise:
|
||||
"All sid payload #i.
|
||||
PayloadMarkedSecret(sid, payload) @ i
|
||||
& not (Ex pkS #r. SourceCompromised(sid, pkS) @ r)
|
||||
& not (Ex pkS pkT #r. TargetCompromised(sid, pkS, pkT) @ r)
|
||||
==> not (Ex #j. K(payload) @ j)"
|
||||
|
||||
// Target-side agreement: if the target completes, the source genuinely
|
||||
// sent that exact payload under this session.
|
||||
lemma target_completion_agrees_on_source_payload:
|
||||
"All sid pkS pkT payload #i.
|
||||
TargetCompleted(sid, pkS, pkT, payload) @ i
|
||||
& not (Ex pkS2 #r. SourceCompromised(sid, pkS2) @ r)
|
||||
& not (Ex pkS2 pkT2 #r. TargetCompromised(sid, pkS2, pkT2) @ r)
|
||||
==> (Ex #j.
|
||||
SourceSentPayload(sid, pkS, pkT, payload) @ j
|
||||
& #j < #i)"
|
||||
|
||||
// Source-side completion soundness: if the source sees `complete`, the
|
||||
// target really completed this session.
|
||||
lemma source_completion_implies_prior_target_completion_without_compromise:
|
||||
"All sid pkS pkT payload #i.
|
||||
SourceCompleted(sid, pkS, pkT, payload) @ i
|
||||
& not (Ex pkS2 #r. SourceCompromised(sid, pkS2) @ r)
|
||||
& not (Ex pkS2 pkT2 #r. TargetCompromised(sid, pkS2, pkT2) @ r)
|
||||
==> (Ex #j.
|
||||
TargetCompleted(sid, pkS, pkT, payload) @ j
|
||||
& #j < #i)"
|
||||
|
||||
// Injective agreement (target → source): each target completion corresponds
|
||||
// to a unique source payload send, and that send is itself unique. This is
|
||||
// one-directional; the reverse (every send leads to a completion) is a
|
||||
// liveness property not provable under Dolev-Yao scheduling.
|
||||
lemma injective_target_source_agreement:
|
||||
"All sid pkS pkT payload #i.
|
||||
TargetCompleted(sid, pkS, pkT, payload) @ i
|
||||
& not (Ex pkS2 #r. SourceCompromised(sid, pkS2) @ r)
|
||||
& not (Ex pkS2 pkT2 #r. TargetCompromised(sid, pkS2, pkT2) @ r)
|
||||
==> (Ex #j.
|
||||
SourceSentPayload(sid, pkS, pkT, payload) @ j
|
||||
& #j < #i
|
||||
& not (Ex #i2.
|
||||
TargetCompleted(sid, pkS, pkT, payload) @ i2
|
||||
& not (#i2 = #i))
|
||||
& not (Ex #j2.
|
||||
SourceSentPayload(sid, pkS, pkT, payload) @ j2
|
||||
& not (#j2 = #j)))"
|
||||
|
||||
// ============================================================================
|
||||
// MITM-resistance test cases
|
||||
// ============================================================================
|
||||
|
||||
// "MITM does not work": any SAS match pins the source's view of pkT to an
|
||||
// actual target-generated ephemeral ('g'^~xt from Target_Scan_QR_And_Send_Offer).
|
||||
// A network adversary who substitutes the offer's pkT with an attacker-chosen
|
||||
// value can never make this lemma's conclusion hold, because the fresh ~xt
|
||||
// in TargetStarted is outside attacker knowledge.
|
||||
lemma sas_match_implies_genuine_target:
|
||||
"All sid pkS pkT sas #i.
|
||||
SasMatched(sid, pkS, pkT, sas) @ i
|
||||
==> (Ex #j.
|
||||
TargetStarted(sid, pkS, pkT) @ j
|
||||
& #j < #i)"
|
||||
|
||||
// Composition: no payload is ever sent under a pkT the real target did not
|
||||
// produce. Follows from payload_requires_successful_sas_match combined with
|
||||
// sas_match_implies_genuine_target, and is the explicit no-MITM guarantee.
|
||||
lemma payload_delivery_requires_genuine_target:
|
||||
"All sid pkS pkT payload #i.
|
||||
SourceSentPayload(sid, pkS, pkT, payload) @ i
|
||||
==> (Ex #j.
|
||||
TargetStarted(sid, pkS, pkT) @ j
|
||||
& #j < #i)"
|
||||
|
||||
// Dual-consent gate (spec §Step 3, PR #346): the target never processes
|
||||
// (decrypts/imports) a payload without BOTH transcript verification AND
|
||||
// an explicit user-approval step. The payload may arrive and be buffered
|
||||
// earlier (see Target_Buffers_Payload), but processing is gated.
|
||||
lemma target_decrypts_payload_only_after_dual_consent:
|
||||
"All sid pkS pkT payload #i.
|
||||
TargetDecryptedPayload(sid, pkS, pkT, payload) @ i
|
||||
==> (Ex #j #k.
|
||||
TargetVerifiedTranscript(sid, pkS, pkT) @ j
|
||||
& TargetUserApproved(sid, pkS, pkT) @ k
|
||||
& #j < #i
|
||||
& #k < #i)"
|
||||
|
||||
// ============================================================================
|
||||
// Reachability / sanity test cases
|
||||
// ============================================================================
|
||||
//
|
||||
// These exists-trace lemmas prove that the compromise model is meaningful
|
||||
// (each compromise rule is actually reachable within a valid protocol run)
|
||||
// and that compromise genuinely breaks payload confidentiality. Without
|
||||
// these, a trivially unreachable compromise rule would make the no-compromise
|
||||
// secrecy claims vacuous.
|
||||
|
||||
lemma executable_with_qr_leak:
|
||||
exists-trace
|
||||
"Ex sid pkS #r. QrLeaked(sid, pkS) @ r"
|
||||
|
||||
lemma executable_with_source_compromise:
|
||||
exists-trace
|
||||
"Ex sid pkS #r. SourceCompromised(sid, pkS) @ r"
|
||||
|
||||
lemma executable_with_target_compromise:
|
||||
exists-trace
|
||||
"Ex sid pkS pkT #r. TargetCompromised(sid, pkS, pkT) @ r"
|
||||
|
||||
// Buffer-then-decrypt sequencing: every decryption is preceded by buffering.
|
||||
// Makes the intended two-phase flow explicit in the proof surface.
|
||||
lemma decryption_requires_prior_buffering:
|
||||
"All sid pkS pkT payload #i.
|
||||
TargetDecryptedPayload(sid, pkS, pkT, payload) @ i
|
||||
==> (Ex #j.
|
||||
TargetBufferedPayload(sid, pkS, pkT) @ j
|
||||
& #j < #i)"
|
||||
|
||||
// Sanity: the payload CAN arrive (be buffered) before the target user
|
||||
// approves. This proves the buffering path is reachable and the dual-consent
|
||||
// gate is not vacuously enforced by message ordering alone.
|
||||
lemma executable_payload_buffered_before_approval:
|
||||
exists-trace
|
||||
"Ex sid pkS pkT #i #j.
|
||||
TargetBufferedPayload(sid, pkS, pkT) @ i
|
||||
& TargetUserApproved(sid, pkS, pkT) @ j
|
||||
& #i < #j"
|
||||
|
||||
// Source-side compromise: an attacker who learns xs can decrypt the payload.
|
||||
// Counter-example to a naive "secrecy always holds" claim; justifies the
|
||||
// `not SourceCompromised` guard in payload_secrecy_without_endpoint_compromise.
|
||||
lemma source_compromise_can_leak_payload:
|
||||
exists-trace
|
||||
"Ex sid pkS payload #i #j #k.
|
||||
PayloadMarkedSecret(sid, payload) @ i
|
||||
& SourceCompromised(sid, pkS) @ j
|
||||
& K(payload) @ k"
|
||||
|
||||
// Target-side compromise: same story, from the target side.
|
||||
lemma target_compromise_can_leak_payload:
|
||||
exists-trace
|
||||
"Ex sid pkS pkT payload #i #j #k.
|
||||
PayloadMarkedSecret(sid, payload) @ i
|
||||
& TargetCompromised(sid, pkS, pkT) @ j
|
||||
& K(payload) @ k"
|
||||
|
||||
end
|
||||
@@ -0,0 +1,413 @@
|
||||
//! NIP-AB HKDF-SHA256 key derivation primitives.
|
||||
//!
|
||||
//! All functions are pure (no I/O, no side effects) and operate on fixed-size
|
||||
//! `[u8; 32]` arrays. The underlying HKDF implementation is
|
||||
//! [`nostr::util::hkdf`], which uses `bitcoin::hashes` internally.
|
||||
//!
|
||||
//! # Derivation overview
|
||||
//!
|
||||
//! ```text
|
||||
//! session_secret (32 bytes, random)
|
||||
//! │
|
||||
//! ├─► derive_session_id → session_id (HKDF, salt=[], info="nostr-pair-session-id")
|
||||
//! │
|
||||
//! ├─► derive_sas(ecdh_shared, …)
|
||||
//! │ ├─ sas_input (HKDF, salt=session_secret, info="nostr-pair-sas-v1")
|
||||
//! │ └─ sas_code = be_u32(sas_input[0..4]) % 1_000_000
|
||||
//! │
|
||||
//! └─► derive_transcript_hash(session_id, src_pk, tgt_pk, sas_input, …)
|
||||
//! └─ transcript_hash (HKDF, salt=session_secret,
|
||||
//! info="nostr-pair-transcript-v1")
|
||||
//! ```
|
||||
|
||||
use nostr::hashes::Hash as _;
|
||||
use nostr::util::hkdf;
|
||||
|
||||
const INFO_SESSION_ID: &[u8] = b"nostr-pair-session-id";
|
||||
const INFO_SAS: &[u8] = b"nostr-pair-sas-v1";
|
||||
const INFO_TRANSCRIPT: &[u8] = b"nostr-pair-transcript-v1";
|
||||
|
||||
/// Run HKDF-SHA256(IKM=`ikm`, salt=`salt`, info=`info`) and return 32 bytes.
|
||||
///
|
||||
/// Uses `nostr::util::hkdf::{extract, expand}` directly so we don't pull in
|
||||
/// an extra `hkdf` crate dependency.
|
||||
fn hkdf32(salt: &[u8], ikm: &[u8], info: &[u8]) -> [u8; 32] {
|
||||
let prk = hkdf::extract(salt, ikm);
|
||||
let okm = hkdf::expand(&prk.to_byte_array(), info, 32);
|
||||
// HKDF-Expand with L=32 and SHA-256 (HashLen=32) always produces exactly
|
||||
// 32 bytes (one iteration, truncated to L). Copy into a fixed-size array
|
||||
// without expect/unwrap.
|
||||
let mut out = [0u8; 32];
|
||||
out.copy_from_slice(&okm[..32]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Derive the session ID from the session secret.
|
||||
///
|
||||
/// ```text
|
||||
/// session_id = HKDF-SHA256(IKM=session_secret, salt=[], info="nostr-pair-session-id", L=32)
|
||||
/// ```
|
||||
///
|
||||
/// The session ID is safe to share publicly (e.g., in the QR code or as a
|
||||
/// Nostr event tag). It uniquely identifies the pairing session without
|
||||
/// revealing the secret.
|
||||
pub fn derive_session_id(session_secret: &[u8; 32]) -> [u8; 32] {
|
||||
hkdf32(b"", session_secret, INFO_SESSION_ID)
|
||||
}
|
||||
|
||||
/// Derive the Short Authentication String (SAS) code and the raw SAS input.
|
||||
///
|
||||
/// ```text
|
||||
/// sas_input = HKDF-SHA256(IKM=ecdh_shared, salt=session_secret, info="nostr-pair-sas-v1", L=32)
|
||||
/// sas_code = be_u32(sas_input[0..4]) mod 1_000_000
|
||||
/// ```
|
||||
///
|
||||
/// Returns `(sas_code, sas_input)`. The caller needs `sas_input` to compute
|
||||
/// the transcript hash — see [`derive_transcript_hash`].
|
||||
///
|
||||
/// `ecdh_shared` is the raw 32-byte x-coordinate from
|
||||
/// `nostr::util::generate_shared_key(own_secret, other_pubkey)`.
|
||||
pub fn derive_sas(ecdh_shared: &[u8; 32], session_secret: &[u8; 32]) -> (u32, [u8; 32]) {
|
||||
let sas_input = hkdf32(session_secret, ecdh_shared, INFO_SAS);
|
||||
let sas_code =
|
||||
u32::from_be_bytes([sas_input[0], sas_input[1], sas_input[2], sas_input[3]]) % 1_000_000;
|
||||
(sas_code, sas_input)
|
||||
}
|
||||
|
||||
/// Derive the transcript hash that binds all session parameters together.
|
||||
///
|
||||
/// ```text
|
||||
/// transcript = session_id ‖ source_pubkey ‖ target_pubkey ‖ sas_input (128 bytes)
|
||||
/// transcript_hash = HKDF-SHA256(IKM=transcript, salt=session_secret,
|
||||
/// info="nostr-pair-transcript-v1", L=32)
|
||||
/// ```
|
||||
///
|
||||
/// Both parties must independently compute this value and compare it before
|
||||
/// exchanging the actual payload. A mismatch means the session is compromised.
|
||||
///
|
||||
/// `sas_input` is the second return value of [`derive_sas`].
|
||||
pub fn derive_transcript_hash(
|
||||
session_id: &[u8; 32],
|
||||
source_pubkey: &[u8; 32],
|
||||
target_pubkey: &[u8; 32],
|
||||
sas_input: &[u8; 32],
|
||||
session_secret: &[u8; 32],
|
||||
) -> [u8; 32] {
|
||||
// Concatenate into a 128-byte transcript.
|
||||
let mut transcript = [0u8; 128];
|
||||
transcript[0..32].copy_from_slice(session_id);
|
||||
transcript[32..64].copy_from_slice(source_pubkey);
|
||||
transcript[64..96].copy_from_slice(target_pubkey);
|
||||
transcript[96..128].copy_from_slice(sas_input);
|
||||
|
||||
hkdf32(session_secret, &transcript, INFO_TRANSCRIPT)
|
||||
}
|
||||
|
||||
/// Format a SAS code as a zero-padded 6-digit string.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use buzz_core::pairing::crypto::format_sas;
|
||||
/// assert_eq!(format_sas(291), "000291");
|
||||
/// assert_eq!(format_sas(47291), "047291");
|
||||
/// assert_eq!(format_sas(999999), "999999");
|
||||
/// assert_eq!(format_sas(0), "000000");
|
||||
/// ```
|
||||
pub fn format_sas(code: u32) -> String {
|
||||
format!("{code:06}")
|
||||
}
|
||||
|
||||
/// Constant-time comparison of two 32-byte arrays.
|
||||
///
|
||||
/// Returns `true` iff all bytes are equal. Uses [`subtle::ConstantTimeEq`]
|
||||
/// to guarantee the comparison is not optimized into a short-circuit by the
|
||||
/// compiler, preventing timing side-channels on secret-derived values like
|
||||
/// transcript hashes and session IDs.
|
||||
pub fn ct_eq(a: &[u8; 32], b: &[u8; 32]) -> bool {
|
||||
use subtle::ConstantTimeEq;
|
||||
a.ct_eq(b).into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// session_secret = 0xa1b2c3d4…
|
||||
fn session_secret() -> [u8; 32] {
|
||||
hex_to_32("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2")
|
||||
}
|
||||
|
||||
/// source ephemeral private key bytes (used to derive pubkey for transcript test)
|
||||
fn source_privkey_bytes() -> [u8; 32] {
|
||||
hex_to_32("7f4c11a9c9d1e3b5a7f2e4d6c8b0a2f4e6d8c0b2a4f6e8d0c2b4a6f8e0d2c4b5")
|
||||
}
|
||||
|
||||
/// target ephemeral private key bytes
|
||||
fn target_privkey_bytes() -> [u8; 32] {
|
||||
hex_to_32("3a5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b3c5d7e9f1a3b5c7d9e1f3a5b")
|
||||
}
|
||||
|
||||
fn hex_to_32(s: &str) -> [u8; 32] {
|
||||
let bytes = hex::decode(s).expect("valid hex");
|
||||
bytes.try_into().expect("32 bytes")
|
||||
}
|
||||
|
||||
fn bytes_to_hex(b: &[u8]) -> String {
|
||||
hex::encode(b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_id_is_deterministic() {
|
||||
let secret = session_secret();
|
||||
let id1 = derive_session_id(&secret);
|
||||
let id2 = derive_session_id(&secret);
|
||||
assert_eq!(id1, id2, "session_id must be deterministic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_id_is_32_bytes() {
|
||||
let id = derive_session_id(&session_secret());
|
||||
assert_eq!(id.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_id_differs_from_secret() {
|
||||
let secret = session_secret();
|
||||
let id = derive_session_id(&secret);
|
||||
assert_ne!(id, secret, "session_id must not equal the raw secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_id_test_vector() {
|
||||
let id = derive_session_id(&session_secret());
|
||||
assert_eq!(
|
||||
bytes_to_hex(&id),
|
||||
"fb357d0f8e8d5a5ba3b2a91cb18c119e1567b07ffa38cdebb73e68df78f5a380",
|
||||
"session_id must match NIP-AB spec test vector"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sas_code_is_six_digits() {
|
||||
// Use a synthetic ECDH shared secret (just some fixed bytes).
|
||||
let ecdh = hex_to_32("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20");
|
||||
let (code, _) = derive_sas(&ecdh, &session_secret());
|
||||
assert!(code < 1_000_000, "SAS code must be < 1_000_000, got {code}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sas_is_deterministic() {
|
||||
let ecdh = hex_to_32("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20");
|
||||
let (code1, input1) = derive_sas(&ecdh, &session_secret());
|
||||
let (code2, input2) = derive_sas(&ecdh, &session_secret());
|
||||
assert_eq!(code1, code2);
|
||||
assert_eq!(input1, input2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sas_changes_with_different_ecdh() {
|
||||
let ecdh1 = hex_to_32("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20");
|
||||
let ecdh2 = hex_to_32("ff02030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20");
|
||||
let (code1, _) = derive_sas(&ecdh1, &session_secret());
|
||||
let (code2, _) = derive_sas(&ecdh2, &session_secret());
|
||||
assert_ne!(
|
||||
code1, code2,
|
||||
"different ECDH inputs must produce different SAS codes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sas_with_real_ecdh_keys() {
|
||||
use nostr::{Keys, SecretKey};
|
||||
|
||||
let src_sk = SecretKey::from_slice(&source_privkey_bytes()).expect("valid key");
|
||||
let tgt_sk = SecretKey::from_slice(&target_privkey_bytes()).expect("valid key");
|
||||
let src_keys = Keys::new(src_sk);
|
||||
let tgt_keys = Keys::new(tgt_sk);
|
||||
|
||||
// ECDH: source computes shared key with target's pubkey
|
||||
let ecdh_from_src =
|
||||
nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key())
|
||||
.unwrap();
|
||||
// ECDH: target computes shared key with source's pubkey (must match)
|
||||
let ecdh_from_tgt =
|
||||
nostr::util::generate_shared_key(tgt_keys.secret_key(), &src_keys.public_key())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(ecdh_from_src, ecdh_from_tgt, "ECDH must be symmetric");
|
||||
|
||||
let (code, sas_input) = derive_sas(&ecdh_from_src, &session_secret());
|
||||
println!("sas_code = {}", format_sas(code));
|
||||
println!("sas_input = {}", bytes_to_hex(&sas_input));
|
||||
|
||||
assert!(code < 1_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_hash_is_deterministic() {
|
||||
use nostr::{Keys, SecretKey};
|
||||
|
||||
let src_sk = SecretKey::from_slice(&source_privkey_bytes()).expect("valid key");
|
||||
let tgt_sk = SecretKey::from_slice(&target_privkey_bytes()).expect("valid key");
|
||||
let src_keys = Keys::new(src_sk);
|
||||
let tgt_keys = Keys::new(tgt_sk);
|
||||
|
||||
let session_id = derive_session_id(&session_secret());
|
||||
let ecdh = nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key())
|
||||
.unwrap();
|
||||
let (_, sas_input) = derive_sas(&ecdh, &session_secret());
|
||||
|
||||
let src_pk: [u8; 32] = src_keys.public_key().to_bytes();
|
||||
let tgt_pk: [u8; 32] = tgt_keys.public_key().to_bytes();
|
||||
|
||||
let h1 =
|
||||
derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input, &session_secret());
|
||||
let h2 =
|
||||
derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input, &session_secret());
|
||||
assert_eq!(h1, h2);
|
||||
}
|
||||
|
||||
/// Full test vector suite — all values pinned against the NIP-AB spec.
|
||||
#[test]
|
||||
fn all_test_vectors() {
|
||||
use nostr::{Keys, SecretKey};
|
||||
|
||||
let src_sk = SecretKey::from_slice(&source_privkey_bytes()).expect("valid key");
|
||||
let tgt_sk = SecretKey::from_slice(&target_privkey_bytes()).expect("valid key");
|
||||
let src_keys = Keys::new(src_sk);
|
||||
let tgt_keys = Keys::new(tgt_sk);
|
||||
|
||||
// Pubkeys
|
||||
assert_eq!(
|
||||
bytes_to_hex(&src_keys.public_key().to_bytes()),
|
||||
"199e64ca60662cb2d6e91d16cb065be51ad74a6ee5f8c5b0fdc53d246611ed9a"
|
||||
);
|
||||
assert_eq!(
|
||||
bytes_to_hex(&tgt_keys.public_key().to_bytes()),
|
||||
"89a9fa762105d0aee2b19678246fe7b823aabbc4f4bf691a1ce8a70fcd36d6e4"
|
||||
);
|
||||
|
||||
// ECDH
|
||||
let ecdh = nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
bytes_to_hex(&ecdh),
|
||||
"9b4b6d6990713d89d6d9982e506ee1bbcde6f05c54d9d2978696e8a7274d4408"
|
||||
);
|
||||
|
||||
// Session ID
|
||||
let session_id = derive_session_id(&session_secret());
|
||||
assert_eq!(
|
||||
bytes_to_hex(&session_id),
|
||||
"fb357d0f8e8d5a5ba3b2a91cb18c119e1567b07ffa38cdebb73e68df78f5a380"
|
||||
);
|
||||
|
||||
// SAS
|
||||
let (sas_code, sas_input) = derive_sas(&ecdh, &session_secret());
|
||||
assert_eq!(
|
||||
bytes_to_hex(&sas_input),
|
||||
"e8b03a329f3a0ac37fe7fbe929171e14b72812be67e33c5d6e193543c41798d3"
|
||||
);
|
||||
assert_eq!(format_sas(sas_code), "863346");
|
||||
|
||||
// Transcript hash
|
||||
let src_pk = src_keys.public_key().to_bytes();
|
||||
let tgt_pk = tgt_keys.public_key().to_bytes();
|
||||
let transcript_hash =
|
||||
derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input, &session_secret());
|
||||
assert_eq!(
|
||||
bytes_to_hex(&transcript_hash),
|
||||
"d662818ff8911fc60a2d025f8b8b4756107104e85888dd202d28db5ca2cf28d3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_hash_sensitive_to_pubkey_order() {
|
||||
use nostr::{Keys, SecretKey};
|
||||
|
||||
let src_sk = SecretKey::from_slice(&source_privkey_bytes()).expect("valid key");
|
||||
let tgt_sk = SecretKey::from_slice(&target_privkey_bytes()).expect("valid key");
|
||||
let src_keys = Keys::new(src_sk);
|
||||
let tgt_keys = Keys::new(tgt_sk);
|
||||
|
||||
let session_id = derive_session_id(&session_secret());
|
||||
let ecdh = nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key())
|
||||
.unwrap();
|
||||
let (_, sas_input) = derive_sas(&ecdh, &session_secret());
|
||||
|
||||
let src_pk: [u8; 32] = src_keys.public_key().to_bytes();
|
||||
let tgt_pk: [u8; 32] = tgt_keys.public_key().to_bytes();
|
||||
|
||||
let h_correct =
|
||||
derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input, &session_secret());
|
||||
// Swap source and target — must produce a different hash.
|
||||
let h_swapped =
|
||||
derive_transcript_hash(&session_id, &tgt_pk, &src_pk, &sas_input, &session_secret());
|
||||
assert_ne!(
|
||||
h_correct, h_swapped,
|
||||
"transcript_hash must be sensitive to pubkey order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_sas_zero_padding() {
|
||||
assert_eq!(format_sas(0), "000000");
|
||||
assert_eq!(format_sas(1), "000001");
|
||||
assert_eq!(format_sas(291), "000291");
|
||||
assert_eq!(format_sas(47291), "047291");
|
||||
assert_eq!(format_sas(999999), "999999");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_sas_always_six_chars() {
|
||||
for code in [0u32, 1, 99, 1000, 99999, 100000, 999999] {
|
||||
let s = format_sas(code);
|
||||
assert_eq!(s.len(), 6, "format_sas({code}) = {s:?} (expected 6 chars)");
|
||||
assert!(s.chars().all(|c| c.is_ascii_digit()), "all digits: {s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_derivation_round_trip() {
|
||||
use nostr::{Keys, SecretKey};
|
||||
|
||||
// Simulate both sides of the pairing independently deriving the same values.
|
||||
let src_sk = SecretKey::from_slice(&source_privkey_bytes()).expect("valid key");
|
||||
let tgt_sk = SecretKey::from_slice(&target_privkey_bytes()).expect("valid key");
|
||||
let src_keys = Keys::new(src_sk);
|
||||
let tgt_keys = Keys::new(tgt_sk);
|
||||
let secret = session_secret();
|
||||
|
||||
// Both sides derive the same session_id.
|
||||
let session_id = derive_session_id(&secret);
|
||||
|
||||
// Both sides compute ECDH (symmetric).
|
||||
let ecdh_src =
|
||||
nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key())
|
||||
.unwrap();
|
||||
let ecdh_tgt =
|
||||
nostr::util::generate_shared_key(tgt_keys.secret_key(), &src_keys.public_key())
|
||||
.unwrap();
|
||||
assert_eq!(ecdh_src, ecdh_tgt, "ECDH must be symmetric");
|
||||
|
||||
// Both sides derive the same SAS.
|
||||
let (code_src, sas_input_src) = derive_sas(&ecdh_src, &secret);
|
||||
let (code_tgt, sas_input_tgt) = derive_sas(&ecdh_tgt, &secret);
|
||||
assert_eq!(code_src, code_tgt, "SAS codes must match");
|
||||
assert_eq!(sas_input_src, sas_input_tgt, "sas_input must match");
|
||||
|
||||
// Both sides derive the same transcript hash (using the agreed pubkey ordering).
|
||||
let src_pk: [u8; 32] = src_keys.public_key().to_bytes();
|
||||
let tgt_pk: [u8; 32] = tgt_keys.public_key().to_bytes();
|
||||
|
||||
let th_src = derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input_src, &secret);
|
||||
let th_tgt = derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input_tgt, &secret);
|
||||
assert_eq!(th_src, th_tgt, "transcript hashes must match");
|
||||
|
||||
println!(
|
||||
"✅ Round-trip OK: sas={} transcript={}",
|
||||
format_sas(code_src),
|
||||
bytes_to_hex(&th_src)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! NIP-AB device pairing — crypto primitives, message types, and error types.
|
||||
//!
|
||||
//! NIP-AB enables two Nostr devices to securely exchange a secret (e.g., an
|
||||
//! `nsec` or a NIP-46 bunker connection string) over an untrusted relay, using:
|
||||
//!
|
||||
//! 1. **HKDF-SHA256** for all key derivation (session ID, SAS code, transcript hash).
|
||||
//! 2. **ECDH** (via [`nostr::util::generate_shared_key`]) for the shared secret.
|
||||
//! 3. **NIP-44 v2** for encrypting the message payloads.
|
||||
//! 4. **Short Authentication String (SAS)** for out-of-band confirmation.
|
||||
//!
|
||||
//! # Module layout
|
||||
//!
|
||||
//! | Module | Contents |
|
||||
//! |--------|----------|
|
||||
//! | [`crypto`] | Pure HKDF derivation functions |
|
||||
//! | [`types`] | Serde-serializable pairing message types |
|
||||
//!
|
||||
//! # Error handling
|
||||
//!
|
||||
//! All fallible operations in the pairing flow return [`PairingError`].
|
||||
|
||||
pub mod crypto;
|
||||
pub mod qr;
|
||||
pub mod session;
|
||||
pub mod types;
|
||||
|
||||
pub use qr::QrPayload;
|
||||
pub use session::{PairingSession, Role, SessionState};
|
||||
pub use types::{AbortReason, PairingMessage, PayloadType};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur during a NIP-AB pairing session.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PairingError {
|
||||
/// The scanned QR URI was not a valid NIP-AB pairing URI.
|
||||
#[error("invalid QR URI: {0}")]
|
||||
InvalidQr(String),
|
||||
|
||||
/// The session ID extracted from a message was not a valid 32-byte hex string.
|
||||
#[error("invalid session ID")]
|
||||
InvalidSessionId,
|
||||
|
||||
/// The SAS code shown on both devices did not match — session must be aborted.
|
||||
#[error("SAS mismatch")]
|
||||
SasMismatch,
|
||||
|
||||
/// The transcript hash received from the peer did not match the locally computed value.
|
||||
#[error("transcript hash mismatch")]
|
||||
TranscriptMismatch,
|
||||
|
||||
/// A message arrived out of sequence or with the wrong type for the current state.
|
||||
#[error("unexpected message type: expected {expected}, got {got}")]
|
||||
UnexpectedMessage {
|
||||
/// The message type that was expected at this point in the protocol.
|
||||
expected: String,
|
||||
/// The message type that was actually received.
|
||||
got: String,
|
||||
},
|
||||
|
||||
/// The pairing session exceeded its time limit without completing.
|
||||
#[error("session expired")]
|
||||
SessionExpired,
|
||||
|
||||
/// NIP-44 encryption or decryption failed.
|
||||
#[error("NIP-44 error: {0}")]
|
||||
Nip44(#[from] nostr::nips::nip44::Error),
|
||||
|
||||
/// JSON serialization or deserialization failed.
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
/// A public key string could not be parsed.
|
||||
#[error("invalid pubkey: {0}")]
|
||||
InvalidPubkey(String),
|
||||
|
||||
/// Event signing or construction failed.
|
||||
#[error("event signing failed: {0}")]
|
||||
SigningError(String),
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
//! NIP-AB QR code URI encoding and decoding.
|
||||
//!
|
||||
//! The QR code encodes a `nostrpair://` URI that the scanning device uses to
|
||||
//! bootstrap a pairing session. The URI carries:
|
||||
//!
|
||||
//! - The source device's ephemeral public key (hex, 64 chars)
|
||||
//! - A 32-byte session secret shared between both devices (hex, 64 chars)
|
||||
//! - One or more relay URLs where the pairing messages will be exchanged
|
||||
//! - A protocol version (`v=1`)
|
||||
//!
|
||||
//! # URI format
|
||||
//!
|
||||
//! ```text
|
||||
//! nostrpair://<source_pubkey_hex>?secret=<session_secret_hex>&relay=<url-encoded-relay>&v=1
|
||||
//! ```
|
||||
//!
|
||||
//! Multiple relays are represented as repeated `relay=` parameters:
|
||||
//!
|
||||
//! ```text
|
||||
//! nostrpair://abc123...?secret=def456...&relay=wss%3A%2F%2Frelay1.example.com&relay=wss%3A%2F%2Frelay2.example.com&v=1
|
||||
//! ```
|
||||
//!
|
||||
//! All characters unsafe in a query-parameter value (`:`, `/`, `?`, `#`,
|
||||
//! `&`, `=`, `%`, and space) are percent-encoded.
|
||||
|
||||
use nostr::PublicKey;
|
||||
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use super::PairingError;
|
||||
|
||||
/// Data encoded in the QR code displayed by the source device.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QrPayload {
|
||||
/// The source device's ephemeral public key.
|
||||
pub source_pubkey: PublicKey,
|
||||
/// 32-byte session secret shared between both devices.
|
||||
///
|
||||
/// This is generated fresh for each pairing session and never reused.
|
||||
pub session_secret: [u8; 32],
|
||||
/// One or more relay URLs where pairing messages will be exchanged.
|
||||
pub relays: Vec<String>,
|
||||
/// Protocol version. Always `1` for this implementation.
|
||||
///
|
||||
/// Encoded as `v=1` in the URI. Absent in legacy URIs; defaults to `1`
|
||||
/// on decode for backward compatibility. Values > 1 are rejected.
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
/// Zero the session secret on drop using `zeroize` to prevent dead-store
|
||||
/// elimination by the compiler (plain `fill(0)` can be optimized away).
|
||||
impl Drop for QrPayload {
|
||||
fn drop(&mut self) {
|
||||
self.session_secret.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a [`QrPayload`] as a `nostrpair://` URI.
|
||||
///
|
||||
/// Relay URLs are percent-encoded (`:` → `%3A`, `/` → `%2F`) so they can
|
||||
/// safely appear as query parameter values.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use buzz_core::pairing::qr::{QrPayload, encode_qr};
|
||||
/// use nostr::Keys;
|
||||
///
|
||||
/// let keys = Keys::generate();
|
||||
/// let payload = QrPayload {
|
||||
/// source_pubkey: keys.public_key(),
|
||||
/// session_secret: [0u8; 32],
|
||||
/// relays: vec!["wss://relay.example.com".to_string()],
|
||||
/// version: 1,
|
||||
/// };
|
||||
/// let uri = encode_qr(&payload);
|
||||
/// assert!(uri.starts_with("nostrpair://"));
|
||||
/// ```
|
||||
pub fn encode_qr(payload: &QrPayload) -> String {
|
||||
let pubkey_hex = payload.source_pubkey.to_hex();
|
||||
let secret_hex = hex::encode(payload.session_secret);
|
||||
|
||||
let mut uri = format!("nostrpair://{}?secret={}", pubkey_hex, secret_hex);
|
||||
|
||||
for relay in &payload.relays {
|
||||
uri.push_str("&relay=");
|
||||
uri.push_str(&url_encode(relay));
|
||||
}
|
||||
|
||||
uri.push_str("&v=1");
|
||||
|
||||
uri
|
||||
}
|
||||
|
||||
/// Decode a `nostrpair://` URI into a [`QrPayload`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PairingError::InvalidQr`] if:
|
||||
/// - The scheme is not `nostrpair`
|
||||
/// - The public key is not a valid 64-char hex string
|
||||
/// - The `secret` parameter is missing or not a valid 64-char hex string
|
||||
/// - No `relay` parameters are present
|
||||
pub fn decode_qr(uri: &str) -> Result<QrPayload, PairingError> {
|
||||
// NIP-AB §QR Code Format: URI length MUST NOT exceed 2048 characters.
|
||||
if uri.len() > 2048 {
|
||||
return Err(PairingError::InvalidQr(format!(
|
||||
"URI exceeds 2048-character limit ({} chars)",
|
||||
uri.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// Split scheme from the rest.
|
||||
let rest = uri
|
||||
.strip_prefix("nostrpair://")
|
||||
.ok_or_else(|| PairingError::InvalidQr("URI must start with nostrpair://".into()))?;
|
||||
|
||||
// Split pubkey from query string.
|
||||
let (pubkey_hex, query) = match rest.split_once('?') {
|
||||
Some((pk, q)) => (pk, q),
|
||||
None => {
|
||||
return Err(PairingError::InvalidQr(
|
||||
"missing query string (expected ?secret=…&relay=…)".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Validate pubkey: must be exactly 64 lowercase hex chars (NIP-AB §QR Code Format).
|
||||
if pubkey_hex.len() != 64 || !pubkey_hex.chars().all(is_lowercase_hex) {
|
||||
return Err(PairingError::InvalidQr(format!(
|
||||
"pubkey must be 64 lowercase hex chars, got {:?}",
|
||||
pubkey_hex
|
||||
)));
|
||||
}
|
||||
let source_pubkey = PublicKey::from_hex(pubkey_hex)
|
||||
.map_err(|e| PairingError::InvalidQr(format!("invalid pubkey: {e}")))?;
|
||||
|
||||
// Parse query parameters.
|
||||
let mut secret_hex: Option<&str> = None;
|
||||
let mut relays: Vec<String> = Vec::new();
|
||||
let mut version: Option<u32> = None;
|
||||
|
||||
for pair in query.split('&') {
|
||||
if let Some((key, value)) = pair.split_once('=') {
|
||||
match key {
|
||||
"secret" => secret_hex = Some(value),
|
||||
"relay" => relays.push(url_decode(value)),
|
||||
"v" => version = value.parse::<u32>().ok(),
|
||||
_ => {} // ignore unknown params
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to version 1 if absent (backward compat); reject unsupported versions.
|
||||
let version = version.unwrap_or(1);
|
||||
if version != 1 {
|
||||
return Err(PairingError::InvalidQr(format!(
|
||||
"unsupported protocol version {version}, expected 1"
|
||||
)));
|
||||
}
|
||||
|
||||
// Validate secret: must be exactly 64 hex chars.
|
||||
let secret_str = secret_hex
|
||||
.ok_or_else(|| PairingError::InvalidQr("missing 'secret' query parameter".into()))?;
|
||||
|
||||
if secret_str.len() != 64 || !secret_str.chars().all(is_lowercase_hex) {
|
||||
return Err(PairingError::InvalidQr(format!(
|
||||
"secret must be 64 lowercase hex chars, got {:?}",
|
||||
secret_str
|
||||
)));
|
||||
}
|
||||
let secret_bytes = hex::decode(secret_str)
|
||||
.map_err(|e| PairingError::InvalidQr(format!("invalid secret hex: {e}")))?;
|
||||
let session_secret: [u8; 32] = secret_bytes
|
||||
.try_into()
|
||||
.map_err(|_| PairingError::InvalidQr("secret must be exactly 32 bytes".into()))?;
|
||||
|
||||
// NIP-AB §Test Vectors: all-zeros session_secret MUST be rejected.
|
||||
if session_secret == [0u8; 32] {
|
||||
return Err(PairingError::InvalidQr(
|
||||
"session_secret must not be all zeros".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Must have at least one relay.
|
||||
if relays.is_empty() {
|
||||
return Err(PairingError::InvalidQr(
|
||||
"at least one 'relay' query parameter is required".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate relay URLs — parse fully and require WebSocket scheme + host.
|
||||
// Prefix-matching alone would accept malformed URLs that crash downstream.
|
||||
for relay in &relays {
|
||||
let parsed = url::Url::parse(relay)
|
||||
.map_err(|e| PairingError::InvalidQr(format!("invalid relay URL {:?}: {e}", relay)))?;
|
||||
match parsed.scheme() {
|
||||
"wss" | "ws" => {}
|
||||
other => {
|
||||
return Err(PairingError::InvalidQr(format!(
|
||||
"relay URL must use wss:// or ws:// scheme, got {:?}",
|
||||
other
|
||||
)));
|
||||
}
|
||||
}
|
||||
if parsed.host().is_none() {
|
||||
return Err(PairingError::InvalidQr(format!(
|
||||
"relay URL has no host: {:?}",
|
||||
relay
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(QrPayload {
|
||||
source_pubkey,
|
||||
session_secret,
|
||||
relays,
|
||||
version,
|
||||
})
|
||||
}
|
||||
|
||||
/// Percent-encode a relay URL for use as a query parameter value.
|
||||
///
|
||||
/// Uses `percent-encoding` crate's `NON_ALPHANUMERIC` set, which encodes
|
||||
/// everything except ASCII alphanumerics. This is a strict superset of the
|
||||
/// characters unsafe in query-parameter values (`:`, `/`, `?`, `#`, `&`,
|
||||
/// `=`, `%`, space) — safe by construction.
|
||||
fn url_encode(s: &str) -> String {
|
||||
utf8_percent_encode(s, NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
|
||||
/// Percent-decode a query parameter value.
|
||||
///
|
||||
/// Falls back to lossy UTF-8 conversion for non-UTF-8 sequences (which
|
||||
/// shouldn't appear in valid relay URLs, but we handle it safely).
|
||||
fn url_decode(s: &str) -> String {
|
||||
percent_decode_str(s).decode_utf8_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// NIP-AB §QR Code Format requires lowercase hex only (`0-9`, `a-f`).
|
||||
fn is_lowercase_hex(c: char) -> bool {
|
||||
c.is_ascii_digit() || ('a'..='f').contains(&c)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr::Keys;
|
||||
|
||||
fn make_payload(relays: Vec<String>) -> QrPayload {
|
||||
let keys = Keys::generate();
|
||||
QrPayload {
|
||||
source_pubkey: keys.public_key(),
|
||||
session_secret: [0xab; 32],
|
||||
relays,
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Round-trip encode/decode
|
||||
#[test]
|
||||
fn round_trip_single_relay() {
|
||||
let original = make_payload(vec!["wss://relay.example.com".to_string()]);
|
||||
let uri = encode_qr(&original);
|
||||
let decoded = decode_qr(&uri).expect("decode should succeed");
|
||||
|
||||
assert_eq!(original.source_pubkey, decoded.source_pubkey);
|
||||
assert_eq!(original.session_secret, decoded.session_secret);
|
||||
assert_eq!(original.relays, decoded.relays);
|
||||
}
|
||||
|
||||
// 7. Handle multiple relays
|
||||
#[test]
|
||||
fn round_trip_multiple_relays() {
|
||||
let original = make_payload(vec![
|
||||
"wss://relay1.example.com".to_string(),
|
||||
"wss://relay2.example.com".to_string(),
|
||||
"wss://relay3.example.com".to_string(),
|
||||
]);
|
||||
let uri = encode_qr(&original);
|
||||
let decoded = decode_qr(&uri).expect("decode should succeed");
|
||||
|
||||
assert_eq!(decoded.relays.len(), 3);
|
||||
assert_eq!(decoded.relays, original.relays);
|
||||
}
|
||||
|
||||
// 8. Handle URL-encoded relay URLs
|
||||
#[test]
|
||||
fn url_encoding_round_trip() {
|
||||
let relay = "wss://relay.example.com/path";
|
||||
let encoded = url_encode(relay);
|
||||
// NON_ALPHANUMERIC encodes dots too — stricter than necessary but safe.
|
||||
assert_eq!(encoded, "wss%3A%2F%2Frelay%2Eexample%2Ecom%2Fpath");
|
||||
let decoded = url_decode(&encoded);
|
||||
assert_eq!(decoded, relay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_relay_with_path() {
|
||||
let original = make_payload(vec!["wss://relay.example.com/nostr".to_string()]);
|
||||
let uri = encode_qr(&original);
|
||||
let decoded = decode_qr(&uri).expect("decode should succeed");
|
||||
assert_eq!(decoded.relays[0], "wss://relay.example.com/nostr");
|
||||
}
|
||||
|
||||
// 2. Reject missing scheme
|
||||
#[test]
|
||||
fn reject_missing_scheme() {
|
||||
let err = decode_qr("https://relay.example.com").unwrap_err();
|
||||
assert!(
|
||||
matches!(err, PairingError::InvalidQr(_)),
|
||||
"expected InvalidQr, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_wrong_scheme() {
|
||||
let err = decode_qr("nostr://abc").unwrap_err();
|
||||
assert!(matches!(err, PairingError::InvalidQr(_)));
|
||||
}
|
||||
|
||||
// 3. Reject missing secret
|
||||
#[test]
|
||||
fn reject_missing_secret() {
|
||||
let keys = Keys::generate();
|
||||
let pubkey = keys.public_key().to_hex();
|
||||
let relay_encoded = url_encode("wss://relay.example.com");
|
||||
let uri = format!("nostrpair://{}?relay={}", pubkey, relay_encoded);
|
||||
let err = decode_qr(&uri).unwrap_err();
|
||||
assert!(matches!(err, PairingError::InvalidQr(_)));
|
||||
}
|
||||
|
||||
// 4. Reject missing relay
|
||||
#[test]
|
||||
fn reject_missing_relay() {
|
||||
let keys = Keys::generate();
|
||||
let pubkey = keys.public_key().to_hex();
|
||||
let secret = hex::encode([0xab; 32]);
|
||||
let uri = format!("nostrpair://{}?secret={}", pubkey, secret);
|
||||
let err = decode_qr(&uri).unwrap_err();
|
||||
assert!(matches!(err, PairingError::InvalidQr(_)));
|
||||
}
|
||||
|
||||
// 5. Reject invalid hex in pubkey
|
||||
#[test]
|
||||
fn reject_invalid_pubkey_hex() {
|
||||
let bad_pubkey = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"; // 64 chars, not hex
|
||||
let secret = hex::encode([0xab; 32]);
|
||||
let relay_encoded = url_encode("wss://relay.example.com");
|
||||
let uri = format!(
|
||||
"nostrpair://{}?secret={}&relay={}",
|
||||
bad_pubkey, secret, relay_encoded
|
||||
);
|
||||
let err = decode_qr(&uri).unwrap_err();
|
||||
assert!(matches!(err, PairingError::InvalidQr(_)));
|
||||
}
|
||||
|
||||
// 6. Reject invalid hex in secret
|
||||
#[test]
|
||||
fn reject_invalid_secret_hex() {
|
||||
let keys = Keys::generate();
|
||||
let pubkey = keys.public_key().to_hex();
|
||||
let bad_secret = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"; // 64 chars, not hex
|
||||
let relay_encoded = url_encode("wss://relay.example.com");
|
||||
let uri = format!(
|
||||
"nostrpair://{}?secret={}&relay={}",
|
||||
pubkey, bad_secret, relay_encoded
|
||||
);
|
||||
let err = decode_qr(&uri).unwrap_err();
|
||||
assert!(matches!(err, PairingError::InvalidQr(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_short_pubkey() {
|
||||
let secret = hex::encode([0xab; 32]);
|
||||
let relay_encoded = url_encode("wss://relay.example.com");
|
||||
let uri = format!(
|
||||
"nostrpair://abc123?secret={}&relay={}",
|
||||
secret, relay_encoded
|
||||
);
|
||||
let err = decode_qr(&uri).unwrap_err();
|
||||
assert!(matches!(err, PairingError::InvalidQr(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_short_secret() {
|
||||
let keys = Keys::generate();
|
||||
let pubkey = keys.public_key().to_hex();
|
||||
let relay_encoded = url_encode("wss://relay.example.com");
|
||||
let uri = format!(
|
||||
"nostrpair://{}?secret=abc123&relay={}",
|
||||
pubkey, relay_encoded
|
||||
);
|
||||
let err = decode_qr(&uri).unwrap_err();
|
||||
assert!(matches!(err, PairingError::InvalidQr(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_missing_query_string() {
|
||||
let keys = Keys::generate();
|
||||
let pubkey = keys.public_key().to_hex();
|
||||
let uri = format!("nostrpair://{}", pubkey);
|
||||
let err = decode_qr(&uri).unwrap_err();
|
||||
assert!(matches!(err, PairingError::InvalidQr(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_non_websocket_relay_scheme() {
|
||||
let keys = Keys::generate();
|
||||
let pubkey = keys.public_key().to_hex();
|
||||
let secret = hex::encode([0xab; 32]);
|
||||
// http:// is not a valid relay scheme
|
||||
let relay_encoded = url_encode("https://evil.example.com");
|
||||
let uri = format!(
|
||||
"nostrpair://{}?secret={}&relay={}",
|
||||
pubkey, secret, relay_encoded
|
||||
);
|
||||
let err = decode_qr(&uri).unwrap_err();
|
||||
assert!(matches!(err, PairingError::InvalidQr(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_ws_and_wss_relay_schemes() {
|
||||
let payload_wss = make_payload(vec!["wss://relay.example.com".to_string()]);
|
||||
let uri_wss = encode_qr(&payload_wss);
|
||||
assert!(decode_qr(&uri_wss).is_ok(), "wss:// should be accepted");
|
||||
|
||||
let payload_ws = make_payload(vec!["ws://relay.example.com".to_string()]);
|
||||
let uri_ws = encode_qr(&payload_ws);
|
||||
assert!(decode_qr(&uri_ws).is_ok(), "ws:// should be accepted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_relay_with_no_scheme() {
|
||||
let keys = Keys::generate();
|
||||
let pubkey = keys.public_key().to_hex();
|
||||
let secret = hex::encode([0xab; 32]);
|
||||
let relay_encoded = url_encode("relay.example.com");
|
||||
let uri = format!(
|
||||
"nostrpair://{}?secret={}&relay={}",
|
||||
pubkey, secret, relay_encoded
|
||||
);
|
||||
let err = decode_qr(&uri).unwrap_err();
|
||||
assert!(matches!(err, PairingError::InvalidQr(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uri_contains_scheme_and_pubkey() {
|
||||
let payload = make_payload(vec!["wss://relay.example.com".to_string()]);
|
||||
let uri = encode_qr(&payload);
|
||||
assert!(uri.starts_with("nostrpair://"));
|
||||
assert!(uri.contains(&payload.source_pubkey.to_hex()));
|
||||
assert!(uri.contains("secret="));
|
||||
assert!(uri.contains("relay="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_decode_case_insensitive() {
|
||||
// %3a and %2f (lowercase) should also decode
|
||||
assert_eq!(
|
||||
url_decode("wss%3a%2f%2frelay.example.com"),
|
||||
"wss://relay.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_relay_with_query_params() {
|
||||
// Relay URL with query parameters containing &, =, and ?
|
||||
let original = make_payload(vec![
|
||||
"wss://relay.example.com/path?token=abc&flag=1".to_string()
|
||||
]);
|
||||
let uri = encode_qr(&original);
|
||||
let decoded = decode_qr(&uri).expect("decode should succeed");
|
||||
assert_eq!(
|
||||
decoded.relays[0],
|
||||
"wss://relay.example.com/path?token=abc&flag=1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_relay_with_percent_and_hash() {
|
||||
let original = make_payload(vec!["wss://relay.example.com/path#frag%20ment".to_string()]);
|
||||
let uri = encode_qr(&original);
|
||||
let decoded = decode_qr(&uri).expect("decode should succeed");
|
||||
assert_eq!(
|
||||
decoded.relays[0],
|
||||
"wss://relay.example.com/path#frag%20ment"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_encode_reserved_chars() {
|
||||
let encoded = url_encode("wss://relay.com/path?a=1&b=2#frag");
|
||||
assert!(!encoded.contains('&'), "& must be encoded");
|
||||
assert!(!encoded.contains('='), "= must be encoded");
|
||||
assert!(!encoded.contains('?'), "? must be encoded");
|
||||
assert!(!encoded.contains('#'), "# must be encoded");
|
||||
let decoded = url_decode(&encoded);
|
||||
assert_eq!(decoded, "wss://relay.com/path?a=1&b=2#frag");
|
||||
}
|
||||
|
||||
// Version field tests
|
||||
|
||||
#[test]
|
||||
fn round_trip_with_version() {
|
||||
let original = make_payload(vec!["wss://relay.example.com".to_string()]);
|
||||
let uri = encode_qr(&original);
|
||||
assert!(uri.contains("&v=1"), "URI must contain &v=1: {uri}");
|
||||
let decoded = decode_qr(&uri).expect("decode should succeed");
|
||||
assert_eq!(decoded.version, 1);
|
||||
assert_eq!(original.source_pubkey, decoded.source_pubkey);
|
||||
assert_eq!(original.session_secret, decoded.session_secret);
|
||||
assert_eq!(original.relays, decoded.relays);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_unsupported_version() {
|
||||
let payload = make_payload(vec!["wss://relay.example.com".to_string()]);
|
||||
// Build a URI with v=2 manually.
|
||||
let uri = encode_qr(&payload).replace("&v=1", "&v=2");
|
||||
let err = decode_qr(&uri).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, PairingError::InvalidQr(ref msg) if msg.contains("unsupported protocol version 2")),
|
||||
"expected unsupported version error, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_version_when_absent() {
|
||||
// Strip the &v=1 from a well-formed URI to simulate a legacy QR code.
|
||||
let payload = make_payload(vec!["wss://relay.example.com".to_string()]);
|
||||
let uri = encode_qr(&payload).replace("&v=1", "");
|
||||
let decoded = decode_qr(&uri).expect("legacy URI without v= should decode as version 1");
|
||||
assert_eq!(decoded.version, 1, "missing v= should default to version 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_all_zeros_session_secret() {
|
||||
let keys = Keys::generate();
|
||||
let pubkey = keys.public_key().to_hex();
|
||||
let zero_secret = "00".repeat(32); // 64 hex chars, all zeros
|
||||
let relay_encoded = url_encode("wss://relay.example.com");
|
||||
let uri = format!(
|
||||
"nostrpair://{}?secret={}&relay={}&v=1",
|
||||
pubkey, zero_secret, relay_encoded
|
||||
);
|
||||
let err = decode_qr(&uri).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, PairingError::InvalidQr(ref msg) if msg.contains("all zeros")),
|
||||
"expected all-zeros rejection, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_uppercase_hex_in_pubkey() {
|
||||
let keys = Keys::generate();
|
||||
// Force uppercase in the pubkey hex
|
||||
let pubkey_upper = keys.public_key().to_hex().to_uppercase();
|
||||
let secret = hex::encode([0xab; 32]);
|
||||
let relay_encoded = url_encode("wss://relay.example.com");
|
||||
let uri = format!(
|
||||
"nostrpair://{}?secret={}&relay={}&v=1",
|
||||
pubkey_upper, secret, relay_encoded
|
||||
);
|
||||
let err = decode_qr(&uri).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, PairingError::InvalidQr(ref msg) if msg.contains("lowercase")),
|
||||
"expected lowercase rejection for pubkey, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_uppercase_hex_in_secret() {
|
||||
let keys = Keys::generate();
|
||||
let pubkey = keys.public_key().to_hex();
|
||||
let secret_upper = hex::encode([0xab; 32]).to_uppercase();
|
||||
let relay_encoded = url_encode("wss://relay.example.com");
|
||||
let uri = format!(
|
||||
"nostrpair://{}?secret={}&relay={}&v=1",
|
||||
pubkey, secret_upper, relay_encoded
|
||||
);
|
||||
let err = decode_qr(&uri).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, PairingError::InvalidQr(ref msg) if msg.contains("lowercase")),
|
||||
"expected lowercase rejection for secret, got {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
||||
//! NIP-AB pairing message types.
|
||||
//!
|
||||
//! All message types are serialized as JSON with a `"type"` discriminant field
|
||||
//! (kebab-case). These are the plaintext payloads that get NIP-44 encrypted
|
||||
//! before being placed in a [`crate::kind::KIND_PAIRING`] event.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn default_version() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
/// The set of messages exchanged during a NIP-AB device-pairing session.
|
||||
///
|
||||
/// Serialized with `"type"` as the tag field (kebab-case). Example:
|
||||
/// ```json
|
||||
/// {"type":"offer","session_id":"a1b2c3..."}
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub enum PairingMessage {
|
||||
/// Target → Source. Announces the session and proves possession of the QR secret.
|
||||
Offer {
|
||||
/// Hex-encoded 32-byte session ID derived via HKDF from the session secret.
|
||||
session_id: String,
|
||||
/// Protocol version. Always `1` for this implementation.
|
||||
///
|
||||
/// Defaults to `1` when absent (backward compat with pre-versioned implementations).
|
||||
#[serde(default = "default_version")]
|
||||
version: u32,
|
||||
},
|
||||
|
||||
/// Either party → other. Confirms the Short Authentication String matches.
|
||||
SasConfirm {
|
||||
/// Hex-encoded 32-byte transcript hash, binding all session parameters.
|
||||
transcript_hash: String,
|
||||
},
|
||||
|
||||
/// Initiator → Responder (or vice-versa). Delivers the actual secret payload.
|
||||
Payload {
|
||||
/// Discriminates the payload format so the receiver knows how to handle it.
|
||||
payload_type: PayloadType,
|
||||
/// The payload content (format depends on `payload_type`).
|
||||
payload: String,
|
||||
},
|
||||
|
||||
/// Sent by either party to signal successful session completion.
|
||||
Complete {
|
||||
/// `true` if the session completed successfully, `false` on partial failure.
|
||||
success: bool,
|
||||
},
|
||||
|
||||
/// Sent by either party to abort the session early.
|
||||
Abort {
|
||||
/// Machine-readable reason for the abort.
|
||||
reason: AbortReason,
|
||||
},
|
||||
}
|
||||
|
||||
/// Discriminates the content of a [`PairingMessage::Payload`] message.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PayloadType {
|
||||
/// Raw `nsec` bech32 secret key.
|
||||
Nsec,
|
||||
/// NIP-46 bunker connection string.
|
||||
Bunker,
|
||||
/// NIP-46 `nostrconnect://` URI.
|
||||
Connect,
|
||||
/// Application-defined payload; interpretation is out-of-band.
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// Machine-readable reason a pairing session was aborted.
|
||||
///
|
||||
/// The spec allows implementations to define additional reason strings.
|
||||
/// Unknown reasons are deserialized as [`Unknown`](AbortReason::Unknown)
|
||||
/// and SHOULD be treated as `protocol_error` per NIP-AB §Abort.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AbortReason {
|
||||
/// The Short Authentication Strings shown to both users did not match.
|
||||
SasMismatch,
|
||||
/// The user explicitly denied the pairing request.
|
||||
UserDenied,
|
||||
/// The session exceeded its time limit without completing.
|
||||
Timeout,
|
||||
/// An unexpected or malformed message was received.
|
||||
ProtocolError,
|
||||
/// An unrecognized abort reason from a future or extended implementation.
|
||||
/// Produced only by deserialization of unknown reason strings.
|
||||
/// Callers MUST NOT use this variant for outbound aborts — use a
|
||||
/// spec-defined reason instead. Treat as `ProtocolError` per NIP-AB §Abort.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn offer_round_trip() {
|
||||
let msg = PairingMessage::Offer {
|
||||
session_id: "deadbeef".repeat(8),
|
||||
version: 1,
|
||||
};
|
||||
let json = serde_json::to_string(&msg).expect("serialize");
|
||||
assert!(
|
||||
json.contains(r#""type":"offer""#),
|
||||
"tag field present: {json}"
|
||||
);
|
||||
assert!(
|
||||
json.contains(r#""version":1"#),
|
||||
"version field present: {json}"
|
||||
);
|
||||
let back: PairingMessage = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(msg, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offer_version_defaults_to_1_when_absent() {
|
||||
// Simulate a legacy offer message without the version field.
|
||||
let json = r#"{"type":"offer","session_id":"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"}"#;
|
||||
let msg: PairingMessage = serde_json::from_str(json).expect("deserialize");
|
||||
assert_eq!(
|
||||
msg,
|
||||
PairingMessage::Offer {
|
||||
session_id: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
|
||||
.to_string(),
|
||||
version: 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sas_confirm_round_trip() {
|
||||
let msg = PairingMessage::SasConfirm {
|
||||
transcript_hash: "cafebabe".repeat(8),
|
||||
};
|
||||
let json = serde_json::to_string(&msg).expect("serialize");
|
||||
assert!(
|
||||
json.contains(r#""type":"sas-confirm""#),
|
||||
"kebab-case tag: {json}"
|
||||
);
|
||||
let back: PairingMessage = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(msg, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_round_trip() {
|
||||
let msg = PairingMessage::Payload {
|
||||
payload_type: PayloadType::Nsec,
|
||||
payload: "nsec1abc".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&msg).expect("serialize");
|
||||
assert!(json.contains(r#""type":"payload""#));
|
||||
assert!(json.contains(r#""payload_type":"nsec""#));
|
||||
let back: PairingMessage = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(msg, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abort_sas_mismatch_round_trip() {
|
||||
let msg = PairingMessage::Abort {
|
||||
reason: AbortReason::SasMismatch,
|
||||
};
|
||||
let json = serde_json::to_string(&msg).expect("serialize");
|
||||
assert!(
|
||||
json.contains(r#""reason":"sas_mismatch""#),
|
||||
"snake_case: {json}"
|
||||
);
|
||||
let back: PairingMessage = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(msg, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_round_trip() {
|
||||
for success in [true, false] {
|
||||
let msg = PairingMessage::Complete { success };
|
||||
let json = serde_json::to_string(&msg).expect("serialize");
|
||||
let back: PairingMessage = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(msg, back);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_abort_reasons_round_trip() {
|
||||
let reasons = [
|
||||
AbortReason::SasMismatch,
|
||||
AbortReason::UserDenied,
|
||||
AbortReason::Timeout,
|
||||
AbortReason::ProtocolError,
|
||||
];
|
||||
for reason in reasons {
|
||||
let msg = PairingMessage::Abort { reason };
|
||||
let json = serde_json::to_string(&msg).expect("serialize");
|
||||
let back: PairingMessage = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(msg, back);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_abort_reason_deserializes_to_unknown() {
|
||||
// NIP-AB §Abort: "unknown reasons SHOULD be treated as protocol_error"
|
||||
let json = r#"{"type":"abort","reason":"solar_flare"}"#;
|
||||
let msg: PairingMessage = serde_json::from_str(json).expect("deserialize");
|
||||
assert_eq!(
|
||||
msg,
|
||||
PairingMessage::Abort {
|
||||
reason: AbortReason::Unknown
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_abort_reason_is_not_protocol_error_variant() {
|
||||
// Unknown is a distinct variant — callers should never construct it
|
||||
// for outbound use, but if they do it serializes distinctly from
|
||||
// ProtocolError so we can catch the mistake.
|
||||
assert_ne!(AbortReason::Unknown, AbortReason::ProtocolError);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_payload_types_round_trip() {
|
||||
let types = [
|
||||
PayloadType::Nsec,
|
||||
PayloadType::Bunker,
|
||||
PayloadType::Connect,
|
||||
PayloadType::Custom,
|
||||
];
|
||||
for payload_type in types {
|
||||
let msg = PairingMessage::Payload {
|
||||
payload_type,
|
||||
payload: "data".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&msg).expect("serialize");
|
||||
let back: PairingMessage = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(msg, back);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Presence status types shared across REST, MCP, and WebSocket surfaces.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Allowed presence statuses for the REST/MCP surface.
|
||||
///
|
||||
/// The WebSocket path (kind:20001) accepts arbitrary status strings for
|
||||
/// forward-compatibility; this enum is the curated set for structured APIs.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PresenceStatus {
|
||||
/// User is actively online.
|
||||
Online,
|
||||
/// User is away / idle.
|
||||
Away,
|
||||
/// User is offline; clears the presence entry.
|
||||
Offline,
|
||||
}
|
||||
|
||||
impl PresenceStatus {
|
||||
/// Returns the lowercase string representation stored in Redis.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Online => "online",
|
||||
Self::Away => "away",
|
||||
Self::Offline => "offline",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PresenceStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip() {
|
||||
let online: PresenceStatus = serde_json::from_str(r#""online""#).unwrap();
|
||||
assert_eq!(online, PresenceStatus::Online);
|
||||
assert_eq!(serde_json::to_string(&online).unwrap(), r#""online""#);
|
||||
|
||||
let away: PresenceStatus = serde_json::from_str(r#""away""#).unwrap();
|
||||
assert_eq!(away, PresenceStatus::Away);
|
||||
|
||||
let offline: PresenceStatus = serde_json::from_str(r#""offline""#).unwrap();
|
||||
assert_eq!(offline, PresenceStatus::Offline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_rejects_unknown_variant() {
|
||||
let result: Result<PresenceStatus, _> = serde_json::from_str(r#""invisible""#);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn as_str_matches_serde() {
|
||||
assert_eq!(PresenceStatus::Online.as_str(), "online");
|
||||
assert_eq!(PresenceStatus::Away.as_str(), "away");
|
||||
assert_eq!(PresenceStatus::Offline.as_str(), "offline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_matches_as_str() {
|
||||
assert_eq!(format!("{}", PresenceStatus::Online), "online");
|
||||
assert_eq!(format!("{}", PresenceStatus::Away), "away");
|
||||
assert_eq!(format!("{}", PresenceStatus::Offline), "offline");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
//! Canonical relay identities shared by runtime components.
|
||||
|
||||
use thiserror::Error;
|
||||
use url::{Host, Url};
|
||||
|
||||
/// Errors returned while canonicalizing a relay URL for runtime identity.
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum NormalizeRelayUrlError {
|
||||
/// The input is not a valid URL.
|
||||
#[error("invalid relay URL: {0}")]
|
||||
InvalidUrl(String),
|
||||
/// Relay sockets must use WebSocket schemes.
|
||||
#[error("relay URL scheme must be ws or wss")]
|
||||
InvalidScheme,
|
||||
/// Relay identity never includes user credentials.
|
||||
#[error("relay URL must not contain credentials")]
|
||||
Credentials,
|
||||
/// Relay identity never includes a fragment.
|
||||
#[error("relay URL must not contain a fragment")]
|
||||
Fragment,
|
||||
/// A relay URL requires a host.
|
||||
#[error("relay URL must contain a host")]
|
||||
MissingHost,
|
||||
}
|
||||
|
||||
/// Canonicalize a WebSocket relay URL for use as a runtime identity key.
|
||||
///
|
||||
/// This is the sole normalizer for `(agent, relay)` process identity. It keeps
|
||||
/// the WebSocket scheme, lowercases DNS hosts, folds all loopback spellings to
|
||||
/// `127.0.0.1`, removes default ports and a root slash, and preserves non-root
|
||||
/// paths and queries. It deliberately is **not** the NIP-42 AUTH comparison
|
||||
/// helper in `buzz-auth`: AUTH validation is a security boundary with narrower
|
||||
/// equivalence rules and must not be widened by runtime-key canonicalization.
|
||||
///
|
||||
/// Connection code may retain the configured URL; this canonical form is for
|
||||
/// identity, receipts, status and deduplication.
|
||||
pub fn normalize_relay_url(raw: &str) -> Result<String, NormalizeRelayUrlError> {
|
||||
let mut url = Url::parse(raw.trim())
|
||||
.map_err(|error| NormalizeRelayUrlError::InvalidUrl(error.to_string()))?;
|
||||
if !matches!(url.scheme(), "ws" | "wss") {
|
||||
return Err(NormalizeRelayUrlError::InvalidScheme);
|
||||
}
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
return Err(NormalizeRelayUrlError::Credentials);
|
||||
}
|
||||
if url.fragment().is_some() {
|
||||
return Err(NormalizeRelayUrlError::Fragment);
|
||||
}
|
||||
|
||||
let host = url.host().ok_or(NormalizeRelayUrlError::MissingHost)?;
|
||||
let loopback = match host {
|
||||
Host::Domain(domain) => domain.eq_ignore_ascii_case("localhost"),
|
||||
Host::Ipv4(address) => address.is_loopback(),
|
||||
Host::Ipv6(address) => address.is_loopback(),
|
||||
};
|
||||
if loopback {
|
||||
url.set_host(Some("127.0.0.1"))
|
||||
.map_err(|_| NormalizeRelayUrlError::MissingHost)?;
|
||||
} else if let Host::Domain(domain) = host {
|
||||
let lowercase = domain.to_ascii_lowercase();
|
||||
url.set_host(Some(&lowercase))
|
||||
.map_err(|_| NormalizeRelayUrlError::MissingHost)?;
|
||||
}
|
||||
|
||||
let default_port = match url.scheme() {
|
||||
"ws" => Some(80),
|
||||
"wss" => Some(443),
|
||||
_ => None,
|
||||
};
|
||||
if url.port() == default_port {
|
||||
url.set_port(None)
|
||||
.map_err(|_| NormalizeRelayUrlError::InvalidScheme)?;
|
||||
}
|
||||
if url.path() == "/" {
|
||||
url.set_path("");
|
||||
}
|
||||
Ok(url.to_string().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn loopback_spellings_have_one_identity() {
|
||||
let ipv6 = normalize_relay_url("wss://[::1]/").unwrap();
|
||||
let ipv4 = normalize_relay_url("wss://127.0.0.1/").unwrap();
|
||||
let localhost = normalize_relay_url("wss://localhost/").unwrap();
|
||||
assert_eq!(ipv6, ipv4);
|
||||
assert_eq!(ipv4, localhost);
|
||||
assert_eq!(localhost, "wss://127.0.0.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonicalizes_only_identity_equivalences() {
|
||||
assert_eq!(
|
||||
normalize_relay_url(" WSS://Relay.Example:443/ ").unwrap(),
|
||||
"wss://relay.example"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_relay_url("ws://relay.example:8080/community/?x=1").unwrap(),
|
||||
"ws://relay.example:8080/community/?x=1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_relay_and_ambiguous_urls() {
|
||||
assert_eq!(
|
||||
normalize_relay_url("https://relay.example").unwrap_err(),
|
||||
NormalizeRelayUrlError::InvalidScheme
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_relay_url("wss://user@relay.example").unwrap_err(),
|
||||
NormalizeRelayUrlError::Credentials
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_relay_url("wss://relay.example/#x").unwrap_err(),
|
||||
NormalizeRelayUrlError::Fragment
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Tenant identity: the server-resolved community key carried on every scoped path.
|
||||
//!
|
||||
//! These types live in `buzz-core` (zero I/O deps) so the DB, auth, pub/sub,
|
||||
//! search, audit, media, and relay-wiring layers all name a community the same
|
||||
//! way without depending on each other.
|
||||
//!
|
||||
//! ## The fence
|
||||
//!
|
||||
//! The whole multi-tenant safety story rests on one invariant from the formal
|
||||
//! model (conformance "row zero"): a request's community is *resolved from the
|
||||
//! connection host by the server*, never supplied or influenced by the client.
|
||||
//!
|
||||
//! [`TenantContext`] expresses that invariant in the type system as far as the
|
||||
//! type system can carry it: there is no `Default`, no `Deserialize`, and no
|
||||
//! way to *parse* a community from client input. A `CommunityId` only ever
|
||||
//! comes from host resolution or from a DB row the server already scoped.
|
||||
//!
|
||||
//! This is a **lint-and-review fence, not a compiler fence.**
|
||||
//! [`TenantContext::resolved`] and [`CommunityId::from_uuid`] are public so the
|
||||
//! host-resolution path (in another crate) can call them — which means a
|
||||
//! determined caller elsewhere *could* call them too. The migration-lint
|
||||
//! harness forbids constructing a `TenantContext` outside host resolution and
|
||||
//! tests; the type only removes the *accidental* path (deserializing a
|
||||
//! client-chosen community), and review/lint closes the deliberate one. We say
|
||||
//! this plainly rather than overclaim a guarantee the `pub` API doesn't give.
|
||||
|
||||
use std::fmt;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A community: the first-class tenant key on every scoped row.
|
||||
///
|
||||
/// Opaque UUID newtype. Equality and ordering are the underlying UUID's.
|
||||
/// There is deliberately no `community_id` parsed from client input anywhere;
|
||||
/// a `CommunityId` only ever originates from host resolution or from a DB row
|
||||
/// the server already scoped.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct CommunityId(Uuid);
|
||||
|
||||
impl CommunityId {
|
||||
/// Wrap a UUID that the server has already established as a community id
|
||||
/// (e.g. read back from the `communities` table during host resolution).
|
||||
///
|
||||
/// This is intentionally not a parse-from-client entry point: callers must
|
||||
/// already hold a server-trusted UUID.
|
||||
pub const fn from_uuid(id: Uuid) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
|
||||
/// The underlying UUID, for DB binds and Redis key construction.
|
||||
pub const fn as_uuid(&self) -> &Uuid {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CommunityId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Display::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
/// The resolved tenant of an in-flight request, bound once at connection /
|
||||
/// request establishment before any handler observes tenant data.
|
||||
///
|
||||
/// Carried by reference (`&TenantContext`) through every scoped call. This is
|
||||
/// the *only* way to name a community downstream, and it cannot be constructed
|
||||
/// from client input — see the module-level "fence" note.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TenantContext {
|
||||
community: CommunityId,
|
||||
host: String,
|
||||
}
|
||||
|
||||
impl TenantContext {
|
||||
/// Construct a context from a completed host resolution.
|
||||
///
|
||||
/// Call this *only* from the host-resolution path (the function that maps a
|
||||
/// connection's host to a `communities` row). Everywhere else takes
|
||||
/// `&TenantContext` and reads it; nothing else mints one.
|
||||
pub fn resolved(community: CommunityId, host: impl Into<String>) -> Self {
|
||||
Self {
|
||||
community,
|
||||
host: host.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The community every scoped operation under this request must use.
|
||||
pub const fn community(&self) -> CommunityId {
|
||||
self.community
|
||||
}
|
||||
|
||||
/// The host that resolved to this community.
|
||||
///
|
||||
/// Authoritative for the NIP-05 domain and audit labelling; never re-derive
|
||||
/// the community from it downstream — the community is already fixed.
|
||||
pub fn host(&self) -> &str {
|
||||
&self.host
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a connection `Host` into the canonical form used as the community
|
||||
/// lookup key.
|
||||
///
|
||||
/// This is the *one* normalization rule shared by both sides of the fence:
|
||||
/// the `communities.host` column is stored already-normalized, and host
|
||||
/// resolution normalizes the incoming `Host` header with this same function
|
||||
/// before looking it up. Because both sides agree by construction,
|
||||
/// `Relay.Example`, `relay.example.`, and `relay.example:443` all resolve to
|
||||
/// the one community — they can never split into distinct tenants.
|
||||
///
|
||||
/// Rules (host only — the caller has already split off any path/scheme):
|
||||
/// - ASCII-lowercase (hosts are case-insensitive per RFC 3986);
|
||||
/// - strip a single trailing dot (the FQDN root label);
|
||||
/// - strip a default port suffix (`:80`, `:443`) — non-default ports are kept,
|
||||
/// since a deployment may legitimately serve different communities on
|
||||
/// different ports of the same name.
|
||||
///
|
||||
/// The input is trimmed of surrounding whitespace. An empty result (e.g. the
|
||||
/// caller passed `""`) is returned as-is; resolution treats an empty or
|
||||
/// unmapped host as a fail-closed rejection, never a default tenant.
|
||||
#[must_use]
|
||||
pub fn normalize_host(host: &str) -> String {
|
||||
let host = host.trim();
|
||||
let mut host = host.to_ascii_lowercase();
|
||||
// Strip default ports. We only touch a `:port` suffix that is exactly a
|
||||
// default port, so IPv6 literals like `[::1]` (which contain colons but no
|
||||
// trailing `:80`/`:443`) are left intact.
|
||||
if let Some(stripped) = host
|
||||
.strip_suffix(":443")
|
||||
.or_else(|| host.strip_suffix(":80"))
|
||||
{
|
||||
host = stripped.to_string();
|
||||
}
|
||||
// Strip a single trailing FQDN-root dot.
|
||||
if let Some(stripped) = host.strip_suffix('.') {
|
||||
host = stripped.to_string();
|
||||
}
|
||||
host
|
||||
}
|
||||
|
||||
/// Extract the authority (host plus an explicit non-default port, if present)
|
||||
/// from a relay URL in the same normalized shape as request `Host` headers and
|
||||
/// `communities.host`.
|
||||
///
|
||||
/// Shared by the relay's host-resolution seam (startup community seeding and
|
||||
/// the deployment-community bind), the relay's `bind_deployment_community`, and
|
||||
/// the `buzz-admin` CLI's tenant resolution. All of these must derive the
|
||||
/// *byte-identical* authority that live request resolution
|
||||
/// ([`crate::tenant::normalize_host`]) produces from an inbound `Host`, or a
|
||||
/// bootstrapped/looked-up community lands under a host no request resolves to.
|
||||
///
|
||||
/// In particular this preserves an explicit non-default port (`relay:8443` →
|
||||
/// `relay:8443`) and IPv6 brackets (`[::1]:3000`) — both of which a naive
|
||||
/// `Url::host_str()` drops. Returns the empty string when `relay_url` has no
|
||||
/// parseable host (the caller fails closed on empty).
|
||||
#[must_use]
|
||||
pub fn relay_url_authority(relay_url: &str) -> String {
|
||||
let Ok(url) = url::Url::parse(relay_url) else {
|
||||
return String::new();
|
||||
};
|
||||
let Some(host) = url.host() else {
|
||||
return String::new();
|
||||
};
|
||||
let host = match host {
|
||||
url::Host::Domain(domain) => domain.to_string(),
|
||||
url::Host::Ipv4(addr) => addr.to_string(),
|
||||
url::Host::Ipv6(addr) => format!("[{addr}]"),
|
||||
};
|
||||
let authority = match url.port() {
|
||||
Some(port) => format!("{host}:{port}"),
|
||||
None => host,
|
||||
};
|
||||
normalize_host(&authority)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn community_id_roundtrips_uuid() {
|
||||
let u = Uuid::from_u128(0x1234_5678_9abc_def0_1122_3344_5566_7788);
|
||||
let c = CommunityId::from_uuid(u);
|
||||
assert_eq!(c.as_uuid(), &u);
|
||||
assert_eq!(c.to_string(), u.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tenant_context_exposes_resolution_inputs() {
|
||||
let u = Uuid::from_u128(1);
|
||||
let ctx = TenantContext::resolved(CommunityId::from_uuid(u), "relay.example");
|
||||
assert_eq!(ctx.community().as_uuid(), &u);
|
||||
assert_eq!(ctx.host(), "relay.example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_host_collapses_tenant_split_variants() {
|
||||
// All of these are the SAME tenant and must normalize identically —
|
||||
// this is the property that stops accidental split-tenant.
|
||||
let canonical = "relay.example";
|
||||
for variant in [
|
||||
"relay.example",
|
||||
"Relay.Example",
|
||||
"RELAY.EXAMPLE",
|
||||
"relay.example.", // trailing FQDN root dot
|
||||
"relay.example:443", // default https port
|
||||
"relay.example:80", // default http port
|
||||
"Relay.Example.:443",
|
||||
" relay.example ", // surrounding whitespace
|
||||
] {
|
||||
assert_eq!(normalize_host(variant), canonical, "variant {variant:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_host_keeps_nondefault_port() {
|
||||
// A non-default port is a legitimate distinct selector — keep it.
|
||||
assert_eq!(normalize_host("relay.example:8443"), "relay.example:8443");
|
||||
assert_eq!(normalize_host("relay.example:3000"), "relay.example:3000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_host_leaves_ipv6_literal_intact() {
|
||||
// IPv6 literals contain colons but no trailing default-port suffix.
|
||||
assert_eq!(normalize_host("[::1]"), "[::1]");
|
||||
assert_eq!(normalize_host("[::1]:443"), "[::1]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_host_empty_stays_empty() {
|
||||
// Empty / whitespace-only resolves to empty; resolution fails closed.
|
||||
assert_eq!(normalize_host(""), "");
|
||||
assert_eq!(normalize_host(" "), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_url_authority_keeps_explicit_nondefault_port() {
|
||||
// The default dev seed: startup, bind_deployment_community, and
|
||||
// buzz-admin must all derive `localhost:3000` (NOT bare `localhost`),
|
||||
// or the admin lookup misses the community startup seeded.
|
||||
assert_eq!(relay_url_authority("ws://localhost:3000"), "localhost:3000");
|
||||
assert_eq!(
|
||||
relay_url_authority("wss://relay.example:8443"),
|
||||
"relay.example:8443"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_url_authority_collapses_default_ports() {
|
||||
// Default ports collapse to the bare host, matching how an inbound
|
||||
// `Host` header for the same deployment normalizes.
|
||||
assert_eq!(
|
||||
relay_url_authority("wss://relay.example:443"),
|
||||
"relay.example"
|
||||
);
|
||||
assert_eq!(
|
||||
relay_url_authority("ws://relay.example:80"),
|
||||
"relay.example"
|
||||
);
|
||||
assert_eq!(relay_url_authority("wss://relay.example"), "relay.example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_url_authority_preserves_ipv6_brackets() {
|
||||
// `host_str()` strips IPv6 brackets and the port; `relay_url_authority`
|
||||
// must keep both so the authority matches `communities.host`.
|
||||
assert_eq!(relay_url_authority("ws://[::1]:3000"), "[::1]:3000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_url_authority_unparseable_is_empty() {
|
||||
// No parseable host → empty authority; callers fail closed.
|
||||
assert_eq!(relay_url_authority("not a url"), "");
|
||||
assert_eq!(relay_url_authority(""), "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! `verify_event()` is CPU-bound (Schnorr). In async contexts call it via
|
||||
//! `tokio::task::spawn_blocking` — never directly on an async task.
|
||||
|
||||
use nostr::{Event, EventId};
|
||||
|
||||
use crate::error::VerificationError;
|
||||
|
||||
/// Verifies the event ID hash and Schnorr signature.
|
||||
///
|
||||
/// CPU-bound — call via `tokio::task::spawn_blocking` in async contexts.
|
||||
pub fn verify_event(event: &Event) -> Result<(), VerificationError> {
|
||||
if !event.verify_id() {
|
||||
let computed = EventId::new(
|
||||
&event.pubkey,
|
||||
&event.created_at,
|
||||
&event.kind,
|
||||
&event.tags,
|
||||
&event.content,
|
||||
)
|
||||
.to_hex();
|
||||
return Err(VerificationError::InvalidId {
|
||||
computed,
|
||||
got: event.id.to_hex(),
|
||||
});
|
||||
}
|
||||
|
||||
if !event.verify_signature() {
|
||||
return Err(VerificationError::InvalidSignature);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, JsonUtil, Keys, Kind};
|
||||
|
||||
fn make_valid_event() -> Event {
|
||||
let keys = Keys::generate();
|
||||
EventBuilder::new(Kind::TextNote, "test content")
|
||||
.tags([])
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tampered_id() {
|
||||
let keys = Keys::generate();
|
||||
let event = EventBuilder::new(Kind::TextNote, "original")
|
||||
.tags([])
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign");
|
||||
let mut json: serde_json::Value = serde_json::from_str(&event.as_json()).expect("parse");
|
||||
json["content"] = serde_json::Value::String("tampered".to_string());
|
||||
let tampered = Event::from_json(json.to_string()).expect("parse");
|
||||
assert!(matches!(
|
||||
verify_event(&tampered),
|
||||
Err(VerificationError::InvalidId { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tampered_signature() {
|
||||
let event = make_valid_event();
|
||||
let mut json: serde_json::Value = serde_json::from_str(&event.as_json()).expect("parse");
|
||||
json["sig"] = serde_json::Value::String("0".repeat(128));
|
||||
let tampered = Event::from_json(json.to_string()).expect("parse");
|
||||
assert!(verify_event(&tampered).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user