From a8f37953e371a950c58f39c4f337a60ec31dfc52 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:19:37 -0500 Subject: [PATCH] test(auth): publish hosted O4 conformance selection Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .github/workflows/ci.yml | 29 ++ Cargo.lock | 1 + .../tests/fixtures/nip_fi_trusted_proxy.json | 90 +++++ .../tests/nip_fi_runtime_conformance.rs | 381 ++++++++++++++++++ desktop/src-tauri/Cargo.lock | 19 + docs/NIP_FI_RUNTIME_OPERATIONS.md | 152 +++++++ docs/nips/NIP-FI-RUNTIME-CONFORMANCE.md | 166 ++++++++ 7 files changed, 838 insertions(+) create mode 100644 crates/buzz-relay/tests/fixtures/nip_fi_trusted_proxy.json create mode 100644 crates/buzz-relay/tests/nip_fi_runtime_conformance.rs create mode 100644 docs/NIP_FI_RUNTIME_OPERATIONS.md create mode 100644 docs/nips/NIP-FI-RUNTIME-CONFORMANCE.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5f46ccd4..1efd0275a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index d1b247469..63ed5de96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1056,6 +1056,7 @@ version = "0.1.0" dependencies = [ "axum", "blurhash", + "buzz-auth", "buzz-core", "bytes", "chrono", diff --git a/crates/buzz-relay/tests/fixtures/nip_fi_trusted_proxy.json b/crates/buzz-relay/tests/fixtures/nip_fi_trusted_proxy.json new file mode 100644 index 000000000..affe64ec7 --- /dev/null +++ b/crates/buzz-relay/tests/fixtures/nip_fi_trusted_proxy.json @@ -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" + ] +} diff --git a/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs b/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs new file mode 100644 index 000000000..bb5ce7762 --- /dev/null +++ b/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs @@ -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::>() + .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 { + 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 +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 9feecbee0..476736f02 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -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", diff --git a/docs/NIP_FI_RUNTIME_OPERATIONS.md b/docs/NIP_FI_RUNTIME_OPERATIONS.md new file mode 100644 index 000000000..f9f1a1fd5 --- /dev/null +++ b/docs/NIP_FI_RUNTIME_OPERATIONS.md @@ -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. diff --git a/docs/nips/NIP-FI-RUNTIME-CONFORMANCE.md b/docs/nips/NIP-FI-RUNTIME-CONFORMANCE.md new file mode 100644 index 000000000..0995f9eb5 --- /dev/null +++ b/docs/nips/NIP-FI-RUNTIME-CONFORMANCE.md @@ -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.