mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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, ¤t), 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));
|
||||
}
|
||||
}
|
||||
@@ -68,11 +68,9 @@ pub const KIND_LONG_FORM: u32 = 30023;
|
||||
/// Parameterized replaceable (NIP-33, 30000–39999 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, 30000–39999 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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user