test(auth): publish hosted O4 conformance selection

Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
This commit is contained in:
Cea Stapleton Cordasco
2026-08-04 12:19:37 -05:00
parent 3a682c6fc1
commit a8f37953e3
7 changed files with 838 additions and 0 deletions

View File

@@ -356,6 +356,7 @@ jobs:
-p buzz-relay \
-p buzz-test-client \
--lib \
--test nip_fi_runtime_conformance \
--test e2e_event_reminder \
--archive-file target/ci/backend-integration-tests.tar.zst
- name: Save relay artifacts cache
@@ -713,6 +714,20 @@ jobs:
--run-ignored all
env:
DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz_identity_tests
- name: NIP-FI runtime and protected transport conformance
run: |
cargo nextest run \
--archive-file target/ci/backend-integration-tests.tar.zst \
-E 'binary(nip_fi_runtime_conformance)'
cargo nextest run \
--archive-file target/ci/backend-integration-tests.tar.zst \
-E 'package(buzz-relay) and (test(protected_media_reads_require_corporate_identity_for_get_and_head) or test(moderation_reads_require_corporate_identity_after_nip98_proof))' \
--test-threads 1 \
--run-ignored ignored-only
env:
DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
BUZZ_TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
REDIS_URL: redis://localhost:6379
- name: Workspace profile (kind:9033) gate tests
# Call-site integration for the 9033 authorization gate: open relay
# rosterless/steward transitions and the closed-relay admin/owner rule,
@@ -725,6 +740,20 @@ jobs:
--run-ignored ignored-only
env:
DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
- name: Protected Git and media authority migration tests
run: |
docker exec -e PGPASSWORD="${BUZZ_TEST_POSTGRES_PASSWORD}" buzz-postgres \
psql -U buzz -d postgres -v ON_ERROR_STOP=1 \
-c "CREATE DATABASE buzz_visibility_tests"
cargo nextest run \
--archive-file target/ci/backend-integration-tests.tar.zst \
-E '(package(buzz-db) and test(/protected_visibility::tests::cutover_waits/)) or (package(buzz-relay) and test(/api::media_migration::tests::populated_git_and_media_cutover/))' \
--test-threads 1 \
--run-ignored ignored-only
env:
DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz_visibility_tests
BUZZ_TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz_visibility_tests
REDIS_URL: redis://localhost:6379
- name: NIP-ER reminder e2e
# Feature e2e for NIP-ER (Event Reminders, kind:30300): write-path
# validation, author-only read filtering, and scheduler delivery against

1
Cargo.lock generated
View File

@@ -1056,6 +1056,7 @@ version = "0.1.0"
dependencies = [
"axum",
"blurhash",
"buzz-auth",
"buzz-core",
"bytes",
"chrono",

View File

@@ -0,0 +1,90 @@
{
"schema_version": 1,
"fixture_classification": "synthetic-only",
"full_stack_conformance_claim": false,
"presentation_gate": "disabled",
"trusted_proxy_cases": [
{
"row": "TR-1.direct-bypass",
"origin_isolation_enforced": false,
"inbound_assertion_header_stripped": true,
"expected": "deny-before-verification"
},
{
"row": "TR-1.inbound-header-copy",
"origin_isolation_enforced": true,
"inbound_assertion_header_stripped": false,
"expected": "deny-before-verification"
},
{
"row": "TR-1.complete-deployment-evidence",
"origin_isolation_enforced": true,
"inbound_assertion_header_stripped": true,
"expected": "eligible-for-provider-verification"
}
],
"nip_fi_rows": [
{
"row": "AS-3.future-iat",
"owner": "o4-client-status",
"status": "covered"
},
{
"row": "BD-1.cross-domain",
"owner": "authorization-runtime",
"status": "required-before-full-stack-claim"
},
{
"row": "SE-4.invalidation",
"owner": "invalidation-runtime",
"status": "required-before-full-stack-claim"
},
{
"row": "DG-3.no-finite-bound",
"owner": "delegation-runtime",
"status": "required-before-full-stack-claim"
},
{
"row": "OP-2.discovery",
"owner": "o4-client-status",
"status": "covered"
},
{
"row": "OP-3.absent",
"owner": "o4-client-status",
"status": "covered"
},
{
"row": "OP-3.implemented",
"owner": "o4-client-status",
"status": "disabled-pending-approved-rfc-presentation-gate"
},
{
"row": "OP-4.privacy",
"owner": "o4-client-status",
"status": "covered"
}
],
"j3c_rows": [
"J3C-STATUS-RELAY-SIGNER",
"J3C-STATUS-EXACT-SCOPE",
"J3C-STATUS-FRESHNESS",
"J3C-STATUS-REVISION-FOLD",
"J3C-STATUS-WITHDRAWAL",
"J3C-STATUS-PRIVACY",
"J3C-STATUS-VERIFY-ONLY",
"J3C-STATUS-DEDICATED-TRANSPORT",
"J3C-STATUS-REAL-USER-HIDDEN"
],
"forbidden_public_fields": [
"iss",
"sub",
"display_name",
"bearer_assertion",
"private_audience",
"provider_tenant_url",
"corporate_history",
"historical_label",
"employment_history"
]
}

View File

@@ -0,0 +1,381 @@
//! Structural O4 conformance checks for the disabled client-status surface.
use std::fs;
use std::path::{Path, PathBuf};
const FIXTURE: &str = include_str!("fixtures/nip_fi_trusted_proxy.json");
const STATUS_MODULE: &str = include_str!("../src/authorization_runtime/status.rs");
const ASSERTION_VERIFIER: &str = include_str!("../src/corporate_identity.rs");
const INVALIDATION_RUNTIME: &str = include_str!("../src/authorization_runtime/invalidation.rs");
const TRANSPORT_RUNTIME: &str = include_str!("../src/authorization_runtime/transport.rs");
const FINALIZATION_RUNTIME: &str = include_str!("../src/authorization_runtime/finalization.rs");
const PRODUCTION_RUNTIME: &str = include_str!("../src/authorization_runtime/production.rs");
const AUTH_HANDLER: &str = include_str!("../src/handlers/auth.rs");
const KIND_REGISTRY: &str = include_str!("../../buzz-core/src/kind.rs");
const INGEST_HANDLER: &str = include_str!("../src/handlers/ingest.rs");
#[test]
fn mandatory_o4_security_contracts_are_present() {
let cases = [
(
"protected-header-denial",
ASSERTION_VERIFIER.contains("headers.get_all(config.jwt_header.as_str())")
&& ASSERTION_VERIFIER.contains("values.next().is_some()")
&& ASSERTION_VERIFIER.contains("raw.contains(',')"),
),
(
"bounded-known-key-jwks-degradation",
ASSERTION_VERIFIER.contains("JWKS_CACHE_MAX_AGE")
&& ASSERTION_VERIFIER.contains("buzz_jwks_stale_key_uses_total")
&& ASSERTION_VERIFIER.contains("buzz_jwks_unknown_kid_total")
&& ASSERTION_VERIFIER.contains("buzz_jwt_verification_errors_total"),
),
(
"lease-expiry-and-invalidation",
TRANSPORT_RUNTIME.contains("expiry_delay")
&& INVALIDATION_RUNTIME.contains("cancel_invalid(community_id)"),
),
(
"revocation-enforcement-timing",
INVALIDATION_RUNTIME.contains("buzz_authorization_revocation_to_enforcement_seconds"),
),
(
"client-status-fail-closed-degradation",
AUTH_HANDLER.contains("client_status_unavailable")
&& AUTH_HANDLER.contains("buzz_client_status_degradation_total"),
),
(
"deny-protected-request-renewal-and-session-eviction",
FINALIZATION_RUNTIME.contains("DenyProtected")
&& PRODUCTION_RUNTIME
.contains("\"deny_protected\" => AuthorizationMode::DenyProtected")
&& TRANSPORT_RUNTIME.contains("AuthorizationMode::DenyProtected =>")
&& TRANSPORT_RUNTIME.contains("deny_protected_request(request)")
&& TRANSPORT_RUNTIME.contains("request.cancellation()")
&& TRANSPORT_RUNTIME.contains("cancellation.cancel()")
&& TRANSPORT_RUNTIME.contains("ProtectedTransportError::DenyProtected"),
),
];
for (name, present) in cases {
assert!(present, "missing mandatory O4 security contract: {name}");
}
for source in [ASSERTION_VERIFIER, INVALIDATION_RUNTIME, AUTH_HANDLER] {
let observability = source
.lines()
.filter(|line| line.contains("metrics::"))
.collect::<Vec<_>>()
.join("\n");
for forbidden in [
"identity_token =",
"access_token =",
"refresh_token =",
"uid =",
"subject =",
"kid =",
] {
assert!(
!observability.contains(forbidden),
"security observability gained a private label: {forbidden}"
);
}
}
}
#[test]
fn synthetic_fixture_names_required_nip_fi_and_j3c_rows() {
let fixture: serde_json::Value = serde_json::from_str(FIXTURE).expect("fixture JSON parses");
assert_eq!(fixture["fixture_classification"], "synthetic-only");
assert_eq!(fixture["full_stack_conformance_claim"], false);
assert_eq!(fixture["presentation_gate"], "disabled");
for row in [
"TR-1.direct-bypass",
"TR-1.inbound-header-copy",
"TR-1.complete-deployment-evidence",
"AS-3.future-iat",
"BD-1.cross-domain",
"SE-4.invalidation",
"DG-3.no-finite-bound",
"OP-2.discovery",
"OP-3.absent",
"OP-3.implemented",
"OP-4.privacy",
"J3C-STATUS-RELAY-SIGNER",
"J3C-STATUS-EXACT-SCOPE",
"J3C-STATUS-FRESHNESS",
"J3C-STATUS-REVISION-FOLD",
"J3C-STATUS-WITHDRAWAL",
"J3C-STATUS-PRIVACY",
"J3C-STATUS-VERIFY-ONLY",
"J3C-STATUS-DEDICATED-TRANSPORT",
"J3C-STATUS-REAL-USER-HIDDEN",
] {
assert!(FIXTURE.contains(row), "fixture omitted required row {row}");
}
let future_iat = fixture["nip_fi_rows"]
.as_array()
.expect("NIP-FI rows are an array")
.iter()
.find(|row| row["row"] == "AS-3.future-iat")
.expect("future-iat allocation exists");
assert_eq!(future_iat["owner"], "o4-client-status");
assert_eq!(future_iat["status"], "covered");
let projection = fixture["nip_fi_rows"]
.as_array()
.expect("NIP-FI rows are an array")
.iter()
.find(|row| row["row"] == "OP-3.implemented")
.expect("implemented projection allocation exists");
assert_eq!(
projection["status"],
"disabled-pending-approved-rfc-presentation-gate"
);
}
#[test]
fn optional_iat_uses_the_shared_injected_authorization_clock() {
assert!(ASSERTION_VERIFIER.contains("self.authorization_clock.now()?"));
assert!(ASSERTION_VERIFIER.contains("validate_optional_iat("));
assert!(
!ASSERTION_VERIFIER
.contains("validate_optional_iat(&decoded.claims.claims, Timestamp::now().as_secs())"),
"optional iat must not bypass the injected authorization clock"
);
}
#[test]
fn client_authored_status_is_rejected_by_the_central_relay_only_fence() {
let relay_only_predicate = KIND_REGISTRY
.split("pub const fn is_relay_only_kind")
.nth(1)
.and_then(|suffix| suffix.split("/// Extract the kind").next())
.expect("central relay-only predicate exists");
assert!(relay_only_predicate.contains("KIND_CLIENT_BINDING_STATUS"));
assert!(INGEST_HANDLER.contains("buzz_core::kind::is_relay_only_kind(kind_u32)"));
assert!(INGEST_HANDLER.contains("restricted: relay-only kind"));
}
#[test]
fn public_projection_retirement_is_durable_internal_and_not_operator_wired() {
let production = ASSERTION_VERIFIER
.split("#[cfg(test)]")
.next()
.expect("production assertion verifier exists");
let migration =
include_str!("../../../migrations/0044_identity_public_projection_retirement.sql");
assert!(migration.contains("identity_public_projection_retirements"));
assert!(migration.contains("source_binding_id"));
assert!(migration.contains("source_binding_version"));
for private in [
"issuer TEXT",
"uid",
"display_name",
"actor",
"reason",
"identity_token",
"access_token",
"refresh_token",
] {
assert!(
!migration.contains(private),
"projection retirement state gained private field {private}"
);
}
assert!(
production.contains("begin_active_public_projection"),
"active projection publication must revalidate the exact binding at its database boundary"
);
let production_runtime = include_str!("../src/authorization_runtime/production.rs");
assert!(
production_runtime.contains("reconcile_public_projection_retirements_startup"),
"committed lifecycle retirement must be reconciled before protected runtime installation"
);
assert!(
production_runtime.contains("run_public_projection_retirement_reconciliation"),
"committed lifecycle retirement needs durable periodic/restart reconciliation"
);
for operator_surface in [
include_str!("../src/api/operator.rs"),
include_str!("../src/api/bridge.rs"),
] {
assert!(!operator_surface.contains("PublicProjectionRetirement"));
}
}
#[test]
fn trusted_proxy_fixture_fails_closed_without_both_deployment_controls() {
let fixture: serde_json::Value = serde_json::from_str(FIXTURE).expect("fixture JSON parses");
let cases = fixture["trusted_proxy_cases"]
.as_array()
.expect("trusted proxy cases are an array");
assert_eq!(cases.len(), 3);
for case in cases {
let isolation = case["origin_isolation_enforced"]
.as_bool()
.expect("fixture isolation flag is boolean");
let stripping = case["inbound_assertion_header_stripped"]
.as_bool()
.expect("fixture stripping flag is boolean");
let expected = case["expected"]
.as_str()
.expect("fixture expectation is a string");
if isolation && stripping {
assert_eq!(expected, "eligible-for-provider-verification");
} else {
assert_eq!(expected, "deny-before-verification");
}
}
}
#[test]
fn verification_only_adapter_has_no_authority_storage_or_pubsub_dependency() {
let production = STATUS_MODULE
.split("#[cfg(test)]")
.next()
.expect("production status module exists");
for forbidden in [
"AuthContext",
"AuthorizationLease",
"CapabilitySet",
"AuthState",
"buzz_db",
"buzz_pubsub",
"publish_event",
"store_event",
"KIND_USER_TRUSTED_ASSERTION",
"corporate_identity",
] {
assert!(
!production.contains(forbidden),
"verification-only status gained forbidden authority path {forbidden}"
);
}
assert_eq!(
production
.matches("pub struct ClientStatusPresentationPermit {")
.count(),
1,
"the disabled presentation permit must have one opaque definition"
);
assert!(production.contains("impl ClientStatusPresentationPermit"));
assert!(production.contains("pub fn from_complete_stack("));
assert!(production.contains("reviewed_implementation_revision"));
assert!(production.contains("presentation_gate_passed"));
assert!(production.contains("dedicated_client_contract_passed"));
assert!(
!production.contains("std::env"),
"presentation must not be enabled by an environment boolean"
);
let current_issuance = production
.split("pub fn issue_verification_only")
.nth(1)
.and_then(|suffix| suffix.split("/// Sign a generic withdrawal").next())
.expect("current-display issuance method exists");
assert!(
current_issuance.contains("&ClientStatusPresentationPermit"),
"current-display signing must require the complete-stack permit"
);
assert_eq!(
production
.matches("issue_current(&evidence, label)")
.count(),
1,
"no second production current-display signing path may bypass the permit"
);
}
#[test]
fn status_uses_only_the_dedicated_authenticated_production_path() {
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let repo = manifest
.parent()
.and_then(Path::parent)
.expect("relay crate is nested under repository crates directory");
let roots = [
manifest.join("src/handlers"),
manifest.join("src/api"),
manifest.join("src/main.rs"),
manifest.join("src/router.rs"),
manifest.join("src/subscription.rs"),
manifest.join("src/connection.rs"),
manifest.join("src/protocol.rs"),
repo.join("desktop/src"),
repo.join("desktop/src-tauri/src"),
repo.join("mobile/lib"),
repo.join("web/src"),
];
for root in roots {
for file in source_files(&root) {
if file.ends_with("src/handlers/auth.rs") {
continue;
}
let source = fs::read_to_string(&file).expect("source file is readable");
for forbidden in [
"KIND_CLIENT_BINDING_STATUS",
"ClientBindingStatus",
"client_binding_status",
"24244",
"deliver_verification_only",
] {
assert!(
!source.contains(forbidden),
"{} exposes status through an ordinary route {forbidden}",
file.display()
);
}
}
}
let handler = fs::read_to_string(manifest.join("src/handlers/auth.rs"))
.expect("AUTH handler source is readable");
let state = fs::read_to_string(manifest.join("src/state.rs"))
.expect("application state source is readable");
let status = fs::read_to_string(manifest.join("src/authorization_runtime/status.rs"))
.expect("status runtime source is readable");
let nip11 =
fs::read_to_string(manifest.join("src/nip11.rs")).expect("NIP-11 source is readable");
let router =
fs::read_to_string(manifest.join("src/router.rs")).expect("router source is readable");
assert!(handler.contains("present_after_auth"));
assert!(state.contains("install_client_status_runtime"));
assert!(state.contains("install_nip_fi_discovery"));
assert!(status.contains("from_complete_stack"));
assert!(status.contains("__buzz_client_binding_status_v1__"));
assert!(nip11.contains("state.nip_fi_discovery()"));
assert!(nip11.contains("with_conformant_federated_identity"));
assert!(router.contains("nip11_document(&state, raw_host).await"));
assert!(!status.contains("std::env"));
}
fn source_files(root: &Path) -> Vec<PathBuf> {
if root.is_file() {
return vec![root.to_path_buf()];
}
let mut pending = vec![root.to_path_buf()];
let mut files = Vec::new();
while let Some(directory) = pending.pop() {
for entry in fs::read_dir(&directory).expect("source directory is readable") {
let path = entry.expect("source directory entry is readable").path();
if path.is_dir() {
pending.push(path);
} else if path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
matches!(extension, "rs" | "ts" | "tsx" | "js" | "jsx" | "dart")
})
{
files.push(path);
}
}
}
files
}

View File

@@ -1013,6 +1013,24 @@ dependencies = [
"webbrowser",
]
[[package]]
name = "buzz-auth"
version = "0.1.0"
dependencies = [
"buzz-core",
"hex",
"nostr",
"rand 0.10.2",
"serde",
"serde_json",
"sha2 0.11.0",
"thiserror 2.0.18",
"tokio",
"tracing",
"url",
"uuid",
]
[[package]]
name = "buzz-core"
version = "0.1.0"
@@ -1130,6 +1148,7 @@ version = "0.1.0"
dependencies = [
"axum",
"blurhash",
"buzz-auth",
"buzz-core",
"bytes",
"chrono",

View File

@@ -0,0 +1,152 @@
# NIP-FI runtime operations
This runbook covers provider-neutral NIP-FI session/discovery behavior and the
separate disabled relay-authenticated client-status contract. It does not
authorize enabling a provider, a client presentation surface, or a conformance
claim.
## Session and reconnect behavior
For WebSocket authorization, the assertion belongs on the upgrade request and
fresh NIP-42 proof follows on that connection. A direct lease ends at the
earliest assertion, binding, policy, or implementation bound. Base V1 has no
in-connection assertion renewal: expiry requires a new connection, a fresh
upgrade assertion, and fresh NIP-42 proof.
Delegated sessions require a separately validated delegation, an active owner
binding, and a positive finite configured implementation maximum. A cached
owner lease is not substitute authority. Reconnect requires fresh delegate
proof and revalidation of every dependency.
When an observed binding, identity, key, policy, or delegation dependency
becomes invalid, reject protected operations or close the affected connection
within the documented detection bound. A polling deployment must publish its
maximum detection latency and must not claim immediate revocation.
The optional assertion `iat` check uses the shared injected authorization
clock. The current JWT library still evaluates `exp` and `nbf` with its own
system-clock source, so operators must maintain host clock synchronization and
must not claim fully centralized assertion time until that library boundary is
made injectable.
`iat` is optional in Base V1. When present, a malformed or more-than-60-second
future value is rejected. A `kid` absent from a still-fresh JWKS set is denied
without an immediate refetch; the default set lifetime is 300 seconds. Issuers
must overlap old and new signing keys for at least the cache lifetime plus the
documented clock allowance. Refresh after expiry is single-flight, and refresh
or issuer failure never falls back to an unverified key.
The assertion header is singular. Multiple field lines, a comma-combined
value, invalid UTF-8, or an empty value is denied before verification. A
trusted-proxy adapter must prove origin isolation and that it stripped every
inbound copy before injecting exactly one assertion; merely observing the
configured header is not transport provenance.
Protected media downloads include both `GET` and `HEAD`. An enforcing domain
cannot expose either method without current authority; a deployment that wants
public media needs a separately reviewed public-media policy rather than an
implicit read bypass.
Client status is presentation-only. It expires independently of an
authorization lease and is cleared on expiry, disconnect, relay-key change,
domain change, or author change. A status cannot renew a session, authorize an
operation, create a binding, mint a lease, or mutate membership.
## Upgrade sequence
1. Upgrade and reconcile durable authorization, binding, lifecycle, lease, and
status-revision-floor state before enabling any behavior.
2. Deploy servers with NIP-FI discovery absent and the client-status
presentation gate disabled.
3. Run every applicable NIP-FI row against the exact candidate revision. For
`trusted-proxy`, attach enforced origin-isolation evidence plus negative
direct-bypass and inbound-header-copy tests.
4. Confirm mixed-version servers all omit discovery. Never advertise based on
a per-process flag or a partial fleet.
5. Supply a complete-stack conformance input only after the whole serving fleet
runs the reviewed revision and all applicable rows pass.
6. Supply the typed client-presentation approval only after its deployment,
privacy, and client-compatibility gates pass at one exact revision. The
stock binary has no environment or boolean shortcut; without that injected
proof it cannot construct the presentation permit or install the dedicated
exact-connection transport.
Old clients ignore unknown status events, and old servers emit none. New
clients must default to no indicator when status is absent, invalid, expired,
withheld, or unsupported. NIP-FI authorization behavior must remain identical
whether client presentation code is present or absent.
## Rollback
Remove the complete-stack readiness input before or with the first server
rollback so NIP-11 immediately omits NIP-FI discovery. Do not leave discovery
enabled for a mixed or unreviewed fleet.
Client-status rollback requires no authority migration: the events are
ephemeral and display-only. Disconnect affected clients or wait no longer than
the bounded status lifetime; clients clear on either condition. Never translate
a cached status into an authorization decision during rollback.
Preserve durable authorization and lifecycle state. Preserve and reconcile the
status revision floor so a restored older process cannot emit a lower revision
that a client might mistake for current state. If that state is unavailable,
emit no status.
## Public projection retirement
The privacy-approved NIP-85 label projection is optional and never authority.
After an authoritative revoke or rotate commits, the lifecycle integration must
derive internal retirement work from the committed lifecycle record. The work
contains only the server-resolved domain, old public Nostr key, relay author,
operation identifier, and opaque binding generation. It must not contain
issuer, subject, display label, provider claims, actor, or free-text reason.
The reconciler idempotently replaces an active projection with the existing
inactive, label-free parameterized event.
A read, clock, build, or write failure must not roll back the already committed
lifecycle mutation. Retry the same domain/key request. If a write committed but
its acknowledgement was lost, the retry observes the inactive replacement and
terminates without another write.
The runtime materializes committed revoke/rotate operations into an internal
durable queue, fences active publication and retirement with the exact binding
generation, and drains unfinished projection and delivery work before protected
runtime installation and after restart. Periodic discovery is the crash-window
backstop. The active projection TTL remains defense in depth. Authenticated
lifecycle routes and durable operator audit remain separately owned.
## Backup and restore
Back up authoritative binding/lifecycle state, policy state, cryptographic
secrets required by deployment policy, and durable status revision/floor state
using the owning subsystem's procedure. Protect the dedicated client-status
privacy key as a secret and never reuse it across unrelated deployments.
Do not back up or restore:
- authorization or provider caches;
- direct or delegated leases;
- WebSocket connection state;
- client presentation caches;
- emitted client-status events; or
- ordinary event/pubsub copies of client status, because none may exist.
After restore, start with discovery absent and presentation disabled. Rebuild
authorization decisions from authoritative state, reconcile revision floors,
reconcile committed projection retirements, and reconnect clients with fresh
assertions/proofs. If the relay signing key or client-status privacy key
changed, treat every old presentation as invalid. A restored service must
complete same-revision conformance again before discovery can return.
## Privacy and observability
Logs, metrics, traces, fixtures, and NIP-11 output must not contain raw bearer
assertions or unredacted issuer, subject, audience, tenant URL, claim name,
display name, email, or provider-private metadata. Use bounded categorical
failure classes and pseudonymous correlation where necessary.
Alert on aggregate validation failures, revision-source unavailability,
dedicated-transport unavailability after a future gate is approved, and
dependency-invalidation lag. Do not include the rejected private value in an
alert. The presence or absence of a client status is not evidence of access and
must never drive an authorization SLO.

View File

@@ -0,0 +1,166 @@
# NIP-FI runtime conformance and client-status boundary
This document maps Buzz runtime evidence to the normative
[NIP-FI specification](NIP-FI.md), [formal model](NIP-FI-MODEL.md), and
[conformance matrix](NIP-FI-CONFORMANCE.md). It does not make a conformance
claim. Discovery remains absent until an injected report proves that every
applicable row passed against one reviewed implementation revision.
## Discovery gate
`RelayInfo::build` omits both `limitation.federated_identity` and the top-level
`federated_identity` object. `ConformanceReadyNipFiDiscovery` is the only API
that can add them. It requires all of the following:
- a provider-neutral discovery value with at least one unique supported
transport and exactly one enrollment mode;
- a positive finite delegated-lease maximum whenever delegation is advertised;
- an exact 40-character reviewed Git revision;
- an injected complete-stack result asserting that every applicable row passed
at that same revision; and
- for `trusted-proxy`, deployment evidence for both origin isolation and
stripping untrusted inbound assertion-header copies.
The reviewed revision and deployment evidence are gate inputs, not public
metadata. NIP-11 exposes no issuer URL, tenant URL, claim name, subject,
audience, assertion header name, or provisional NIP number. Unsupported
behavior is omitted rather than advertised as partially implemented.
The assertion header is singular at every ingress. Repeated field lines,
comma-combined values, invalid UTF-8, and empty values fail closed. An installed
adapter must supply verified transport provenance; header presence alone is
never classified as `trusted-proxy`. Protected media `GET` and `HEAD` remain
inside the enforcing transport inventory unless a separate reviewed public
media policy is selected.
The optional assertion `iat` check uses the shared injected authorization
clock and accepts a missing claim. A present value must be an unsigned integer
no later than injected verifier time plus the bounded 60-second skew. The
current `jsonwebtoken` dependency still evaluates `exp` and `nbf` against its
own system-clock source. Therefore `AS-3.future-iat` is covered, but this
candidate does not claim that all assertion-time checks use one injected clock;
that inherited limitation remains part of the full-stack review.
## Relay-authenticated client status
Kind `24244` is a Buzz-local, short-lived presentation contract, not NIP-FI
authorization evidence or a NIP-FI conformance surface. A status is signed by
the trusted relay and scoped to an exact server-resolved authorization domain
and event-author key. A current status contains a binding version, a
privacy-keyed policy revision, a monotonic durable status revision, and a
bounded validity window. A withdrawal contains only its exact scope, revision,
and bounded validity window. The two wire states are:
- `display_current`; or
- `withdrawn`, with no lifecycle cause or historical binding fields.
Clients fold only within one trusted relay/domain/author scope. A lower
revision is rejected. An equal revision is idempotent only for the identical
signed event; a conflicting equal revision is rejected. Expiry, disconnect,
relay-key change, authorization-domain change, or event-author change clears
presentation. Revision high-water state may survive a transient disconnect,
but it is never authority.
The relay adapter is one-way from `VerificationOnlyDisposition` to a signed
event. It has no dependency on authorization leases, membership mutation,
event ingest, persistence, subscriptions, pub/sub, ordinary delivery, or
NIP-85. The production seam targets an exact authenticated connection and can
construct its permit only from typed evidence that the RFC presentation,
privacy, and dedicated-client gates passed at one exact reviewed revision. The
stock binary supplies no such evidence, key, or transport, so status remains
disabled by default.
The optional label constructor accepts only privacy-approved server
configuration. There is no constructor from issuer data, subject data,
`display_name`, mutable profile content, or provider decisions. The policy
revision is a length-framed, domain-separated HMAC under an injected dedicated
client-status privacy key; identical provider values are unlinkable under
distinct keys.
## Stable row allocation
The synthetic fixture is
`crates/buzz-relay/tests/fixtures/nip_fi_trusted_proxy.json`. It contains no
production issuer, subject, domain, key, assertion, or tenant data.
| Row | Evidence in this lane | Full-stack state |
|---|---|---|
| `TR-1.direct-bypass` | Negative origin-isolation fixture | Deployment proof still required |
| `TR-1.inbound-header-copy` | Negative header-copy fixture | Deployment proof still required |
| `TR-1.complete-deployment-evidence` | Positive two-control fixture shape | Real enforced-control evidence still required |
| `AS-3.future-iat` | Optional assertion `iat` accepts absence and bounded skew; malformed or farther-future values fail closed. Status also rejects future issue time | Covered by O4 |
| `BD-1.cross-domain` | Status validation and folding reject cross-domain scope | Authorization-runtime row must pass at the reviewed revision |
| `SE-4.invalidation` | Withdrawal and client clearing are covered | Lease invalidation runtime must pass at the reviewed revision |
| `DG-3.no-finite-bound` | Discovery cannot represent delegation without a positive bound | Delegation authorization must pass at the reviewed revision |
| `OP-2.discovery` | Default omission, provider-neutral fields, and complete-stack gate | Covered here; final claim still requires all rows |
| `OP-3.absent` | No real-user route or ordinary delivery path | Covered |
| `OP-3.implemented` | Dedicated exact-connection production seam exists behind typed complete-stack approval | Disabled unless the approval, privacy key, transport, and runtime are explicitly installed |
| `OP-4.privacy` | Keyed revision, bounded configured label, field/source scans | Covered |
The local client-status rows are:
- `J3C-STATUS-RELAY-SIGNER`
- `J3C-STATUS-EXACT-SCOPE`
- `J3C-STATUS-FRESHNESS`
- `J3C-STATUS-REVISION-FOLD`
- `J3C-STATUS-WITHDRAWAL`
- `J3C-STATUS-PRIVACY`
- `J3C-STATUS-VERIFY-ONLY`
- `J3C-STATUS-DEDICATED-TRANSPORT`
- `J3C-STATUS-REAL-USER-HIDDEN`
These J3C rows test presentation safety only. They cannot substitute for any
NIP-FI authorization, lifecycle, session, delegation, or deployment row.
## Public projection retirement join
The existing opt-in NIP-85 label projection is separate from both NIP-FI
authorization and kind `24244` client status. Its active assertion is TTL
bounded, but a committed revoke or rotate also needs an inactive parameterized
replacement for the old public key.
The public-projection retirement reconciler is a provider-neutral post-commit
seam. It derives private retry work from committed lifecycle rows and persists
only public event coordinates plus opaque binding generations. The relay reads
the exact relay-authored projection and, when active, writes the existing
`active=false`, `expiration=0`, label-free replacement. A missing or already
inactive projection is an idempotent terminal result. Store or clock failure
leaves both lifecycle authority and the active projection unchanged while the
work remains retryable.
Active publication and retirement share the identity-key and parameterized
event commit boundaries. Server-only head metadata prevents a delayed rotation
job from retiring a later legitimate use of the same key. Startup and periodic
reconciliation provide restart recovery and Redis/local delivery retry. This
lane does not add authenticated lifecycle endpoints or durable operator audit.
## Compatibility cases
| Case | Required result |
|---|---|
| Old relay, new client | No discovery or status; client shows no indicator |
| New relay, old client | Unknown ephemeral status is ignored; ordinary event behavior is unchanged |
| Mixed relay fleet before complete conformance | Discovery stays absent; presentation stays disabled |
| Stale client cache | Expired status is cleared; lower or conflicting revisions cannot restore it |
| Spoofed user event | Wrong signer, kind, tags, content, or signature is rejected |
| Cross-domain replay | Exact expected domain and author mismatch is rejected; scope change clears state |
| Relay signing-key rotation | Old presentation is cleared and the new relay key must be trusted independently |
| Privacy-key rotation | Policy revision changes; it grants no authority and clients accept it only at a higher durable status revision |
| Provider or lifecycle outage | Relay issues an opaque `withdrawn` status only with authoritative revision evidence, otherwise emits nothing |
| Gate disabled | No presentation runtime is installed; no real-user status is delivered |
## Mechanical checks
Run from the repository root in the Hermit environment:
```sh
cargo test -p buzz-core client_binding_status
cargo test -p buzz-relay authorization_runtime::status
cargo test -p buzz-relay nip11
cargo test -p buzz-relay --test nip_fi_runtime_conformance
```
The integration test scans ordinary relay ingest, API, router, state,
subscription, connection, and protocol sources, plus desktop, mobile, and web
client sources. Any reference to the status kind, contract, or disabled
delivery method fails the test.