feat(status): integrate current binding contract

Cumulative PR layer #4790. Integrates the reviewed connection-local current-binding core contract.

Neighbor disclosure: this layer extends #4789 restore and event seams and provides the core status contract consumed by #4847; native desktop consumption remains in #4987.

Signed-off-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com>
This commit is contained in:
Cea Stapleton Cordasco
2026-08-12 11:20:13 -05:00
parent 94e85a6743
commit 5c02ebd4d3
5 changed files with 2724 additions and 4 deletions
@@ -0,0 +1,640 @@
//! Relay-authenticated connection bootstrap for client binding status.
//!
//! Kind `24245` is delivered only on the WebSocket connection whose native
//! client supplied the echoed epoch. It binds the relay's NIP-11 signing key,
//! the server-resolved authorization domain, and the authenticated event
//! author before kind `24244` status can be consumed.
use std::fmt;
use nostr::{Event, EventBuilder, Keys, Kind, PublicKey, Timestamp};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use uuid::Uuid;
use crate::{
client_binding_status::MAX_CLIENT_BINDING_STATUS_LIFETIME_SECS,
kind::KIND_CLIENT_BINDING_BOOTSTRAP, verify_event, CommunityId,
};
/// Integrity-protected NIP-42 tag carrying the native connection scope.
pub const CLIENT_BINDING_SCOPE_TAG: &str = "buzz_client_binding_scope";
/// Reserved exact-connection subscription id for bootstrap delivery.
pub const CLIENT_BINDING_BOOTSTRAP_SUB_ID: &str = "__buzz_client_binding_bootstrap_v1__";
/// Reserved exact-connection subscription id for status delivery.
pub const CLIENT_BINDING_STATUS_SUB_ID: &str = "__buzz_client_binding_status_v1__";
/// Bootstrap wire version accepted by this module.
pub const CLIENT_BINDING_BOOTSTRAP_VERSION: u64 = 1;
/// Maximum encoded bootstrap payload length.
pub const MAX_CLIENT_BINDING_BOOTSTRAP_PAYLOAD_BYTES: usize = 1024;
/// Opaque, native-generated canonical lowercase UUIDv4 connection epoch.
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct ClientBindingEpoch(String);
impl ClientBindingEpoch {
/// Generate a fresh connection epoch from the operating system CSPRNG.
pub fn new_v4() -> Self {
Self(Uuid::new_v4().to_string())
}
/// Parse the canonical lowercase hyphenated UUIDv4 wire form.
pub fn parse(value: &str) -> Result<Self, ClientBindingBootstrapError> {
let parsed = Uuid::parse_str(value)
.map_err(|_| ClientBindingBootstrapError::InvalidConnectionEpoch)?;
let bytes = parsed.as_bytes();
if parsed.to_string() != value || (bytes[6] >> 4) != 4 || (bytes[8] >> 6) != 2 {
return Err(ClientBindingBootstrapError::InvalidConnectionEpoch);
}
Ok(Self(value.to_owned()))
}
/// Canonical payload and signed-tag representation.
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Verified native connection scope carried by one signed NIP-42 AUTH event.
#[derive(Clone, PartialEq, Eq)]
pub struct ClientBindingScopeV1 {
connection_epoch: ClientBindingEpoch,
relay_signer: PublicKey,
}
impl ClientBindingScopeV1 {
/// Parse exactly one canonical v1 scope tag from a verified AUTH event.
///
/// This parser does not authenticate the event. Callers must invoke it only
/// after the ordinary NIP-42 signature, challenge, and relay checks pass.
pub fn from_verified_auth_event(event: &Event) -> Result<Self, ClientBindingBootstrapError> {
let mut matching = event.tags.iter().filter(|tag| {
tag.as_slice().first().map(String::as_str) == Some(CLIENT_BINDING_SCOPE_TAG)
});
let tag = matching
.next()
.ok_or(ClientBindingBootstrapError::MissingScopeTag)?;
if matching.next().is_some() {
return Err(ClientBindingBootstrapError::DuplicateScopeTag);
}
let values = tag.as_slice();
if values.len() != 4 || values[1] != "1" {
return Err(ClientBindingBootstrapError::InvalidScopeTag);
}
let connection_epoch = ClientBindingEpoch::parse(&values[2])?;
let relay_signer = parse_canonical_pubkey(&values[3])
.map_err(|_| ClientBindingBootstrapError::InvalidScopeTag)?;
Ok(Self {
connection_epoch,
relay_signer,
})
}
/// Native-generated epoch authenticated by the NIP-42 event signature.
pub fn connection_epoch(&self) -> &ClientBindingEpoch {
&self.connection_epoch
}
/// NIP-11 relay signer pinned by native before the socket was opened.
pub const fn relay_signer(&self) -> PublicKey {
self.relay_signer
}
}
impl fmt::Debug for ClientBindingScopeV1 {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClientBindingScopeV1")
.field("connection_epoch", &"[redacted]")
.field("relay_signer", &"[redacted]")
.finish()
}
}
impl fmt::Debug for ClientBindingEpoch {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_tuple("ClientBindingEpoch")
.field(&"[redacted]")
.finish()
}
}
/// Validated relay-authenticated bootstrap.
#[derive(Clone, PartialEq, Eq)]
pub struct ClientBindingBootstrapV1 {
authorization_domain: CommunityId,
event_author_pubkey: PublicKey,
connection_epoch: ClientBindingEpoch,
issued_at: u64,
}
impl ClientBindingBootstrapV1 {
/// Server-resolved authorization domain pinned by this connection.
pub const fn authorization_domain(&self) -> CommunityId {
self.authorization_domain
}
/// Authenticated event author pinned by this connection.
pub const fn event_author_pubkey(&self) -> PublicKey {
self.event_author_pubkey
}
/// Echoed native connection epoch.
pub fn connection_epoch(&self) -> &ClientBindingEpoch {
&self.connection_epoch
}
/// Relay issue time in Unix seconds.
pub const fn issued_at(&self) -> u64 {
self.issued_at
}
}
impl fmt::Debug for ClientBindingBootstrapV1 {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClientBindingBootstrapV1")
.field("authorization_domain", &"[redacted]")
.field("event_author_pubkey", &"[redacted]")
.field("connection_epoch", &"[redacted]")
.field("issued_at", &"[redacted]")
.finish()
}
}
/// Validated server-side signing input for one connection bootstrap.
pub struct ClientBindingBootstrapInputV1 {
authorization_domain: CommunityId,
event_author_pubkey: PublicKey,
connection_epoch: ClientBindingEpoch,
issued_at: u64,
}
impl ClientBindingBootstrapInputV1 {
/// Bind server-resolved connection authority to a native epoch.
pub fn new(
authorization_domain: CommunityId,
event_author_pubkey: PublicKey,
connection_epoch: ClientBindingEpoch,
issued_at: u64,
) -> Result<Self, ClientBindingBootstrapError> {
if authorization_domain.as_uuid().is_nil() {
return Err(ClientBindingBootstrapError::InvalidAuthorizationDomain);
}
if issued_at == 0 {
return Err(ClientBindingBootstrapError::InvalidIssueTime);
}
Ok(Self {
authorization_domain,
event_author_pubkey,
connection_epoch,
issued_at,
})
}
/// Sign the bootstrap with the relay key advertised by NIP-11 `self`.
pub fn sign_with_relay_keys(
self,
relay_keys: &Keys,
) -> Result<Event, ClientBindingBootstrapBuildError> {
let wire = WireClientBindingBootstrapV1 {
version: CLIENT_BINDING_BOOTSTRAP_VERSION,
authorization_domain: self.authorization_domain.as_uuid().to_string(),
event_author_pubkey: self.event_author_pubkey.to_hex(),
connection_epoch: self.connection_epoch.0,
issued_at: self.issued_at,
};
let content = serde_json::to_string(&wire)
.map_err(|_| ClientBindingBootstrapBuildError::Serialization)?;
if content.len() > MAX_CLIENT_BINDING_BOOTSTRAP_PAYLOAD_BYTES {
return Err(ClientBindingBootstrapBuildError::PayloadTooLarge);
}
EventBuilder::new(Kind::Custom(KIND_CLIENT_BINDING_BOOTSTRAP as u16), content)
.tags([])
.custom_created_at(Timestamp::from(wire.issued_at))
.sign_with_keys(relay_keys)
.map_err(|_| ClientBindingBootstrapBuildError::Signing)
}
}
impl fmt::Debug for ClientBindingBootstrapInputV1 {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClientBindingBootstrapInputV1")
.field("authorization_domain", &"[redacted]")
.field("event_author_pubkey", &"[redacted]")
.field("connection_epoch", &"[redacted]")
.field("issued_at", &"[redacted]")
.finish()
}
}
#[derive(Deserialize)]
struct VersionHeader {
version: u64,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WireClientBindingBootstrapV1 {
version: u64,
authorization_domain: String,
event_author_pubkey: String,
connection_epoch: String,
issued_at: u64,
}
/// Authenticate and validate a connection bootstrap against native authority.
pub fn validate_client_binding_bootstrap_event(
event: &Event,
trusted_relay_pubkey: &PublicKey,
expected_connection_epoch: &ClientBindingEpoch,
expected_event_author_pubkey: &PublicKey,
now: u64,
) -> Result<ClientBindingBootstrapV1, ClientBindingBootstrapError> {
if event.kind.as_u16() as u32 != KIND_CLIENT_BINDING_BOOTSTRAP {
return Err(ClientBindingBootstrapError::WrongKind);
}
if event.content.len() > MAX_CLIENT_BINDING_BOOTSTRAP_PAYLOAD_BYTES {
return Err(ClientBindingBootstrapError::PayloadTooLarge);
}
verify_event(event).map_err(|_| ClientBindingBootstrapError::UnauthenticatedEvent)?;
if event.pubkey != *trusted_relay_pubkey {
return Err(ClientBindingBootstrapError::UnexpectedRelay);
}
if !event.tags.is_empty() {
return Err(ClientBindingBootstrapError::UnexpectedTags);
}
let header: VersionHeader = serde_json::from_str(&event.content)
.map_err(|_| ClientBindingBootstrapError::MalformedPayload)?;
if header.version != CLIENT_BINDING_BOOTSTRAP_VERSION {
return Err(ClientBindingBootstrapError::UnsupportedVersion);
}
let wire: WireClientBindingBootstrapV1 = serde_json::from_str(&event.content)
.map_err(|_| ClientBindingBootstrapError::MalformedPayload)?;
let authorization_domain = Uuid::parse_str(&wire.authorization_domain)
.map_err(|_| ClientBindingBootstrapError::InvalidAuthorizationDomain)?;
if authorization_domain.is_nil()
|| authorization_domain.to_string() != wire.authorization_domain
{
return Err(ClientBindingBootstrapError::InvalidAuthorizationDomain);
}
let event_author_pubkey = parse_canonical_pubkey(&wire.event_author_pubkey)?;
if event_author_pubkey != *expected_event_author_pubkey {
return Err(ClientBindingBootstrapError::EventAuthorMismatch);
}
let connection_epoch = ClientBindingEpoch::parse(&wire.connection_epoch)?;
if connection_epoch != *expected_connection_epoch {
return Err(ClientBindingBootstrapError::ConnectionEpochMismatch);
}
if wire.issued_at == 0 {
return Err(ClientBindingBootstrapError::InvalidIssueTime);
}
if event.created_at.as_secs() != wire.issued_at {
return Err(ClientBindingBootstrapError::EventTimeMismatch);
}
if wire.issued_at > now {
return Err(ClientBindingBootstrapError::NotYetValid);
}
if now - wire.issued_at > MAX_CLIENT_BINDING_STATUS_LIFETIME_SECS {
return Err(ClientBindingBootstrapError::Expired);
}
Ok(ClientBindingBootstrapV1 {
authorization_domain: CommunityId::from_uuid(authorization_domain),
event_author_pubkey,
connection_epoch,
issued_at: wire.issued_at,
})
}
fn parse_canonical_pubkey(value: &str) -> Result<PublicKey, ClientBindingBootstrapError> {
if value.len() != 64
|| !value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(ClientBindingBootstrapError::InvalidEventAuthorPubkey);
}
PublicKey::from_hex(value).map_err(|_| ClientBindingBootstrapError::InvalidEventAuthorPubkey)
}
/// Fail-closed bootstrap validation failure.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum ClientBindingBootstrapError {
/// The verified AUTH event did not opt into native status projection.
#[error("client binding scope tag is missing")]
MissingScopeTag,
/// More than one connection scope tag was present.
#[error("client binding scope tag is duplicated")]
DuplicateScopeTag,
/// The signed connection scope tag was not the exact canonical v1 shape.
#[error("client binding scope tag is invalid")]
InvalidScopeTag,
/// Event kind was not the dedicated bootstrap kind.
#[error("client binding bootstrap event has the wrong kind")]
WrongKind,
/// Payload exceeded the public bound.
#[error("client binding bootstrap payload is too large")]
PayloadTooLarge,
/// Event signature or identifier was invalid.
#[error("client binding bootstrap event is not authenticated")]
UnauthenticatedEvent,
/// Signer did not match NIP-11 `self`.
#[error("client binding bootstrap signer is not the trusted relay")]
UnexpectedRelay,
/// Bootstrap events must have no tags.
#[error("client binding bootstrap contains unexpected tags")]
UnexpectedTags,
/// Payload was not the bounded v1 shape.
#[error("client binding bootstrap payload is malformed")]
MalformedPayload,
/// Wire version is unsupported.
#[error("client binding bootstrap version is unsupported")]
UnsupportedVersion,
/// Authorization domain was nil or noncanonical.
#[error("client binding bootstrap authorization domain is invalid")]
InvalidAuthorizationDomain,
/// Event-author key was noncanonical.
#[error("client binding bootstrap event author is invalid")]
InvalidEventAuthorPubkey,
/// Authenticated author did not match native signing state.
#[error("client binding bootstrap event author does not match")]
EventAuthorMismatch,
/// Connection epoch was noncanonical.
#[error("client binding bootstrap connection epoch is invalid")]
InvalidConnectionEpoch,
/// Echoed epoch did not match the native signed NIP-42 scope.
#[error("client binding bootstrap connection epoch does not match")]
ConnectionEpochMismatch,
/// Issue time was zero.
#[error("client binding bootstrap issue time is invalid")]
InvalidIssueTime,
/// Signed timestamp did not equal the payload timestamp.
#[error("client binding bootstrap event time does not match")]
EventTimeMismatch,
/// Bootstrap claims a future issue time.
#[error("client binding bootstrap is not yet valid")]
NotYetValid,
/// Bootstrap exceeded the client-status maximum lifetime.
#[error("client binding bootstrap has expired")]
Expired,
}
/// Bootstrap serialization or signing failure.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum ClientBindingBootstrapBuildError {
/// JSON serialization failed.
#[error("client binding bootstrap serialization failed")]
Serialization,
/// Serialized payload exceeded its public bound.
#[error("client binding bootstrap payload is too large")]
PayloadTooLarge,
/// Relay signing failed.
#[error("client binding bootstrap signing failed")]
Signing,
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::{JsonUtil, Tag};
use serde_json::{json, Value};
const DOMAIN: &str = "abcdefab-cdef-4abc-8def-abcdefabcdef";
const ISSUED_AT: u64 = 1_800_000_000;
fn domain() -> CommunityId {
CommunityId::from_uuid(Uuid::parse_str(DOMAIN).expect("synthetic domain is valid"))
}
fn epoch(byte: u8) -> ClientBindingEpoch {
ClientBindingEpoch::parse(&format!("11111111-1111-4111-8111-{byte:012x}"))
.expect("synthetic epoch is canonical UUIDv4")
}
fn signed_bootstrap(relay: &Keys, author: PublicKey) -> Event {
ClientBindingBootstrapInputV1::new(domain(), author, epoch(0x11), ISSUED_AT)
.expect("synthetic bootstrap input is valid")
.sign_with_relay_keys(relay)
.expect("synthetic bootstrap signs")
}
fn validate(
event: &Event,
relay: &Keys,
author: &Keys,
expected_epoch: &ClientBindingEpoch,
now: u64,
) -> Result<ClientBindingBootstrapV1, ClientBindingBootstrapError> {
validate_client_binding_bootstrap_event(
event,
&relay.public_key(),
expected_epoch,
&author.public_key(),
now,
)
}
fn resign_payload(relay: &Keys, payload: Value) -> Event {
EventBuilder::new(
Kind::Custom(KIND_CLIENT_BINDING_BOOTSTRAP as u16),
payload.to_string(),
)
.tags([])
.custom_created_at(Timestamp::from(ISSUED_AT))
.sign_with_keys(relay)
.expect("synthetic bootstrap variant signs")
}
#[test]
fn relay_authenticated_bootstrap_roundtrips_exact_authority() {
let relay = Keys::generate();
let author = Keys::generate();
let expected_epoch = epoch(0x11);
let event = signed_bootstrap(&relay, author.public_key());
let bootstrap = validate(&event, &relay, &author, &expected_epoch, ISSUED_AT)
.expect("synthetic bootstrap validates");
assert_eq!(event.kind.as_u16() as u32, KIND_CLIENT_BINDING_BOOTSTRAP);
assert!(event.tags.is_empty());
assert_eq!(bootstrap.authorization_domain(), domain());
assert_eq!(bootstrap.event_author_pubkey(), author.public_key());
assert_eq!(bootstrap.connection_epoch(), &expected_epoch);
assert_eq!(bootstrap.issued_at(), ISSUED_AT);
let payload: Value = serde_json::from_str(&event.content).expect("payload parses");
assert_eq!(
payload
.as_object()
.expect("payload is an object")
.keys()
.map(String::as_str)
.collect::<std::collections::BTreeSet<_>>(),
std::collections::BTreeSet::from([
"authorization_domain",
"connection_epoch",
"event_author_pubkey",
"issued_at",
"version",
])
);
let debug = format!("{bootstrap:?}");
assert!(!debug.contains(DOMAIN));
assert!(!debug.contains(&author.public_key().to_hex()));
assert!(!debug.contains(expected_epoch.as_str()));
}
#[test]
fn bootstrap_rejects_noncanonical_epoch_and_invalid_input_bounds() {
assert_eq!(
ClientBindingEpoch::parse("11111111-1111-4111-8111-AAAAAAAAAAAA"),
Err(ClientBindingBootstrapError::InvalidConnectionEpoch)
);
assert_eq!(
ClientBindingEpoch::parse("11111111-1111-5111-8111-111111111111"),
Err(ClientBindingBootstrapError::InvalidConnectionEpoch)
);
assert_eq!(
ClientBindingEpoch::parse("11111111-1111-4111-7111-111111111111"),
Err(ClientBindingBootstrapError::InvalidConnectionEpoch)
);
assert_eq!(
ClientBindingBootstrapInputV1::new(
CommunityId::from_uuid(Uuid::nil()),
Keys::generate().public_key(),
epoch(1),
ISSUED_AT,
)
.expect_err("nil domains are invalid"),
ClientBindingBootstrapError::InvalidAuthorizationDomain
);
assert_eq!(
ClientBindingBootstrapInputV1::new(
domain(),
Keys::generate().public_key(),
epoch(1),
0,
)
.expect_err("zero issue time is invalid"),
ClientBindingBootstrapError::InvalidIssueTime
);
}
#[test]
fn bootstrap_rejects_wrong_scope_tampering_and_unknown_shape() {
let relay = Keys::generate();
let wrong_relay = Keys::generate();
let author = Keys::generate();
let other_author = Keys::generate();
let expected_epoch = epoch(0x11);
let event = signed_bootstrap(&relay, author.public_key());
assert_eq!(
validate(&event, &wrong_relay, &author, &expected_epoch, ISSUED_AT),
Err(ClientBindingBootstrapError::UnexpectedRelay)
);
assert_eq!(
validate(&event, &relay, &other_author, &expected_epoch, ISSUED_AT),
Err(ClientBindingBootstrapError::EventAuthorMismatch)
);
assert_eq!(
validate(&event, &relay, &author, &epoch(0x22), ISSUED_AT),
Err(ClientBindingBootstrapError::ConnectionEpochMismatch)
);
assert_eq!(
validate(&event, &relay, &author, &expected_epoch, ISSUED_AT - 1),
Err(ClientBindingBootstrapError::NotYetValid)
);
assert_eq!(
validate(
&event,
&relay,
&author,
&expected_epoch,
ISSUED_AT + MAX_CLIENT_BINDING_STATUS_LIFETIME_SECS + 1,
),
Err(ClientBindingBootstrapError::Expired)
);
let mut tampered_json: Value =
serde_json::from_str(&event.as_json()).expect("event parses");
tampered_json["content"] = Value::String("{}".to_string());
let tampered =
Event::from_json(tampered_json.to_string()).expect("tampered event still parses");
assert_eq!(
validate(&tampered, &relay, &author, &expected_epoch, ISSUED_AT),
Err(ClientBindingBootstrapError::UnauthenticatedEvent)
);
let mut payload: Value =
serde_json::from_str(&event.content).expect("bootstrap content parses");
payload["synthetic_extension"] = json!(true);
assert_eq!(
validate(
&resign_payload(&relay, payload),
&relay,
&author,
&expected_epoch,
ISSUED_AT,
),
Err(ClientBindingBootstrapError::MalformedPayload)
);
let mut payload: Value =
serde_json::from_str(&event.content).expect("bootstrap content parses");
payload["authorization_domain"] = json!(DOMAIN.to_uppercase());
assert_eq!(
validate(
&resign_payload(&relay, payload),
&relay,
&author,
&expected_epoch,
ISSUED_AT,
),
Err(ClientBindingBootstrapError::InvalidAuthorizationDomain)
);
}
#[test]
fn verified_auth_scope_requires_one_exact_signed_tag() {
let author = Keys::generate();
let relay = Keys::generate();
let epoch = epoch(0x11);
let scope = vec![
CLIENT_BINDING_SCOPE_TAG.to_string(),
"1".to_string(),
epoch.as_str().to_string(),
relay.public_key().to_hex(),
];
let auth = EventBuilder::new(Kind::Custom(22242), "")
.tags([Tag::parse(scope.clone()).expect("synthetic scope tag")])
.sign_with_keys(&author)
.expect("synthetic AUTH signs");
let parsed = ClientBindingScopeV1::from_verified_auth_event(&auth)
.expect("exact signed scope parses");
assert_eq!(parsed.connection_epoch(), &epoch);
assert_eq!(parsed.relay_signer(), relay.public_key());
let missing = EventBuilder::new(Kind::Custom(22242), "")
.sign_with_keys(&author)
.expect("synthetic AUTH signs");
assert_eq!(
ClientBindingScopeV1::from_verified_auth_event(&missing),
Err(ClientBindingBootstrapError::MissingScopeTag)
);
let duplicate = EventBuilder::new(Kind::Custom(22242), "")
.tags([
Tag::parse(scope.clone()).expect("synthetic scope tag"),
Tag::parse(scope).expect("synthetic scope tag"),
])
.sign_with_keys(&author)
.expect("synthetic AUTH signs");
assert_eq!(
ClientBindingScopeV1::from_verified_auth_event(&duplicate),
Err(ClientBindingBootstrapError::DuplicateScopeTag)
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,613 @@
//! Shared native-client fold for reserved binding bootstrap/status frames.
use std::fmt;
use nostr::{Event, EventId, PublicKey};
use serde::Serialize;
use serde_json::Value;
use crate::{
client_binding_bootstrap::{
validate_client_binding_bootstrap_event, ClientBindingEpoch,
CLIENT_BINDING_BOOTSTRAP_SUB_ID, CLIENT_BINDING_STATUS_SUB_ID,
},
client_binding_status::{ClientBindingStatusTracker, ClientBindingStatusUpdate},
verify_event,
};
/// Current-only data permitted to cross a native client IPC boundary.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CurrentProjection {
/// Canonical lowercase Nostr event-author public key.
pub event_author_pubkey: String,
/// Exclusive Unix-seconds freshness bound.
pub fresh_until: u64,
}
/// One change produced by the serialized native fold.
pub enum ProjectionUpdate {
/// Reserved input did not change the trusted projection.
Unchanged,
/// Clear any existing projection.
Clear,
/// Replace the projection with a fresh current value.
Current(CurrentProjection),
}
struct ReservedEvent {
event: Result<Event, ()>,
exact_outer_shape: bool,
}
enum ReservedFrame {
Bootstrap(ReservedEvent),
Status(ReservedEvent),
}
/// Connection-scoped wrapper around the authenticated status tracker.
pub struct ClientBindingStatusSession {
trusted_relay_pubkey: PublicKey,
expected_event_author_pubkey: PublicKey,
connection_epoch: ClientBindingEpoch,
bootstrap_event_id: Option<EventId>,
bootstrap_latched_invalid: bool,
tracker: Option<ClientBindingStatusTracker>,
projected_fresh_until: Option<u64>,
}
impl fmt::Debug for ClientBindingStatusSession {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClientBindingStatusSession")
.field("trusted_relay_pubkey", &"[redacted]")
.field("expected_event_author_pubkey", &"[redacted]")
.field("connection_epoch", &"[redacted]")
.field(
"bootstrap_event_id",
&self.bootstrap_event_id.map(|_| "[redacted]"),
)
.field("bootstrap_latched_invalid", &self.bootstrap_latched_invalid)
.field("tracker", &self.tracker.as_ref().map(|_| "[redacted]"))
.field("projected_fresh_until", &"[redacted]")
.finish()
}
}
impl ClientBindingStatusSession {
/// Start an empty connection-scoped consumer.
pub fn new(
trusted_relay_pubkey: PublicKey,
expected_event_author_pubkey: PublicKey,
connection_epoch: ClientBindingEpoch,
) -> Self {
Self {
trusted_relay_pubkey,
expected_event_author_pubkey,
connection_epoch,
bootstrap_event_id: None,
bootstrap_latched_invalid: false,
tracker: None,
projected_fresh_until: None,
}
}
/// Exact authenticated connection epoch.
pub fn connection_epoch(&self) -> &ClientBindingEpoch {
&self.connection_epoch
}
/// Swallow and fold an exact reserved EVENT frame.
///
/// `None` identifies ordinary non-reserved traffic, which callers must
/// deliver unchanged. Any malformed or unauthenticated reserved frame is
/// fail-closed and clears an existing projection.
pub fn consume_text(&mut self, text: &str, now: u64) -> Option<ProjectionUpdate> {
let frame = reserved_frame(text.as_bytes())?;
Some(match frame {
ReservedFrame::Bootstrap(event) => self.accept_bootstrap(event, now),
ReservedFrame::Status(event) => self.accept_status(event, now),
})
}
/// Currently projected freshness bound, if any.
pub const fn projected_fresh_until(&self) -> Option<u64> {
self.projected_fresh_until
}
/// Clear presentation exactly at or after the exclusive freshness bound.
pub fn expire(&mut self, now: u64) -> ProjectionUpdate {
let expired = self
.projected_fresh_until
.is_some_and(|fresh_until| now >= fresh_until);
if !expired {
return ProjectionUpdate::Unchanged;
}
if let Some(tracker) = self.tracker.as_mut() {
let _ = tracker.current_presentation(now);
}
self.projected_fresh_until = None;
ProjectionUpdate::Clear
}
/// Clear presentation on disconnect while retaining the local floor.
pub fn disconnect(&mut self) -> ProjectionUpdate {
if let Some(tracker) = self.tracker.as_mut() {
tracker.on_disconnect();
}
self.projected_fresh_until = None;
ProjectionUpdate::Clear
}
fn accept_bootstrap(&mut self, reserved: ReservedEvent, now: u64) -> ProjectionUpdate {
let Ok(event) = reserved.event else {
return self.clear_trusted_invalid();
};
if verify_event(&event).is_err() || event.pubkey != self.trusted_relay_pubkey {
return self.clear_trusted_invalid();
}
if !reserved.exact_outer_shape || self.bootstrap_latched_invalid {
self.bootstrap_latched_invalid = true;
return self.clear_trusted_invalid();
}
let bootstrap = match validate_client_binding_bootstrap_event(
&event,
&self.trusted_relay_pubkey,
&self.connection_epoch,
&self.expected_event_author_pubkey,
now,
) {
Ok(bootstrap) => bootstrap,
Err(_) => {
self.bootstrap_latched_invalid = true;
return self.clear_trusted_invalid();
}
};
if let Some(event_id) = self.bootstrap_event_id {
return if event_id == event.id {
ProjectionUpdate::Unchanged
} else {
self.bootstrap_latched_invalid = true;
self.clear_trusted_invalid()
};
}
self.bootstrap_event_id = Some(event.id);
self.tracker = Some(ClientBindingStatusTracker::new(
self.trusted_relay_pubkey,
bootstrap.authorization_domain(),
self.expected_event_author_pubkey,
));
ProjectionUpdate::Unchanged
}
fn accept_status(&mut self, reserved: ReservedEvent, now: u64) -> ProjectionUpdate {
let Ok(event) = reserved.event else {
return self.clear_trusted_invalid();
};
if verify_event(&event).is_err() || event.pubkey != self.trusted_relay_pubkey {
return self.clear_trusted_invalid();
}
if self.bootstrap_latched_invalid {
return self.clear_trusted_invalid();
}
if !reserved.exact_outer_shape {
if let Some(tracker) = self.tracker.as_mut() {
if tracker.accept(&event, now).is_err() {
tracker.retain_trusted_invalid_high_water(&event);
}
tracker.on_disconnect();
} else {
self.bootstrap_latched_invalid = true;
}
return self.clear_trusted_invalid();
}
let Some(tracker) = self.tracker.as_mut() else {
self.bootstrap_latched_invalid = true;
return self.clear_trusted_invalid();
};
match tracker.accept(&event, now) {
Ok(ClientBindingStatusUpdate::Duplicate) => ProjectionUpdate::Unchanged,
Ok(ClientBindingStatusUpdate::Accepted) => {
let Some(status) = tracker.current_presentation(now) else {
self.projected_fresh_until = None;
return ProjectionUpdate::Clear;
};
self.projected_fresh_until = Some(status.fresh_until());
ProjectionUpdate::Current(CurrentProjection {
event_author_pubkey: self.expected_event_author_pubkey.to_hex(),
fresh_until: status.fresh_until(),
})
}
Err(_) => {
tracker.retain_trusted_invalid_high_water(&event);
self.clear_trusted_invalid()
}
}
}
fn clear_trusted_invalid(&mut self) -> ProjectionUpdate {
self.projected_fresh_until = None;
ProjectionUpdate::Clear
}
}
fn reserved_frame(bytes: &[u8]) -> Option<ReservedFrame> {
let value: Value = serde_json::from_slice(bytes).ok()?;
let values = value.as_array()?;
if values.first().and_then(Value::as_str) != Some("EVENT") {
return None;
}
let reserved = match values.get(1).and_then(Value::as_str) {
Some(CLIENT_BINDING_BOOTSTRAP_SUB_ID) => ReservedFrame::Bootstrap,
Some(CLIENT_BINDING_STATUS_SUB_ID) => ReservedFrame::Status,
_ => return None,
};
let event = values
.get(2)
.cloned()
.ok_or(())
.and_then(|value| serde_json::from_value(value).map_err(|_| ()));
Some(reserved(ReservedEvent {
event,
exact_outer_shape: values.len() == 3,
}))
}
/// Identify reserved status/bootstrap text without consuming ordinary frames.
pub fn is_reserved_text(text: &str) -> bool {
reserved_frame(text.as_bytes()).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
client_binding_bootstrap::ClientBindingBootstrapInputV1,
client_binding_status::{ClientBindingStatusDisposition, ClientBindingStatusInputV1},
CommunityId,
};
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Timestamp};
use serde_json::json;
use uuid::Uuid;
const ISSUED_AT: u64 = 1_800_000_000;
const FRESH_UNTIL: u64 = ISSUED_AT + 120;
fn domain() -> CommunityId {
CommunityId::from_uuid(Uuid::from_u128(0x1234))
}
fn epoch_b() -> ClientBindingEpoch {
ClientBindingEpoch::parse("22222222-2222-4222-8222-222222222222").unwrap()
}
fn bootstrap(relay: &Keys, author: &Keys, epoch: ClientBindingEpoch) -> Event {
ClientBindingBootstrapInputV1::new(domain(), author.public_key(), epoch, ISSUED_AT)
.unwrap()
.sign_with_relay_keys(relay)
.unwrap()
}
fn status(
relay: &Keys,
author: &Keys,
revision: u64,
disposition: ClientBindingStatusDisposition,
) -> Event {
let input = match disposition {
ClientBindingStatusDisposition::DisplayCurrent => ClientBindingStatusInputV1::current(
domain(),
author.public_key(),
7,
"policy-v1",
revision,
ISSUED_AT,
FRESH_UNTIL,
),
ClientBindingStatusDisposition::Withdrawn => ClientBindingStatusInputV1::withdrawn(
domain(),
author.public_key(),
revision,
ISSUED_AT,
FRESH_UNTIL,
),
}
.unwrap();
input.sign_with_relay_keys(relay).unwrap()
}
fn frame(sub_id: &str, event: &Event) -> String {
json!(["EVENT", sub_id, event]).to_string()
}
fn extra_outer_value_frame(sub_id: &str, event: &Event) -> String {
json!(["EVENT", sub_id, event, { "unexpected": true }]).to_string()
}
fn live_session(relay: &Keys, author: &Keys) -> ClientBindingStatusSession {
let mut session =
ClientBindingStatusSession::new(relay.public_key(), author.public_key(), epoch_b());
assert!(matches!(
session.consume_text(
&frame(
CLIENT_BINDING_BOOTSTRAP_SUB_ID,
&bootstrap(relay, author, epoch_b()),
),
ISSUED_AT,
),
Some(ProjectionUpdate::Unchanged)
));
assert!(matches!(
session.consume_text(
&frame(
CLIENT_BINDING_STATUS_SUB_ID,
&status(
relay,
author,
1,
ClientBindingStatusDisposition::DisplayCurrent,
),
),
ISSUED_AT,
),
Some(ProjectionUpdate::Current(_))
));
session
}
#[test]
fn current_projection_serializes_as_exact_epoch_free_two_key_ipc_shape() {
let author = Keys::generate();
let projection = CurrentProjection {
event_author_pubkey: author.public_key().to_hex(),
fresh_until: FRESH_UNTIL,
};
let serialized = serde_json::to_value(projection).expect("projection serializes");
assert_eq!(
serialized,
json!({
"eventAuthorPubkey": author.public_key().to_hex(),
"freshUntil": FRESH_UNTIL,
})
);
let object = serialized.as_object().expect("projection is an object");
assert_eq!(object.len(), 2);
for forbidden in [
"connectionEpoch",
"connection_epoch",
"epoch",
"statusRevision",
"bindingVersion",
"policyVersion",
] {
assert!(
object.get(forbidden).is_none(),
"leaked IPC field {forbidden}"
);
}
}
#[test]
fn reserved_frames_are_classified_without_consuming_ordinary_traffic() {
assert!(is_reserved_text(
&json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID]).to_string()
));
assert!(is_reserved_text(
&json!(["EVENT", CLIENT_BINDING_BOOTSTRAP_SUB_ID, "bad"]).to_string()
));
assert!(!is_reserved_text(
&json!(["EVENT", "ordinary", {}]).to_string()
));
assert!(!is_reserved_text("not-json"));
}
#[test]
fn malformed_or_unauthenticated_reserved_frames_clear_live_projection() {
let relay = Keys::generate();
let wrong_relay = Keys::generate();
let author = Keys::generate();
let signed = status(
&relay,
&author,
2,
ClientBindingStatusDisposition::DisplayCurrent,
);
let mut tampered_json: Value = serde_json::from_str(&signed.as_json()).unwrap();
tampered_json["content"] = Value::String("{}".to_string());
let tampered = Event::from_json(tampered_json.to_string()).unwrap();
let wrong_signer = status(
&wrong_relay,
&author,
2,
ClientBindingStatusDisposition::DisplayCurrent,
);
let cases = [
json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID]).to_string(),
json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID, "bad"]).to_string(),
frame(CLIENT_BINDING_STATUS_SUB_ID, &tampered),
frame(CLIENT_BINDING_STATUS_SUB_ID, &wrong_signer),
json!(["EVENT", CLIENT_BINDING_BOOTSTRAP_SUB_ID]).to_string(),
];
for reserved in cases {
let mut session = live_session(&relay, &author);
assert!(matches!(
session.consume_text(&reserved, ISSUED_AT),
Some(ProjectionUpdate::Clear)
));
assert!(session.projected_fresh_until().is_none());
}
}
#[test]
fn malformed_reserved_outer_shape_consumes_high_water_before_clearing() {
let relay = Keys::generate();
let author = Keys::generate();
let mut session =
ClientBindingStatusSession::new(relay.public_key(), author.public_key(), epoch_b());
assert!(matches!(
session.consume_text(
&frame(
CLIENT_BINDING_BOOTSTRAP_SUB_ID,
&bootstrap(&relay, &author, epoch_b()),
),
ISSUED_AT,
),
Some(ProjectionUpdate::Unchanged)
));
let revision_two = status(
&relay,
&author,
2,
ClientBindingStatusDisposition::DisplayCurrent,
);
let revision_three = status(
&relay,
&author,
3,
ClientBindingStatusDisposition::DisplayCurrent,
);
assert!(matches!(
session.consume_text(
&extra_outer_value_frame(CLIENT_BINDING_STATUS_SUB_ID, &revision_two),
ISSUED_AT,
),
Some(ProjectionUpdate::Clear)
));
assert!(matches!(
session.consume_text(
&frame(CLIENT_BINDING_STATUS_SUB_ID, &revision_two),
ISSUED_AT,
),
Some(ProjectionUpdate::Unchanged)
));
assert!(session.projected_fresh_until().is_none());
assert!(matches!(
session.consume_text(
&frame(CLIENT_BINDING_STATUS_SUB_ID, &revision_three),
ISSUED_AT,
),
Some(ProjectionUpdate::Current(_))
));
}
#[test]
fn trusted_invalid_same_scope_revision_advances_hidden_high_water() {
let relay = Keys::generate();
let author = Keys::generate();
let mut session = live_session(&relay, &author);
let revision_four = status(
&relay,
&author,
4,
ClientBindingStatusDisposition::DisplayCurrent,
);
let mut invalid_payload: Value =
serde_json::from_str(&revision_four.content).expect("synthetic status payload");
invalid_payload["fresh_until"] = json!(ISSUED_AT);
let trusted_invalid = EventBuilder::new(
Kind::Custom(crate::kind::KIND_CLIENT_BINDING_STATUS as u16),
invalid_payload.to_string(),
)
.custom_created_at(Timestamp::from(ISSUED_AT))
.sign_with_keys(&relay)
.expect("trusted-invalid status signs");
assert!(matches!(
session.consume_text(
&frame(CLIENT_BINDING_STATUS_SUB_ID, &trusted_invalid),
ISSUED_AT,
),
Some(ProjectionUpdate::Clear)
));
assert!(matches!(
session.consume_text(
&frame(CLIENT_BINDING_STATUS_SUB_ID, &revision_four),
ISSUED_AT,
),
Some(ProjectionUpdate::Clear)
));
let revision_five = status(
&relay,
&author,
5,
ClientBindingStatusDisposition::DisplayCurrent,
);
assert!(matches!(
session.consume_text(
&frame(CLIENT_BINDING_STATUS_SUB_ID, &revision_five),
ISSUED_AT,
),
Some(ProjectionUpdate::Current(_))
));
}
#[test]
fn wrong_bootstrap_epoch_latches_session_invalid() {
let relay = Keys::generate();
let author = Keys::generate();
let wrong_epoch =
ClientBindingEpoch::parse("11111111-1111-4111-8111-111111111111").unwrap();
let mut session =
ClientBindingStatusSession::new(relay.public_key(), author.public_key(), epoch_b());
assert!(matches!(
session.consume_text(
&frame(
CLIENT_BINDING_BOOTSTRAP_SUB_ID,
&bootstrap(&relay, &author, wrong_epoch),
),
ISSUED_AT,
),
Some(ProjectionUpdate::Clear)
));
assert!(matches!(
session.consume_text(
&frame(
CLIENT_BINDING_BOOTSTRAP_SUB_ID,
&bootstrap(&relay, &author, epoch_b()),
),
ISSUED_AT,
),
Some(ProjectionUpdate::Clear)
));
let current = status(
&relay,
&author,
1,
ClientBindingStatusDisposition::DisplayCurrent,
);
assert!(matches!(
session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, &current), ISSUED_AT),
Some(ProjectionUpdate::Clear)
));
}
#[test]
fn withdrawal_expiry_and_disconnect_clear_current_projection() {
let relay = Keys::generate();
let author = Keys::generate();
let mut session = live_session(&relay, &author);
let withdrawal = status(
&relay,
&author,
2,
ClientBindingStatusDisposition::Withdrawn,
);
assert!(matches!(
session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, &withdrawal), ISSUED_AT,),
Some(ProjectionUpdate::Clear)
));
let mut session = live_session(&relay, &author);
assert!(matches!(
session.expire(FRESH_UNTIL),
ProjectionUpdate::Clear
));
let mut session = live_session(&relay, &author);
assert!(matches!(session.disconnect(), ProjectionUpdate::Clear));
}
}
+21 -4
View File
@@ -68,11 +68,9 @@ pub const KIND_LONG_FORM: u32 = 30023;
/// Parameterized replaceable (NIP-33, 3000039999 range) — keyed by `(pubkey, kind, d_tag)`.
/// Stored globally (channel_id = NULL); user-owned personal data, not channel-scoped.
pub const KIND_USER_STATUS: u32 = 30315;
/// NIP-85: relay-signed trusted assertion about a user pubkey.
/// NIP-85: relay-signed binding assertion about a user public key.
///
/// Buzz uses this standard user-subject assertion kind to project an active
/// enterprise identity binding without exposing the binding's stable uid.
/// The relay authors the event and keys it by the subject pubkey in `d`.
/// The relay authors the event and keys it by the subject public key in `d`.
pub const KIND_USER_TRUSTED_ASSERTION: u32 = 30382;
/// NIP-78 / NIP-RS: Per-client read state blob for cross-device read position sync.
/// Parameterized replaceable (NIP-33, 3000039999 range) — keyed by `(pubkey, kind, d_tag)`.
@@ -85,6 +83,13 @@ pub const KIND_AUTH: u32 = 22242;
pub const KIND_BLOSSOM_AUTH: u32 = 24242;
/// Buzz custom one-time identity binding proof (ephemeral, not stored).
pub const KIND_NOSTR_IDENTITY_BINDING: u32 = 24243;
/// Buzz relay-authenticated, display-only client binding status.
///
/// Kind 24244 is ephemeral and relay-authored. It is never durable profile
/// authority and must not be used to make authorization decisions.
pub const KIND_CLIENT_BINDING_STATUS: u32 = 24244;
/// Buzz relay-authenticated connection bootstrap (ephemeral, not stored).
pub const KIND_CLIENT_BINDING_BOOTSTRAP: u32 = 24245;
/// NIP-98: HTTP auth event (used in nip98.rs, not stored).
pub const KIND_HTTP_AUTH: u32 = 27235;
@@ -702,6 +707,8 @@ pub const ALL_KINDS: &[u32] = &[
KIND_TYPING_INDICATOR,
KIND_HUDDLE_REACTION,
KIND_BLOSSOM_AUTH,
KIND_CLIENT_BINDING_STATUS,
KIND_CLIENT_BINDING_BOOTSTRAP,
KIND_PAIRING,
KIND_AGENT_OBSERVER_FRAME,
KIND_HTTP_AUTH,
@@ -837,6 +844,8 @@ pub const fn is_relay_only_kind(kind: u32) -> bool {
matches!(
kind,
KIND_NIP43_MEMBERSHIP_LIST
| KIND_CLIENT_BINDING_STATUS
| KIND_CLIENT_BINDING_BOOTSTRAP
| KIND_CHANNEL_SUMMARY
| KIND_PRESENCE_SNAPSHOT
| KIND_DM_VISIBILITY
@@ -919,6 +928,14 @@ mod tests {
assert!(!is_relay_only_kind(KIND_NIP43_LEAVE_REQUEST));
}
#[test]
fn client_binding_connection_events_are_relay_only_and_ephemeral() {
assert!(is_relay_only_kind(KIND_CLIENT_BINDING_STATUS));
assert!(is_ephemeral(KIND_CLIENT_BINDING_STATUS));
assert!(is_relay_only_kind(KIND_CLIENT_BINDING_BOOTSTRAP));
assert!(is_ephemeral(KIND_CLIENT_BINDING_BOOTSTRAP));
}
#[test]
fn parameterized_replaceable_range() {
assert!(!is_parameterized_replaceable(29999));
+6
View File
@@ -11,6 +11,12 @@ pub mod agent_turn_metric;
pub mod authorization;
/// Channel and membership enums shared across crates.
pub mod channel;
/// Relay-authenticated connection bootstrap contract for client presentation.
pub mod client_binding_bootstrap;
/// Relay-authenticated, display-only current-binding status contract.
pub mod client_binding_status;
/// Public, I/O-free fold for connection-scoped current-binding presentation.
pub mod client_binding_status_session;
/// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation,
/// body parse/serialize, envelope build/validate, head selection.
pub mod engram;