From afed8a884551789f8f111ad04f74cf2fa5c56767 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 28 Aug 2026 12:01:28 -0400 Subject: [PATCH 01/15] feat(db): add NIP-FI identity + final-admission schema foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the Phase-A NIP-FI schema as two internally-ordered migrations: 0040 lays down the core identity and base-lifecycle relations, and 0041 applies to 0040's resulting state to add the final-admission surface (replay/receipt, audit events, invalidation, capacity, protected-object authority, restore version deltas, and the closed admission result). Identity is issuer-qualified (iss, sub) with no hardcoded issuer. All 15 NIP-FI relations are a durable, immutable, append-only security ledger: both migrations widen the single SQL source of truth community_write_fence_excluded_table so the relations are never fence-attached, purged on community deletion, nor counted as tenant-scoped drift by the deletion control plane's exact-set catalog check — the same posture as product_feedback and rate_limit_violations. schema.sql keeps one consolidated definition of that function whose body byte-matches 0041. Signed-off-by: Will Pfleger Co-authored-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com> --- crates/buzz-db/src/runtime/migration.rs | 231 ++- .../0041_nip_fi_identity_foundation.sql | 879 +++++++++ .../0042_nip_fi_authorization_foundation.sql | 749 ++++++++ schema/schema.sql | 1578 ++++++++++++++++- 4 files changed, 3429 insertions(+), 8 deletions(-) create mode 100644 migrations/0041_nip_fi_identity_foundation.sql create mode 100644 migrations/0042_nip_fi_authorization_foundation.sql diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 66251563cbd..10068eaaa44 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -690,7 +690,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 40); + assert_eq!(migrations.len(), 42); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1232,6 +1232,45 @@ mod tests { operator_audit.contains("_operator_global_tables"), "migration 39 must register relay_operator_audit in _operator_global_tables" ); + + // NIP-FI core identity + base-lifecycle foundation (migration 0041) and + // final-admission foundation (0042). Both widen the single SQL source of + // truth `community_write_fence_excluded_table` so their durable, + // immutable ledger relations are never fence-attached, purged, or + // counted as tenant-scoped drift. schema.sql keeps one consolidated + // definition of that function whose body must match 0042's exactly. + assert_eq!(migrations[40].version, 41); + let identity_foundation = migrations[40].sql.as_str(); + assert!(identity_foundation.contains("CREATE TABLE identity_bindings")); + assert!(identity_foundation.contains("CREATE TABLE identity_lifecycle_history")); + assert!(identity_foundation + .contains("CREATE OR REPLACE FUNCTION community_write_fence_excluded_table")); + assert!(identity_foundation.contains("'identity_bindings'")); + + assert_eq!(migrations[41].version, 42); + let authorization_foundation = migrations[41].sql.as_str(); + assert!(authorization_foundation.contains("CREATE TABLE authorization_events")); + assert!(authorization_foundation.contains("CREATE TABLE protected_object_authority")); + assert!(authorization_foundation.contains("CREATE TABLE authorization_admission_results")); + + // The consolidated desired-state exclusion function must byte-match + // migration 0042's CREATE OR REPLACE body, or a future schema + // consolidation would silently drop NIP-FI relations from the ledger. + fn extract_excluded_table_array(sql: &str) -> &str { + let anchor = "community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN"; + let start = sql.find(anchor).expect("exclusion function definition"); + let array_start = sql[start..].find("ARRAY[").expect("exclusion array") + start; + let array_end = sql[array_start..] + .find("]::TEXT[]") + .expect("exclusion array end") + + array_start; + &sql[array_start..array_end] + } + assert_eq!( + extract_excluded_table_array(authorization_foundation), + extract_excluded_table_array(desired_schema), + "schema.sql exclusion list drifted from migration 0042" + ); } #[test] @@ -2609,4 +2648,194 @@ mod tests { .await .expect("drop late-table fixtures"); } + + /// NIP-FI intermediate state: migration 0041 (identity + base lifecycle) + /// alone must present a coherent catalog. Its five community-scoped ledger + /// relations are immutable and durable, so they are registered in the + /// write-fence exclusion — never counted as tenant-scoped drift, never + /// fence-attached — and the exact deletion catalog must still validate. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn migration_0041_identity_foundation_is_durable_ledger_after_migration_a() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(41, &pool) + .await + .expect("apply migrations 1-41"); + + // The five identity relations exist. + let identity_tables = [ + "authorization_operation_receipts", + "identity_enrollment_policies", + "identity_bindings", + "identity_lifecycle_history", + "identity_lifecycle_selectors", + ]; + for table in identity_tables { + let exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS (SELECT 1 FROM information_schema.tables \ + WHERE table_schema = 'public' AND table_name = $1)", + ) + .bind(table) + .fetch_one(&pool) + .await + .unwrap_or_else(|err| panic!("check table {table}: {err}")); + assert!(exists, "migration 0041 must create {table}"); + } + + // Migration B's relations must NOT exist yet. + for table in ["authorization_events", "protected_object_authority"] { + let exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS (SELECT 1 FROM information_schema.tables \ + WHERE table_schema = 'public' AND table_name = $1)", + ) + .bind(table) + .fetch_one(&pool) + .await + .unwrap_or_else(|err| panic!("check table {table}: {err}")); + assert!(!exists, "{table} belongs to migration 0042, not 0041"); + } + + // Every identity relation is excluded from the write fence: none may + // appear as tenant-scoped drift or carry the fence trigger. + let scoped_or_fenced: Vec = sqlx::query_scalar( + "WITH scoped AS ( \ + SELECT c.relname FROM pg_class c \ + JOIN pg_namespace n ON n.oid = c.relnamespace \ + JOIN pg_attribute a ON a.attrelid = c.oid \ + WHERE n.nspname = 'public' AND c.relkind IN ('r','p') \ + AND NOT c.relispartition AND a.attname = 'community_id' \ + AND NOT a.attisdropped \ + AND NOT community_write_fence_excluded_table(c.relname) \ + ) \ + SELECT relname FROM scoped \ + WHERE relname = ANY($1) ORDER BY relname", + ) + .bind(&identity_tables[..]) + .fetch_all(&pool) + .await + .expect("read scoped identity relations"); + assert!( + scoped_or_fenced.is_empty(), + "identity ledger relations must be write-fence excluded, not scoped: {scoped_or_fenced:?}" + ); + + // The exact deletion catalog validates: the excluded ledger relations + // do not perturb the scoped-table/fence equality check. + crate::deletion::DeletionStore::new(pool.clone()) + .validate_catalog() + .await + .expect("deletion catalog validates after migration 0041"); + + // The immutability contract is enforced, not merely declared. TRUNCATE + // fires the statement-level guard unconditionally, so this proves the + // rejection without constructing a fully valid ledger row. + let rejected = sqlx::query("TRUNCATE identity_lifecycle_selectors") + .execute(&pool) + .await + .expect_err("identity_lifecycle_selectors truncation must be rejected"); + assert!( + rejected.to_string().contains("cannot be truncated"), + "expected immutability rejection, got: {rejected}" + ); + } + + /// NIP-FI full state: migrations 0041 + 0042 together must present a + /// coherent 15-relation catalog with zero dangling foreign keys, all + /// relations write-fence excluded, and an intact exact deletion catalog. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_fi_foundation_is_a_closed_durable_ledger_after_migrations_a_and_b() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let nip_fi_tables = [ + "authorization_admission_results", + "authorization_authentication_denial_attempts", + "authorization_authority_epochs", + "authorization_event_capacity", + "authorization_events", + "authorization_invalidation_domains", + "authorization_invalidation_floors", + "authorization_operation_receipts", + "authorization_operation_version_delta_manifests", + "authorization_operation_version_deltas", + "identity_bindings", + "identity_enrollment_policies", + "identity_lifecycle_history", + "identity_lifecycle_selectors", + "protected_object_authority", + ]; + + // All fifteen relations exist. + let present: Vec = sqlx::query_scalar( + "SELECT table_name FROM information_schema.tables \ + WHERE table_schema = 'public' AND table_name = ANY($1) ORDER BY table_name", + ) + .bind(&nip_fi_tables[..]) + .fetch_all(&pool) + .await + .expect("read NIP-FI table catalog"); + let mut expected: Vec = nip_fi_tables.iter().map(|t| t.to_string()).collect(); + expected.sort(); + assert_eq!( + present, expected, + "all NIP-FI relations must exist after 0042" + ); + + // Zero dangling foreign keys: every FK target is a live relation. + let invalid_fks: i64 = sqlx::query_scalar( + "SELECT count(*)::BIGINT FROM pg_constraint \ + WHERE contype = 'f' AND NOT convalidated", + ) + .fetch_one(&pool) + .await + .expect("read FK validity"); + assert_eq!( + invalid_fks, 0, + "no NIP-FI foreign key may be left unvalidated" + ); + + // None of the fifteen appear as tenant-scoped drift; all are excluded. + let scoped: Vec = sqlx::query_scalar( + "SELECT c.relname FROM pg_class c \ + JOIN pg_namespace n ON n.oid = c.relnamespace \ + JOIN pg_attribute a ON a.attrelid = c.oid \ + WHERE n.nspname = 'public' AND c.relkind IN ('r','p') \ + AND NOT c.relispartition AND a.attname = 'community_id' \ + AND NOT a.attisdropped \ + AND NOT community_write_fence_excluded_table(c.relname) \ + AND c.relname = ANY($1) ORDER BY c.relname", + ) + .bind(&nip_fi_tables[..]) + .fetch_all(&pool) + .await + .expect("read scoped NIP-FI relations"); + assert!( + scoped.is_empty(), + "all NIP-FI ledger relations must be write-fence excluded: {scoped:?}" + ); + + // The exact deletion catalog validates with the full ledger present. + crate::deletion::DeletionStore::new(pool.clone()) + .validate_catalog() + .await + .expect("deletion catalog validates after migrations 0041 + 0042"); + + // A migration-B relation is immutable too. TRUNCATE fires the + // statement-level guard unconditionally. + let rejected = sqlx::query("TRUNCATE authorization_admission_results") + .execute(&pool) + .await + .expect_err("authorization_admission_results truncation must be rejected"); + assert!( + rejected.to_string().contains("cannot be truncated"), + "expected immutability rejection, got: {rejected}" + ); + } } diff --git a/migrations/0041_nip_fi_identity_foundation.sql b/migrations/0041_nip_fi_identity_foundation.sql new file mode 100644 index 00000000000..e4980babf57 --- /dev/null +++ b/migrations/0041_nip_fi_identity_foundation.sql @@ -0,0 +1,879 @@ +-- Provider-free NIP-FI core identity and base-lifecycle foundation. +-- +-- This is direct-final fresh-schema DDL. It intentionally does not replay a +-- historical uid/backfill/ALTER sequence. +-- +-- Scope is NIP-FI *core* only. Base lifecycle is exactly retire, revoke, and +-- rotate (NIP-FI.md "Base lifecycle"). The extended NIP-FI-LIFECYCLE surface +-- (disabled identities, pending-replacement lineage, and their provision, +-- disable, recover, enable, and admission-loss transitions) is deferred to a +-- later migration owned by the FI-LIFECYCLE PR, per NIP-FI-MODEL.md: "NIP-FI- +-- LIFECYCLE adds disabled identities and pending replacement lineage." So the +-- closed vocabularies below are the core subset: +-- transition/operation kinds: 1 enroll, 3 retire, 5 revoke, 6 rotate; +-- lifecycle selector kinds: 1 retired pair (P), 3 revoked key (Y). +-- A later migration widens these vocabularies additively; nothing here presumes +-- a single global issuer — identity is issuer-qualified (iss, sub). + +-- The sole idempotency/result root shared by identity base lifecycle, +-- protected operations, and invalidation. Pre-authentication denials never +-- write this table. ExactReplay and IntentConflict are read-time observations, +-- not persisted outcomes. +CREATE TABLE authorization_operation_receipts ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + -- Core operation kinds: 1 enroll, 3 retire, 5 revoke, 6 rotate, + -- 11 protected mutation, 12 invalidation. Extended lifecycle kinds + -- (2 provision, 4 disable, 7 recover, 8 enable, 9 admission loss) and + -- 10 operator are introduced by their owning later migrations. + operation_kind SMALLINT NOT NULL CHECK ( + operation_kind IN (1, 3, 5, 6, 11, 12) + ), + actor_fingerprint BYTEA NOT NULL CHECK (octet_length(actor_fingerprint) = 32), + -- 1 applied, 2 denied, 3 no-op. + outcome_code SMALLINT NOT NULL CHECK (outcome_code IN (1, 2, 3)), + result_digest BYTEA NOT NULL CHECK (octet_length(result_digest) = 32), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, operation_id), + UNIQUE (community_id, operation_id, request_fingerprint), + UNIQUE ( + community_id, + operation_id, + request_fingerprint, + operation_kind, + outcome_code + ), + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid) +); + +-- Immutable monotonic local policy revisions. Enrollment modes are the closed +-- provider-free V1 set: 1 attested-key, 2 provisioned, 3 risk-labelled TOFU. +CREATE TABLE identity_enrollment_policies ( + community_id UUID NOT NULL REFERENCES communities(id), + policy_revision BIGINT NOT NULL CHECK (policy_revision > 0), + enrollment_mode SMALLINT NOT NULL CHECK (enrollment_mode IN (1, 2, 3)), + policy_digest BYTEA NOT NULL CHECK (octet_length(policy_digest) = 32), + effective_at TIMESTAMPTZ NOT NULL, + -- Optional local binding-policy expiry. Federated token `exp` MUST NOT be + -- copied here: token lifetime bounds an authorization lease, not this + -- durable binding generation. + expires_at TIMESTAMPTZ, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, policy_revision), + UNIQUE (community_id, policy_revision, enrollment_mode), + CHECK (expires_at IS NULL OR effective_at < expires_at) +); + +-- One row is one immutable binding generation. binding_version is allocated +-- from one non-cycling PostgreSQL identity sequence and is never changed or +-- reused. Explicit lifecycle may only retire the generation; X/Y denial +-- semantics live in immutable selector facts below, not alternate row states. +CREATE TABLE identity_bindings ( + community_id UUID NOT NULL REFERENCES communities(id), + binding_id UUID NOT NULL, + binding_version BIGINT GENERATED ALWAYS AS IDENTITY ( + START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1 NO CYCLE + ), + issuer TEXT COLLATE "C" NOT NULL CHECK (octet_length(issuer) BETWEEN 1 AND 2048), + subject TEXT COLLATE "C" NOT NULL CHECK (octet_length(subject) BETWEEN 1 AND 2048), + principal_fingerprint BYTEA NOT NULL CHECK (octet_length(principal_fingerprint) = 32), + event_author_pubkey BYTEA NOT NULL CHECK (octet_length(event_author_pubkey) = 32), + -- 1 active, 2 retired. + binding_state SMALLINT NOT NULL CHECK (binding_state IN (1, 2)), + lifecycle_revision BIGINT NOT NULL CHECK (lifecycle_revision IN (1, 2)), + -- 1 attested-key, 2 provisioned, 3 risk-labelled TOFU. + binding_provenance SMALLINT NOT NULL CHECK (binding_provenance IN (1, 2, 3)), + policy_revision BIGINT NOT NULL CHECK (policy_revision > 0), + -- Canonical evidence for the selected provenance. This is an assertion + -- digest for attested/TOFU admission and a provisioning receipt digest for + -- separately provisioned admission; it never stores credential bytes. + enrollment_evidence_digest BYTEA NOT NULL CHECK ( + octet_length(enrollment_evidence_digest) = 32 + ), + expires_at TIMESTAMPTZ, + birth_history_id UUID NOT NULL, + creation_operation_id UUID NOT NULL, + creation_request_fingerprint BYTEA NOT NULL CHECK ( + octet_length(creation_request_fingerprint) = 32 + ), + retirement_history_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, binding_id), + UNIQUE (community_id, binding_version), + UNIQUE (community_id, binding_id, binding_version), + FOREIGN KEY (community_id, policy_revision, binding_provenance) + REFERENCES identity_enrollment_policies + (community_id, policy_revision, enrollment_mode), + CHECK (binding_version > 0), + CHECK (binding_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (birth_history_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (creation_operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (expires_at IS NULL OR created_at < expires_at), + CHECK ( + (binding_state = 1 AND lifecycle_revision = 1 AND retirement_history_id IS NULL) + OR (binding_state = 2 AND lifecycle_revision = 2 AND retirement_history_id IS NOT NULL) + ) +); + +-- State 1 is Active. Expiry is evaluated with authoritative PostgreSQL time +-- at read/finalization and is exclusive; it cannot appear in an index predicate. +CREATE UNIQUE INDEX identity_bindings_active_principal + ON identity_bindings (community_id, issuer, subject) + WHERE binding_state = 1; +CREATE INDEX identity_bindings_principal_fingerprint_lookup + ON identity_bindings (community_id, principal_fingerprint) + WHERE binding_state = 1; +CREATE UNIQUE INDEX identity_bindings_active_event_author + ON identity_bindings (community_id, event_author_pubkey) + WHERE binding_state = 1; +CREATE INDEX identity_bindings_current_lookup + ON identity_bindings (community_id, event_author_pubkey, binding_state, expires_at); + +-- The one canonical immutable lifecycle transition row for a successful or +-- no-op lifecycle operation. A transition can name an old generation, a new +-- successor generation, both (Rotate), or neither (a semantic no-op). It is not +-- a second result/effect engine: the shared receipt remains the sole persisted +-- operation outcome. Core transition kinds only: 1 enroll, 3 retire, 5 revoke, +-- 6 rotate. +CREATE TABLE identity_lifecycle_history ( + community_id UUID NOT NULL REFERENCES communities(id), + history_id UUID NOT NULL, + transition_kind SMALLINT NOT NULL CHECK ( + transition_kind IN (1, 3, 5, 6) + ), + -- Matches the shared receipt: 1 applied, 3 no-op. + outcome_code SMALLINT NOT NULL CHECK (outcome_code IN (1, 3)), + old_binding_id UUID, + old_binding_version BIGINT CHECK (old_binding_version IS NULL OR old_binding_version > 0), + old_prior_lifecycle_revision BIGINT CHECK ( + old_prior_lifecycle_revision IS NULL OR old_prior_lifecycle_revision IN (1, 2) + ), + old_prior_state SMALLINT CHECK (old_prior_state IS NULL OR old_prior_state IN (1, 2)), + old_resulting_lifecycle_revision BIGINT CHECK ( + old_resulting_lifecycle_revision IS NULL OR old_resulting_lifecycle_revision IN (1, 2) + ), + old_resulting_state SMALLINT CHECK ( + old_resulting_state IS NULL OR old_resulting_state IN (1, 2) + ), + successor_binding_id UUID, + successor_binding_version BIGINT CHECK ( + successor_binding_version IS NULL OR successor_binding_version > 0 + ), + successor_lifecycle_revision BIGINT CHECK ( + successor_lifecycle_revision IS NULL OR successor_lifecycle_revision = 1 + ), + successor_state SMALLINT CHECK (successor_state IS NULL OR successor_state = 1), + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + transition_digest BYTEA NOT NULL CHECK (octet_length(transition_digest) = 32), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, history_id), + UNIQUE (community_id, operation_id), + UNIQUE (community_id, history_id, operation_id, request_fingerprint), + UNIQUE ( + community_id, + history_id, + successor_binding_id, + successor_binding_version, + operation_id, + request_fingerprint + ), + UNIQUE ( + community_id, + history_id, + old_binding_id, + old_binding_version, + old_resulting_lifecycle_revision, + old_resulting_state + ), + FOREIGN KEY ( + community_id, + operation_id, + request_fingerprint, + transition_kind, + outcome_code + ) REFERENCES authorization_operation_receipts ( + community_id, + operation_id, + request_fingerprint, + operation_kind, + outcome_code + ) DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (community_id, old_binding_id, old_binding_version) + REFERENCES identity_bindings (community_id, binding_id, binding_version) + DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (community_id, successor_binding_id, successor_binding_version) + REFERENCES identity_bindings (community_id, binding_id, binding_version) + DEFERRABLE INITIALLY DEFERRED, + CHECK (history_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK ( + (old_binding_id IS NULL + AND old_binding_version IS NULL + AND old_prior_lifecycle_revision IS NULL + AND old_prior_state IS NULL + AND old_resulting_lifecycle_revision IS NULL + AND old_resulting_state IS NULL) + OR (old_binding_id IS NOT NULL + AND old_binding_version IS NOT NULL + AND old_prior_lifecycle_revision IS NOT NULL + AND old_prior_state IS NOT NULL + AND old_resulting_lifecycle_revision IS NOT NULL + AND old_resulting_state IS NOT NULL) + ), + CHECK ( + (successor_binding_id IS NULL + AND successor_binding_version IS NULL + AND successor_lifecycle_revision IS NULL + AND successor_state IS NULL) + OR (successor_binding_id IS NOT NULL + AND successor_binding_version IS NOT NULL + AND successor_lifecycle_revision = 1 + AND successor_state = 1) + ), + CHECK ( + old_binding_id IS NULL + OR successor_binding_id IS NULL + OR old_binding_id <> successor_binding_id + ), + CHECK ( + old_binding_version IS NULL + OR successor_binding_version IS NULL + OR old_binding_version <> successor_binding_version + ), + -- Core lifecycle only ever moves Active/r1 to Retired/r2 for a named old + -- generation. Extended re-enablement (recover/enable from Retired/r2) is a + -- later migration's concern. + CHECK ( + old_binding_id IS NULL + OR (old_prior_lifecycle_revision = 1 + AND old_prior_state = 1 + AND old_resulting_lifecycle_revision = 2 + AND old_resulting_state = 2) + ), + CHECK ( + (outcome_code = 3 + AND old_binding_id IS NULL + AND successor_binding_id IS NULL) + OR (outcome_code = 1 AND ( + (transition_kind = 1 + AND old_binding_id IS NULL + AND successor_binding_id IS NOT NULL) + OR (transition_kind = 3 + AND old_binding_id IS NOT NULL + AND successor_binding_id IS NULL) + OR (transition_kind = 5 + AND successor_binding_id IS NULL) + OR (transition_kind = 6 + AND old_binding_id IS NOT NULL + AND successor_binding_id IS NOT NULL) + )) + ) +); + +CREATE INDEX identity_lifecycle_history_old_binding + ON identity_lifecycle_history (community_id, old_binding_id, old_binding_version, recorded_at); +CREATE INDEX identity_lifecycle_history_successor_binding + ON identity_lifecycle_history ( + community_id, + successor_binding_id, + successor_binding_version, + recorded_at + ); + +-- Circular birth/transition ordering is deliberate and fully deferred. Every +-- generation must commit with its exact birth transition, and a retired row +-- must commit with the exact transition that changed Active/r1 to Retired/r2. +ALTER TABLE identity_bindings + ADD CONSTRAINT identity_bindings_exact_birth_history_fk + FOREIGN KEY ( + community_id, + birth_history_id, + binding_id, + binding_version, + creation_operation_id, + creation_request_fingerprint + ) REFERENCES identity_lifecycle_history ( + community_id, + history_id, + successor_binding_id, + successor_binding_version, + operation_id, + request_fingerprint + ) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE identity_bindings + ADD CONSTRAINT identity_bindings_exact_retirement_history_fk + FOREIGN KEY ( + community_id, + retirement_history_id, + binding_id, + binding_version, + lifecycle_revision, + binding_state + ) REFERENCES identity_lifecycle_history ( + community_id, + history_id, + old_binding_id, + old_binding_version, + old_resulting_lifecycle_revision, + old_resulting_state + ) DEFERRABLE INITIALLY DEFERRED; + +-- One immutable closed-scope fact table. Core selector kinds only: +-- 1 retired pair (P), 3 revoked key (Y). Both are permanent. The extended +-- disabled-identity (X) and pending-replacement (Q) selectors, and their +-- one-shot consumption, are introduced by the FI-LIFECYCLE migration. +CREATE TABLE identity_lifecycle_selectors ( + community_id UUID NOT NULL REFERENCES communities(id), + selector_id UUID NOT NULL, + selector_kind SMALLINT NOT NULL CHECK (selector_kind IN (1, 3)), + selector_fingerprint BYTEA NOT NULL CHECK (octet_length(selector_fingerprint) = 32), + fact_generation BIGINT NOT NULL CHECK (fact_generation > 0), + principal_fingerprint BYTEA CHECK ( + principal_fingerprint IS NULL OR octet_length(principal_fingerprint) = 32 + ), + event_author_pubkey BYTEA CHECK ( + event_author_pubkey IS NULL OR octet_length(event_author_pubkey) = 32 + ), + binding_id UUID, + binding_version BIGINT CHECK (binding_version IS NULL OR binding_version > 0), + asserted_history_id UUID NOT NULL, + selected_by_operation_id UUID NOT NULL, + selected_by_request_fingerprint BYTEA NOT NULL CHECK ( + octet_length(selected_by_request_fingerprint) = 32 + ), + selected_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, selector_id), + UNIQUE (community_id, selector_id, selector_kind), + UNIQUE (community_id, selector_kind, selector_fingerprint, fact_generation), + FOREIGN KEY ( + community_id, + asserted_history_id, + selected_by_operation_id, + selected_by_request_fingerprint + ) REFERENCES identity_lifecycle_history ( + community_id, + history_id, + operation_id, + request_fingerprint + ) DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY ( + community_id, + selected_by_operation_id, + selected_by_request_fingerprint + ) REFERENCES authorization_operation_receipts ( + community_id, + operation_id, + request_fingerprint + ) DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (community_id, binding_id, binding_version) + REFERENCES identity_bindings (community_id, binding_id, binding_version) + DEFERRABLE INITIALLY DEFERRED, + CHECK (selector_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK ( + (selector_kind = 1 + AND fact_generation = 1 + AND principal_fingerprint IS NOT NULL + AND event_author_pubkey IS NOT NULL + AND binding_id IS NOT NULL + AND binding_version IS NOT NULL) + OR (selector_kind = 3 + AND fact_generation = 1 + AND principal_fingerprint IS NULL + AND event_author_pubkey IS NOT NULL + AND binding_id IS NULL + AND binding_version IS NULL) + ) +); + +CREATE UNIQUE INDEX identity_lifecycle_selectors_permanent_pair + ON identity_lifecycle_selectors (community_id, binding_id, binding_version) + WHERE selector_kind = 1; +CREATE UNIQUE INDEX identity_lifecycle_selectors_permanent_principal_key + ON identity_lifecycle_selectors ( + community_id, + principal_fingerprint, + event_author_pubkey + ) WHERE selector_kind = 1; +CREATE UNIQUE INDEX identity_lifecycle_selectors_permanent_key + ON identity_lifecycle_selectors (community_id, event_author_pubkey) + WHERE selector_kind = 3; +CREATE INDEX identity_lifecycle_selectors_principal_lookup + ON identity_lifecycle_selectors + (community_id, selector_kind, principal_fingerprint, fact_generation); +CREATE INDEX identity_lifecycle_selectors_key_lookup + ON identity_lifecycle_selectors + (community_id, selector_kind, event_author_pubkey, fact_generation); +CREATE INDEX identity_lifecycle_selectors_binding_lookup + ON identity_lifecycle_selectors + (community_id, selector_kind, binding_id, binding_version, fact_generation); +CREATE INDEX identity_lifecycle_selectors_asserted_history + ON identity_lifecycle_selectors + (community_id, asserted_history_id, selector_kind); + +CREATE FUNCTION nip_fi_reject_row_mutation_v1() RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION '% is immutable', TG_TABLE_NAME + USING ERRCODE = 'check_violation'; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION nip_fi_reject_truncate_v1() RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION '% cannot be truncated', TG_TABLE_NAME + USING ERRCODE = 'check_violation'; +END; +$$ LANGUAGE plpgsql; + +-- Every binding/selector path derives the same domain-scoped coordinates and +-- takes their signed BIGINT advisory keys in numeric order. Typed transaction +-- APIs take these locks before row mutation; the triggers are the fail-closed +-- backstop for direct SQL. +CREATE FUNCTION identity_lifecycle_lock_coordinates_v1( + locked_community_id UUID, + locked_principal_fingerprint BYTEA, + locked_event_author_pubkey BYTEA +) RETURNS VOID AS $$ +DECLARE + principal_lock_key BIGINT; + event_author_lock_key BIGINT; +BEGIN + IF locked_principal_fingerprint IS NOT NULL THEN + principal_lock_key := hashtextextended( + 'buzz:identity-lifecycle-coordinate:v1:principal:' + || locked_community_id::text || ':' + || encode(locked_principal_fingerprint, 'hex'), + 0 + ); + END IF; + IF locked_event_author_pubkey IS NOT NULL THEN + event_author_lock_key := hashtextextended( + 'buzz:identity-lifecycle-coordinate:v1:key:' + || locked_community_id::text || ':' + || encode(locked_event_author_pubkey, 'hex'), + 0 + ); + END IF; + + IF principal_lock_key IS NOT NULL AND event_author_lock_key IS NOT NULL THEN + PERFORM pg_advisory_xact_lock(LEAST(principal_lock_key, event_author_lock_key)); + IF principal_lock_key <> event_author_lock_key THEN + PERFORM pg_advisory_xact_lock(GREATEST(principal_lock_key, event_author_lock_key)); + END IF; + ELSIF principal_lock_key IS NOT NULL THEN + PERFORM pg_advisory_xact_lock(principal_lock_key); + ELSIF event_author_lock_key IS NOT NULL THEN + PERFORM pg_advisory_xact_lock(event_author_lock_key); + END IF; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_bindings_insert_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + PERFORM identity_lifecycle_lock_coordinates_v1( + NEW.community_id, + NEW.principal_fingerprint, + NEW.event_author_pubkey + ); + IF NEW.binding_state <> 1 + OR NEW.lifecycle_revision <> 1 + OR NEW.retirement_history_id IS NOT NULL + THEN + RAISE EXCEPTION 'identity binding birth must be Active at lifecycle revision 1' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_bindings_birth_state'; + END IF; + NEW.created_at := transaction_timestamp(); + NEW.updated_at := transaction_timestamp(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_bindings_transition_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + PERFORM identity_lifecycle_lock_coordinates_v1( + OLD.community_id, + OLD.principal_fingerprint, + OLD.event_author_pubkey + ); + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.binding_id IS DISTINCT FROM OLD.binding_id + OR NEW.binding_version IS DISTINCT FROM OLD.binding_version + OR NEW.issuer IS DISTINCT FROM OLD.issuer + OR NEW.subject IS DISTINCT FROM OLD.subject + OR NEW.principal_fingerprint IS DISTINCT FROM OLD.principal_fingerprint + OR NEW.event_author_pubkey IS DISTINCT FROM OLD.event_author_pubkey + OR NEW.binding_provenance IS DISTINCT FROM OLD.binding_provenance + OR NEW.policy_revision IS DISTINCT FROM OLD.policy_revision + OR NEW.enrollment_evidence_digest IS DISTINCT FROM OLD.enrollment_evidence_digest + OR NEW.expires_at IS DISTINCT FROM OLD.expires_at + OR NEW.birth_history_id IS DISTINCT FROM OLD.birth_history_id + OR NEW.creation_operation_id IS DISTINCT FROM OLD.creation_operation_id + OR NEW.creation_request_fingerprint IS DISTINCT FROM OLD.creation_request_fingerprint + OR NEW.created_at IS DISTINCT FROM OLD.created_at + THEN + RAISE EXCEPTION 'identity binding generation coordinates are immutable' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_bindings_immutable_generation'; + END IF; + IF OLD.binding_state <> 1 + OR OLD.lifecycle_revision <> 1 + OR OLD.retirement_history_id IS NOT NULL + OR NEW.binding_state <> 2 + OR NEW.lifecycle_revision <> 2 + OR NEW.retirement_history_id IS NULL + OR NEW.retirement_history_id = OLD.birth_history_id + THEN + RAISE EXCEPTION 'identity binding permits only Active/r1 to Retired/r2' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_bindings_active_to_retired'; + END IF; + NEW.updated_at := transaction_timestamp(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_lifecycle_history_insert_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + NEW.recorded_at := transaction_timestamp(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_binding_history_semantics_guard_v1() RETURNS TRIGGER AS $$ +DECLARE + retirement identity_lifecycle_history%ROWTYPE; +BEGIN + IF NEW.binding_state = 2 THEN + SELECT * INTO STRICT retirement + FROM identity_lifecycle_history + WHERE community_id = NEW.community_id + AND history_id = NEW.retirement_history_id + AND old_binding_id = NEW.binding_id + AND old_binding_version = NEW.binding_version; + IF retirement.outcome_code <> 1 + OR retirement.old_prior_lifecycle_revision <> 1 + OR retirement.old_prior_state <> 1 + OR retirement.old_resulting_lifecycle_revision <> 2 + OR retirement.old_resulting_state <> 2 + THEN + RAISE EXCEPTION 'retired binding must reference its exact Active-to-Retired transition' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_bindings_retirement_history_semantics'; + END IF; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_binding_birth_eligibility_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM identity_lifecycle_selectors selector + WHERE selector.community_id = NEW.community_id + AND ( + (selector.selector_kind = 1 + AND selector.principal_fingerprint = NEW.principal_fingerprint + AND selector.event_author_pubkey = NEW.event_author_pubkey) + OR (selector.selector_kind = 3 + AND selector.event_author_pubkey = NEW.event_author_pubkey) + ) + ) THEN + RAISE EXCEPTION 'binding birth conflicts with an effective lifecycle selector' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_bindings_birth_eligibility'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION authorization_operation_receipt_history_guard_v1() RETURNS TRIGGER AS $$ +DECLARE + history_count BIGINT; + expected_count BIGINT; +BEGIN + SELECT count(*) INTO history_count + FROM identity_lifecycle_history history + WHERE history.community_id = NEW.community_id + AND history.operation_id = NEW.operation_id; + + -- Core lifecycle receipts (enroll, retire, revoke, rotate) each require + -- exactly one lifecycle-history row. Non-lifecycle receipts (protected + -- mutation, invalidation) require none. + expected_count := CASE + WHEN NEW.operation_kind IN (1, 3, 5, 6) AND NEW.outcome_code IN (1, 3) THEN 1 + ELSE 0 + END; + IF history_count <> expected_count THEN + RAISE EXCEPTION 'operation receipt requires % lifecycle history row, found %', + expected_count, history_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_operation_receipt_history_cardinality'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_lifecycle_selector_insert_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + NEW.selected_at := transaction_timestamp(); + PERFORM identity_lifecycle_lock_coordinates_v1( + NEW.community_id, + CASE WHEN NEW.selector_kind = 1 THEN NEW.principal_fingerprint END, + NEW.event_author_pubkey + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_lifecycle_selector_history_guard_v1() RETURNS TRIGGER AS $$ +DECLARE + history identity_lifecycle_history%ROWTYPE; + old_binding identity_bindings%ROWTYPE; +BEGIN + SELECT * INTO STRICT history + FROM identity_lifecycle_history + WHERE community_id = NEW.community_id + AND history_id = NEW.asserted_history_id + AND operation_id = NEW.selected_by_operation_id + AND request_fingerprint = NEW.selected_by_request_fingerprint; + + IF history.old_binding_id IS NOT NULL THEN + SELECT * INTO STRICT old_binding + FROM identity_bindings + WHERE community_id = history.community_id + AND binding_id = history.old_binding_id + AND binding_version = history.old_binding_version; + END IF; + + -- A retired-pair (P) selector is asserted by retire, revoke, or rotate of a + -- named old generation; a revoked-key (Y) selector by revoke. + IF history.outcome_code <> 1 + OR (NEW.selector_kind = 1 AND ( + history.transition_kind NOT IN (3, 5, 6) + OR history.old_binding_id IS DISTINCT FROM NEW.binding_id + OR history.old_binding_version IS DISTINCT FROM NEW.binding_version + OR old_binding.principal_fingerprint IS DISTINCT FROM NEW.principal_fingerprint + OR old_binding.event_author_pubkey IS DISTINCT FROM NEW.event_author_pubkey + )) + OR (NEW.selector_kind = 3 AND ( + history.transition_kind <> 5 + OR (history.old_binding_id IS NOT NULL + AND old_binding.event_author_pubkey + IS DISTINCT FROM NEW.event_author_pubkey) + )) + THEN + RAISE EXCEPTION 'selector does not match its lifecycle transition' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_lifecycle_selector_history_semantics'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_lifecycle_transition_integrity_guard_v1() RETURNS TRIGGER AS $$ +DECLARE + transition identity_lifecycle_history%ROWTYPE; + old_binding_state SMALLINT; + asserted_p BIGINT; + asserted_y BIGINT; +BEGIN + IF TG_TABLE_NAME = 'identity_lifecycle_history' THEN + transition := NEW; + ELSIF TG_TABLE_NAME = 'identity_lifecycle_selectors' THEN + SELECT * INTO STRICT transition + FROM identity_lifecycle_history + WHERE community_id = NEW.community_id + AND history_id = NEW.asserted_history_id; + ELSE + SELECT * INTO STRICT transition + FROM identity_lifecycle_history + WHERE community_id = NEW.community_id + AND history_id = CASE + WHEN NEW.binding_state = 2 THEN NEW.retirement_history_id + ELSE NEW.birth_history_id + END; + END IF; + + SELECT + count(*) FILTER (WHERE selector_kind = 1), + count(*) FILTER (WHERE selector_kind = 3) + INTO asserted_p, asserted_y + FROM identity_lifecycle_selectors + WHERE community_id = transition.community_id + AND asserted_history_id = transition.history_id; + + IF transition.old_binding_id IS NOT NULL THEN + SELECT binding_state INTO STRICT old_binding_state + FROM identity_bindings + WHERE community_id = transition.community_id + AND binding_id = transition.old_binding_id + AND binding_version = transition.old_binding_version; + IF old_binding_state <> 2 THEN + RAISE EXCEPTION 'lifecycle transition old binding must be retired at commit' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_lifecycle_transition_integrity'; + END IF; + END IF; + + IF EXISTS ( + SELECT 1 + FROM identity_lifecycle_selectors selector + JOIN identity_bindings active + ON active.community_id = selector.community_id + AND active.binding_state = 1 + AND ( + (selector.selector_kind = 1 + AND active.principal_fingerprint = selector.principal_fingerprint + AND active.event_author_pubkey = selector.event_author_pubkey) + OR (selector.selector_kind = 3 + AND active.event_author_pubkey = selector.event_author_pubkey) + ) + WHERE selector.community_id = transition.community_id + ) THEN + RAISE EXCEPTION 'effective lifecycle selector conflicts with an active binding' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_lifecycle_transition_integrity'; + END IF; + + IF transition.outcome_code = 3 THEN + IF asserted_p + asserted_y <> 0 THEN + RAISE EXCEPTION 'no-op lifecycle transition cannot create selector facts' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_lifecycle_transition_integrity'; + END IF; + RETURN NULL; + END IF; + + -- Core selector companions per transition: + -- enroll (1): none + -- retire (3): exactly one P + -- revoke (5): one Y always; one P when a named old generation is removed + -- rotate (6): exactly one P (old generation retired) + IF (transition.transition_kind = 1 + AND (asserted_p, asserted_y) <> (0, 0)) + OR (transition.transition_kind = 3 + AND (asserted_p, asserted_y) <> (1, 0)) + OR (transition.transition_kind = 5 AND ( + (transition.old_binding_id IS NOT NULL + AND (asserted_p, asserted_y) <> (1, 1)) + OR (transition.old_binding_id IS NULL + AND (asserted_p, asserted_y) <> (0, 1)) + )) + OR (transition.transition_kind = 6 + AND (asserted_p, asserted_y) <> (1, 0)) + THEN + RAISE EXCEPTION 'lifecycle transition has incomplete or forbidden selector companions' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_lifecycle_transition_integrity'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER identity_bindings_insert_guard + BEFORE INSERT ON identity_bindings + FOR EACH ROW EXECUTE FUNCTION identity_bindings_insert_guard_v1(); +CREATE TRIGGER identity_bindings_transition_guard + BEFORE UPDATE ON identity_bindings + FOR EACH ROW EXECUTE FUNCTION identity_bindings_transition_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_bindings_history_semantics + AFTER INSERT OR UPDATE ON identity_bindings + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_binding_history_semantics_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_bindings_birth_eligibility + AFTER INSERT ON identity_bindings + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_binding_birth_eligibility_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_bindings_transition_integrity + AFTER INSERT OR UPDATE ON identity_bindings + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_transition_integrity_guard_v1(); +CREATE TRIGGER identity_bindings_no_delete + BEFORE DELETE ON identity_bindings + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER identity_bindings_no_truncate + BEFORE TRUNCATE ON identity_bindings + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER identity_lifecycle_history_insert_guard + BEFORE INSERT ON identity_lifecycle_history + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_history_insert_guard_v1(); +CREATE CONSTRAINT TRIGGER authorization_operation_receipt_history_cardinality + AFTER INSERT ON authorization_operation_receipts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_history_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_lifecycle_transition_integrity + AFTER INSERT ON identity_lifecycle_history + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_transition_integrity_guard_v1(); + +CREATE TRIGGER identity_lifecycle_selector_insert_guard + BEFORE INSERT ON identity_lifecycle_selectors + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_selector_insert_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_lifecycle_selector_history_semantics + AFTER INSERT ON identity_lifecycle_selectors + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_selector_history_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_lifecycle_selector_transition_integrity + AFTER INSERT ON identity_lifecycle_selectors + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_transition_integrity_guard_v1(); + +CREATE TRIGGER authorization_operation_receipts_immutable + BEFORE UPDATE OR DELETE ON authorization_operation_receipts + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_operation_receipts_no_truncate + BEFORE TRUNCATE ON authorization_operation_receipts + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER identity_enrollment_policies_immutable + BEFORE UPDATE OR DELETE ON identity_enrollment_policies + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER identity_enrollment_policies_no_truncate + BEFORE TRUNCATE ON identity_enrollment_policies + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER identity_lifecycle_history_immutable + BEFORE UPDATE OR DELETE ON identity_lifecycle_history + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER identity_lifecycle_history_no_truncate + BEFORE TRUNCATE ON identity_lifecycle_history + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER identity_lifecycle_selectors_immutable + BEFORE UPDATE OR DELETE ON identity_lifecycle_selectors + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER identity_lifecycle_selectors_no_truncate + BEFORE TRUNCATE ON identity_lifecycle_selectors + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +-- These identity relations are a durable, tamper-evident authorization ledger: +-- FI-INV-02 (durable binding) and FI-INV-03 (tombstone monotonicity) require +-- their denial facts to outlive any single tenant lifecycle, and the immutable +-- no_delete/no_truncate triggers above enforce exactly that. They therefore +-- carry community_id as provenance, not as deletable ownership — the same +-- posture migration 0030 took for product_feedback and rate_limit_violations. +-- Widen the single SQL source of truth so the universal write fence and the +-- deletion catalog treat them as ledger: never fence-attached, never purged, +-- never counted as tenant-scoped drift. community rows are permanent tombstones +-- (never hard-deleted), so their NOT NULL community_id references never dangle. +CREATE OR REPLACE FUNCTION community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN +LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$ + SELECT target::TEXT = ANY (ARRAY[ + 'community_deletion_requests', 'community_deletion_approvals', + 'community_deletion_checkpoints', 'community_serving_write_leases', + 'community_deletion_executor_heartbeats', 'product_feedback', + 'rate_limit_violations', + 'authorization_operation_receipts', 'identity_enrollment_policies', + 'identity_bindings', 'identity_lifecycle_history', + 'identity_lifecycle_selectors' + ]::TEXT[]) +$$; diff --git a/migrations/0042_nip_fi_authorization_foundation.sql b/migrations/0042_nip_fi_authorization_foundation.sql new file mode 100644 index 00000000000..b83469dd811 --- /dev/null +++ b/migrations/0042_nip_fi_authorization_foundation.sql @@ -0,0 +1,749 @@ +-- Provider-free NIP-FI authorization, audit, fencing, and restore foundation. +-- +-- There is no provider registry/SPI/profile/evidence table, durable lease or +-- audio admission ledger, 30382 projection, delivery queue, exporter claim, +-- acknowledgement, retry scheduler, or online retention/compaction workflow. +-- +-- This migration applies to migration 0040's resulting state. Its scope is the +-- NIP-FI *final-admission* surface: replay/receipt, audit events, invalidation, +-- capacity, protected-object authority, restore version deltas, and the closed +-- admission result. Closed vocabularies below carry only the core subset; +-- delegation coordinates (owner/relationship columns, invalidation selector 7, +-- version-delta component kind 6) are deferred to the FI-DELEG migration and +-- extended-lifecycle audit kinds (recover, enable, disable, admission-loss; +-- version-delta component kind 7) to the FI-LIFECYCLE migration, matching +-- 0040's carve. A later migration widens these additively; nothing here +-- presumes a single global issuer. + +-- Durable one-way activation marker and current domain invalidation generation. +CREATE TABLE authorization_invalidation_domains ( + community_id UUID NOT NULL PRIMARY KEY REFERENCES communities(id), + current_generation BIGINT NOT NULL CHECK (current_generation >= 0), + activated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp() +); + +-- Closed selectors: 1 principal, 2 Nostr key, 3 binding, 4 session, 5 domain, +-- 6 configuration revision. Selector 7 (delegated relationship) and its +-- relationship-revision floor are deferred to the FI-DELEG migration. +CREATE TABLE authorization_invalidation_floors ( + community_id UUID NOT NULL REFERENCES communities(id), + selector_kind SMALLINT NOT NULL CHECK (selector_kind IN (1, 2, 3, 4, 5, 6)), + selector_fingerprint BYTEA NOT NULL CHECK (octet_length(selector_fingerprint) = 32), + floor_generation BIGINT NOT NULL CHECK (floor_generation > 0), + binding_version_floor BIGINT CHECK (binding_version_floor IS NULL OR binding_version_floor > 0), + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, selector_kind, selector_fingerprint), + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED, + CHECK ( + (selector_kind = 3 AND binding_version_floor IS NOT NULL) + OR (selector_kind <> 3 AND binding_version_floor IS NULL) + ) +); + +-- Protected-object kinds: 1 domain, 2 channel, 3 repository, 4 media, +-- 5 moderation target, 6 audio session. Kind 7 is retired: current binding +-- status is connection-local evidence and never a durable protected object. +CREATE TABLE authorization_authority_epochs ( + community_id UUID NOT NULL REFERENCES communities(id), + object_kind SMALLINT NOT NULL CHECK (object_kind IN (1, 2, 3, 4, 5, 6)), + object_key BYTEA NOT NULL CHECK (octet_length(object_key) = 32), + authority_epoch BIGINT NOT NULL CHECK (authority_epoch > 0), + fence BYTEA NOT NULL CHECK ( + octet_length(fence) = 32 AND fence <> decode(repeat('00', 32), 'hex') + ), + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, object_kind, object_key), + UNIQUE ( + community_id, + object_kind, + object_key, + authority_epoch, + fence, + operation_id, + request_fingerprint + ), + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED +); + +-- Direct-final current authority for a protected object. The authorization +-- lease itself is sealed in memory and dies on restart; this durable row is the +-- exact source re-fenced immediately before a protected mutation or emission. +CREATE TABLE protected_object_authority ( + community_id UUID NOT NULL REFERENCES communities(id), + object_kind SMALLINT NOT NULL CHECK (object_kind IN (1, 2, 3, 4, 5, 6)), + object_key BYTEA NOT NULL CHECK (octet_length(object_key) = 32), + capability SMALLINT NOT NULL CHECK ( + capability IN ( + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, + 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, + 28, 29 + ) + ), + actor_pubkey BYTEA NOT NULL CHECK (octet_length(actor_pubkey) = 32), + binding_id UUID NOT NULL, + binding_version BIGINT NOT NULL CHECK (binding_version > 0), + policy_revision BIGINT NOT NULL CHECK (policy_revision > 0), + invalidation_generation BIGINT NOT NULL CHECK (invalidation_generation >= 0), + authority_epoch BIGINT NOT NULL CHECK (authority_epoch > 0), + fence BYTEA NOT NULL CHECK ( + octet_length(fence) = 32 AND fence <> decode(repeat('00', 32), 'hex') + ), + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + PRIMARY KEY (community_id, object_kind, object_key), + FOREIGN KEY (community_id, binding_id, binding_version) + REFERENCES identity_bindings (community_id, binding_id, binding_version) + DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY ( + community_id, + object_kind, + object_key, + authority_epoch, + fence, + operation_id, + request_fingerprint + ) REFERENCES authorization_authority_epochs ( + community_id, + object_kind, + object_key, + authority_epoch, + fence, + operation_id, + request_fingerprint + ) DEFERRABLE INITIALLY DEFERRED, + CHECK (issued_at < expires_at) +); + +-- Explicit immutable-capacity policy required by Enforce mode. Hard ceilings +-- match buzz-auth; installation limits must be sized explicitly below them. +-- V1 has no online pruning/export/reset workflow. +CREATE TABLE authorization_event_capacity ( + community_id UUID NOT NULL PRIMARY KEY REFERENCES communities(id), + max_events_per_domain BIGINT NOT NULL CONSTRAINT authorization_event_capacity_max_events CHECK ( + max_events_per_domain BETWEEN 1 AND 10000 + ), + max_bytes_per_domain BIGINT NOT NULL CONSTRAINT authorization_event_capacity_max_bytes CHECK ( + max_bytes_per_domain BETWEEN 1 AND 16777216 + ), + max_envelope_bytes INTEGER NOT NULL CONSTRAINT authorization_event_capacity_max_envelope CHECK ( + max_envelope_bytes BETWEEN 1 AND 16384 + ), + retained_event_count BIGINT NOT NULL DEFAULT 0 CHECK (retained_event_count >= 0), + retained_envelope_bytes BIGINT NOT NULL DEFAULT 0 CHECK (retained_envelope_bytes >= 0), + -- 1 healthy, 2 audit unavailable/exhausted. Recovery/reset is not a V1 + -- online workflow; enabled runtime latches failure when insertion aborts. + health_state SMALLINT NOT NULL DEFAULT 1 CHECK (health_state IN (1, 2)), + failure_code SMALLINT CHECK (failure_code IS NULL OR failure_code IN (1, 2, 3)), + failure_observed_at TIMESTAMPTZ, + configured_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + CHECK (max_envelope_bytes <= max_bytes_per_domain), + CHECK (retained_event_count <= max_events_per_domain), + CHECK (retained_envelope_bytes <= max_bytes_per_domain), + CHECK ( + (health_state = 1 AND failure_code IS NULL AND failure_observed_at IS NULL) + OR (health_state = 2 AND failure_code IS NOT NULL AND failure_observed_at IS NOT NULL) + ) +); + +-- Durable versioned pseudonymous authorization envelope. event_kind: +-- 1 enrolled, 2 revoked, 3 rotated, 6 retired, 9 operator denied, +-- 10 protected allowed, 11 protected denied, 14 invalidation advanced. +-- The extended-lifecycle audit kinds (4 recovered, 5 principal enabled, +-- 7 principal disabled, 8 admission lost) are deferred to the FI-LIFECYCLE +-- migration, matching 0040's core lifecycle carve. Kinds 12 and 13 are +-- retired: kind 24244 publication/withdrawal is ephemeral connection state and +-- never a durable authorization event. +CREATE TABLE authorization_events ( + community_id UUID NOT NULL REFERENCES communities(id), + event_id UUID NOT NULL, + schema_version SMALLINT NOT NULL DEFAULT 1 CHECK (schema_version = 1), + event_kind SMALLINT NOT NULL CHECK ( + event_kind IN (1, 2, 3, 6, 9, 10, 11, 14) + ), + outcome_code SMALLINT NOT NULL CHECK (outcome_code IN (1, 2, 3, 4, 5)), + reason_code SMALLINT NOT NULL CHECK ( + reason_code IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) + ), + actor_kind SMALLINT NOT NULL CHECK (actor_kind IN (1, 2, 3, 4)), + actor_fingerprint BYTEA CHECK ( + actor_fingerprint IS NULL OR octet_length(actor_fingerprint) = 32 + ), + subject_fingerprint BYTEA CHECK ( + subject_fingerprint IS NULL OR octet_length(subject_fingerprint) = 32 + ), + -- Always retains attempted operation identity. Only unresolved pre-auth + -- event kind 9 omits the canonical receipt fingerprint; authenticated + -- OperatorDenied events remain linked to their exact canonical receipt. + operation_id UUID NOT NULL, + request_fingerprint BYTEA CHECK ( + request_fingerprint IS NULL OR octet_length(request_fingerprint) = 32 + ), + correlation_id UUID NOT NULL, + attempt_id UUID NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL, + accepted_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + canonical_envelope BYTEA NOT NULL CONSTRAINT authorization_events_envelope_size CHECK ( + octet_length(canonical_envelope) BETWEEN 1 AND 16384 + ), + envelope_digest BYTEA NOT NULL CHECK (octet_length(envelope_digest) = 32), + PRIMARY KEY (community_id, event_id), + UNIQUE (community_id, event_id, operation_id), + UNIQUE (community_id, event_id, event_kind, operation_id), + UNIQUE (community_id, operation_id, event_kind, attempt_id), + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED, + CHECK (event_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (correlation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (attempt_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK ( + (actor_kind = 4 AND event_kind = 9 AND request_fingerprint IS NULL) + OR (actor_kind IN (1, 2, 3) AND request_fingerprint IS NOT NULL) + ), + CHECK ( + (actor_kind = 4 AND actor_fingerprint IS NULL AND subject_fingerprint IS NULL) + OR (actor_kind IN (1, 2, 3) AND actor_fingerprint IS NOT NULL) + ) +); + +-- Credential-free pre-authentication denial attempts. The five-column key is +-- exact replay identity; no row or FK occupies canonical operation/result, +-- effect, authority, approval, or consumption state. +CREATE TABLE authorization_authentication_denial_attempts ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + correlation_id UUID NOT NULL, + semantic_fingerprint BYTEA NOT NULL CHECK (octet_length(semantic_fingerprint) = 32), + denial_reason SMALLINT NOT NULL CHECK (denial_reason IN (1, 2, 3)), + expected_revision BIGINT NOT NULL CHECK (expected_revision > 0), + action SMALLINT NOT NULL CHECK (action IN (1, 2, 3, 4, 5, 6, 7, 8)), + reason_code SMALLINT NOT NULL CHECK ( + reason_code IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) + ), + audit_event_id UUID NOT NULL, + audit_event_kind SMALLINT NOT NULL DEFAULT 9 CHECK (audit_event_kind = 9), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY ( + community_id, + operation_id, + correlation_id, + semantic_fingerprint, + denial_reason + ), + UNIQUE (community_id, audit_event_id), + FOREIGN KEY (community_id, audit_event_id, audit_event_kind, operation_id) + REFERENCES authorization_events (community_id, event_id, event_kind, operation_id) + DEFERRABLE INITIALLY DEFERRED, + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (correlation_id <> '00000000-0000-0000-0000-000000000000'::uuid) +); + +-- Exact per-operation authority-version attribution for restore. Empty +-- manifests are valid; every stored component must advance strictly. +CREATE TABLE authorization_operation_version_delta_manifests ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + component_count INTEGER NOT NULL CHECK (component_count BETWEEN 0 AND 1024), + before_digest BYTEA NOT NULL CHECK (octet_length(before_digest) = 32), + after_digest BYTEA NOT NULL CHECK (octet_length(after_digest) = 32), + manifest_digest BYTEA NOT NULL CHECK (octet_length(manifest_digest) = 32), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, operation_id), + UNIQUE (community_id, operation_id, request_fingerprint), + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED +); + +-- component_kind: 1 binding version, 2 policy revision, +-- 3 invalidation generation, 4 authority epoch. Kind 6 (delegated-relationship +-- revision) is deferred to the FI-DELEG migration and kind 7 (lifecycle-selector +-- generation) to the FI-LIFECYCLE migration. Kind 5 is retired with durable +-- client-status revisions; retained kinds keep their original identities. +CREATE TABLE authorization_operation_version_deltas ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + component_kind SMALLINT NOT NULL CHECK (component_kind IN (1, 2, 3, 4)), + component_key BYTEA NOT NULL CHECK (octet_length(component_key) = 32), + before_version BIGINT NOT NULL CHECK (before_version >= 0), + after_version BIGINT NOT NULL, + component_digest BYTEA NOT NULL CHECK (octet_length(component_digest) = 32), + PRIMARY KEY (community_id, operation_id, component_kind, component_key), + FOREIGN KEY (community_id, operation_id) + REFERENCES authorization_operation_version_delta_manifests + (community_id, operation_id), + CHECK (after_version > before_version) +); + +CREATE FUNCTION authorization_event_capacity_before_insert_v1() RETURNS TRIGGER AS $$ +DECLARE + policy authorization_event_capacity%ROWTYPE; + envelope_bytes BIGINT; +BEGIN + SELECT * INTO policy + FROM authorization_event_capacity + WHERE community_id = NEW.community_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'authorization event capacity policy missing' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_event_capacity_policy_required'; + END IF; + IF policy.health_state <> 1 THEN + RAISE EXCEPTION 'authorization audit is unavailable' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_event_capacity_health'; + END IF; + + envelope_bytes := octet_length(NEW.canonical_envelope); + IF envelope_bytes > policy.max_envelope_bytes + OR policy.retained_event_count + 1 > policy.max_events_per_domain + OR policy.retained_envelope_bytes + envelope_bytes > policy.max_bytes_per_domain + THEN + -- The INSERT and protected mutation abort together. The runtime maps + -- this stable constraint to typed CapacityExhausted and latches audit + -- health outside the rolled-back transaction. + RAISE EXCEPTION 'authorization event capacity exhausted' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_event_capacity_exhausted'; + END IF; + + UPDATE authorization_event_capacity + SET retained_event_count = retained_event_count + 1, + retained_envelope_bytes = retained_envelope_bytes + envelope_bytes, + updated_at = transaction_timestamp() + WHERE community_id = NEW.community_id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_events_capacity + BEFORE INSERT ON authorization_events + FOR EACH ROW EXECUTE FUNCTION authorization_event_capacity_before_insert_v1(); + +CREATE FUNCTION authorization_invalidation_domain_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.activated_at IS DISTINCT FROM OLD.activated_at + OR NEW.current_generation <= OLD.current_generation + OR NEW.updated_at <= OLD.updated_at + THEN + RAISE EXCEPTION 'authorization invalidation activation/generation cannot move backward' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_invalidation_domains_monotonic + BEFORE UPDATE ON authorization_invalidation_domains + FOR EACH ROW EXECUTE FUNCTION authorization_invalidation_domain_guard_v1(); +CREATE TRIGGER authorization_invalidation_domains_no_delete + BEFORE DELETE ON authorization_invalidation_domains + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_invalidation_domains_no_truncate + BEFORE TRUNCATE ON authorization_invalidation_domains + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE FUNCTION authorization_invalidation_floor_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.selector_kind IS DISTINCT FROM OLD.selector_kind + OR NEW.selector_fingerprint IS DISTINCT FROM OLD.selector_fingerprint + OR NEW.floor_generation < OLD.floor_generation + OR COALESCE(NEW.binding_version_floor, 0) < COALESCE(OLD.binding_version_floor, 0) + OR COALESCE(NEW.relationship_revision_floor, 0) + < COALESCE(OLD.relationship_revision_floor, 0) + OR ( + NEW.floor_generation = OLD.floor_generation + AND COALESCE(NEW.binding_version_floor, 0) + = COALESCE(OLD.binding_version_floor, 0) + AND COALESCE(NEW.relationship_revision_floor, 0) + = COALESCE(OLD.relationship_revision_floor, 0) + ) + OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id + OR NEW.updated_at <= OLD.updated_at + THEN + RAISE EXCEPTION 'authorization invalidation floor cannot move backward' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_invalidation_floors_monotonic + BEFORE UPDATE ON authorization_invalidation_floors + FOR EACH ROW EXECUTE FUNCTION authorization_invalidation_floor_guard_v1(); +CREATE TRIGGER authorization_invalidation_floors_no_delete + BEFORE DELETE ON authorization_invalidation_floors + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_invalidation_floors_no_truncate + BEFORE TRUNCATE ON authorization_invalidation_floors + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE FUNCTION authorization_authority_epoch_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.object_kind IS DISTINCT FROM OLD.object_kind + OR NEW.object_key IS DISTINCT FROM OLD.object_key + OR NEW.authority_epoch <= OLD.authority_epoch + OR NEW.fence IS NOT DISTINCT FROM OLD.fence + OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id + OR NEW.updated_at <= OLD.updated_at + THEN + RAISE EXCEPTION 'authorization authority epoch cannot move backward' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_authority_epochs_monotonic + BEFORE UPDATE ON authorization_authority_epochs + FOR EACH ROW EXECUTE FUNCTION authorization_authority_epoch_guard_v1(); +CREATE TRIGGER authorization_authority_epochs_no_delete + BEFORE DELETE ON authorization_authority_epochs + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_authority_epochs_no_truncate + BEFORE TRUNCATE ON authorization_authority_epochs + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE FUNCTION authorization_event_capacity_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.max_events_per_domain IS DISTINCT FROM OLD.max_events_per_domain + OR NEW.max_bytes_per_domain IS DISTINCT FROM OLD.max_bytes_per_domain + OR NEW.max_envelope_bytes IS DISTINCT FROM OLD.max_envelope_bytes + OR NEW.configured_at IS DISTINCT FROM OLD.configured_at + OR NEW.retained_event_count < OLD.retained_event_count + OR NEW.retained_envelope_bytes < OLD.retained_envelope_bytes + OR NEW.updated_at < OLD.updated_at + OR (OLD.health_state = 2 AND ( + NEW.health_state <> 2 + OR NEW.failure_code IS DISTINCT FROM OLD.failure_code + OR NEW.failure_observed_at IS DISTINCT FROM OLD.failure_observed_at + )) + OR (OLD.health_state = 1 AND NEW.health_state = 1 AND ( + NEW.failure_code IS NOT NULL OR NEW.failure_observed_at IS NOT NULL + )) + THEN + RAISE EXCEPTION 'authorization event capacity cannot be reset online' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION protected_object_authority_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.object_kind IS DISTINCT FROM OLD.object_kind + OR NEW.object_key IS DISTINCT FROM OLD.object_key + OR NEW.authority_epoch <= OLD.authority_epoch + OR NEW.fence IS NOT DISTINCT FROM OLD.fence + OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id + OR NEW.issued_at <= OLD.issued_at + THEN + RAISE EXCEPTION 'protected authority replacement requires a new operation and epoch' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_event_capacity_monotonic + BEFORE UPDATE ON authorization_event_capacity + FOR EACH ROW EXECUTE FUNCTION authorization_event_capacity_guard_v1(); +CREATE TRIGGER authorization_event_capacity_no_delete + BEFORE DELETE ON authorization_event_capacity + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_event_capacity_no_truncate + BEFORE TRUNCATE ON authorization_event_capacity + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER authorization_events_immutable + BEFORE UPDATE OR DELETE ON authorization_events + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_events_no_truncate + BEFORE TRUNCATE ON authorization_events + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER authorization_authentication_denial_attempts_immutable + BEFORE UPDATE OR DELETE ON authorization_authentication_denial_attempts + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_authentication_denial_attempts_no_truncate + BEFORE TRUNCATE ON authorization_authentication_denial_attempts + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE FUNCTION authorization_operation_version_delta_cardinality_guard_v1() +RETURNS TRIGGER AS $$ +DECLARE + manifest authorization_operation_version_delta_manifests%ROWTYPE; + actual_component_count BIGINT; +BEGIN + IF TG_TABLE_NAME = 'authorization_operation_version_delta_manifests' THEN + manifest := NEW; + ELSE + SELECT * INTO STRICT manifest + FROM authorization_operation_version_delta_manifests + WHERE community_id = NEW.community_id + AND operation_id = NEW.operation_id + FOR NO KEY UPDATE; + END IF; + + SELECT count(*) INTO actual_component_count + FROM authorization_operation_version_deltas + WHERE community_id = manifest.community_id + AND operation_id = manifest.operation_id; + + IF actual_component_count <> manifest.component_count THEN + RAISE EXCEPTION 'operation version manifest declares % components, found %', + manifest.component_count, actual_component_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_operation_version_delta_cardinality'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE CONSTRAINT TRIGGER authorization_operation_version_delta_manifest_cardinality + AFTER INSERT ON authorization_operation_version_delta_manifests + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operation_version_delta_cardinality_guard_v1(); +CREATE CONSTRAINT TRIGGER authorization_operation_version_delta_component_cardinality + AFTER INSERT ON authorization_operation_version_deltas + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operation_version_delta_cardinality_guard_v1(); + +CREATE TRIGGER authorization_operation_version_delta_manifests_immutable + BEFORE UPDATE OR DELETE ON authorization_operation_version_delta_manifests + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_operation_version_delta_manifests_no_truncate + BEFORE TRUNCATE ON authorization_operation_version_delta_manifests + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER authorization_operation_version_deltas_immutable + BEFORE UPDATE OR DELETE ON authorization_operation_version_deltas + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_operation_version_deltas_no_truncate + BEFORE TRUNCATE ON authorization_operation_version_deltas + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER protected_object_authority_no_delete + BEFORE DELETE ON protected_object_authority + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER protected_object_authority_no_truncate + BEFORE TRUNCATE ON protected_object_authority + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); +CREATE TRIGGER protected_object_authority_strict_replacement + BEFORE UPDATE ON protected_object_authority + FOR EACH ROW EXECUTE FUNCTION protected_object_authority_guard_v1(); + +-- Canonical admission keeps its complete logical intent and the closed, +-- credential-free application result beside the immutable receipt. This is +-- what lets an identical request replay reconstruct the same typed result +-- without repeating membership or other application DML. Object kinds match +-- protected_object_authority: 1 domain, 2 channel, 3 repository, 4 media, +-- 5 moderation target, 6 audio session. +CREATE TABLE authorization_admission_results ( + community_id UUID NOT NULL, + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + semantic_fingerprint BYTEA NOT NULL CHECK ( + octet_length(semantic_fingerprint) = 32 + AND semantic_fingerprint <> decode(repeat('00', 32), 'hex') + ), + object_kind SMALLINT NOT NULL CHECK (object_kind BETWEEN 1 AND 6), + object_key BYTEA NOT NULL CHECK ( + octet_length(object_key) = 32 + AND object_key <> decode(repeat('00', 32), 'hex') + ), + application_type BYTEA CHECK ( + application_type IS NULL + OR (octet_length(application_type) = 32 + AND application_type <> decode(repeat('00', 32), 'hex')) + ), + application_version SMALLINT CHECK (application_version > 0), + application_code SMALLINT CHECK (application_code > 0), + application_payload BYTEA CHECK ( + application_payload IS NULL OR octet_length(application_payload) <= 4096 + ), + application_intent_digest BYTEA CHECK ( + application_intent_digest IS NULL + OR (octet_length(application_intent_digest) = 32 + AND application_intent_digest <> decode(repeat('00', 32), 'hex')) + ), + application_effect_digest BYTEA CHECK ( + application_effect_digest IS NULL + OR (octet_length(application_effect_digest) = 32 + AND application_effect_digest <> decode(repeat('00', 32), 'hex')) + ), + application_result_digest BYTEA CHECK ( + application_result_digest IS NULL + OR (octet_length(application_result_digest) = 32 + AND application_result_digest <> decode(repeat('00', 32), 'hex')) + ), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, operation_id), + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint), + CHECK ( + (application_type IS NULL + AND application_version IS NULL + AND application_code IS NULL + AND application_payload IS NULL + AND application_intent_digest IS NULL + AND application_effect_digest IS NULL + AND application_result_digest IS NULL) + OR (application_type IS NOT NULL + AND application_version IS NOT NULL + AND application_code IS NOT NULL + AND application_payload IS NOT NULL + AND application_intent_digest IS NOT NULL + AND application_effect_digest IS NOT NULL + AND application_result_digest IS NOT NULL) + ) +); + +CREATE TRIGGER authorization_admission_results_no_update + BEFORE UPDATE OR DELETE ON authorization_admission_results + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_admission_results_no_truncate + BEFORE TRUNCATE ON authorization_admission_results + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +-- Every successful/no-op core lifecycle receipt has exactly one privacy-safe +-- audit event with the closed transition-kind mapping. Both directions are +-- deferred so receipt, history, event, selectors, and binding may be inserted +-- in any order inside one transaction but can never commit partially. The +-- extended-lifecycle operation kinds (2 provision, 4 disable, 7 recover, +-- 8 enable, 9 admission loss) and their event kinds arrive with the +-- FI-LIFECYCLE migration; here the mapping covers only enroll/retire/revoke/ +-- rotate. Non-lifecycle receipts (protected mutation, invalidation) carry no +-- audit-event cardinality requirement. +CREATE FUNCTION authorization_operation_receipt_event_guard_v1() +RETURNS TRIGGER AS $$ +DECLARE + receipt authorization_operation_receipts%ROWTYPE; + expected_event_kind SMALLINT; + matching_event_count BIGINT; + expected_event_count BIGINT; +BEGIN + IF TG_TABLE_NAME = 'authorization_operation_receipts' THEN + receipt := NEW; + ELSE + SELECT * INTO receipt + FROM authorization_operation_receipts + WHERE community_id = NEW.community_id + AND operation_id = NEW.operation_id; + IF NOT FOUND THEN + -- Credential-free pre-authentication denials intentionally have no + -- canonical receipt. Their separate FK/shape guards still run. + RETURN NULL; + END IF; + END IF; + + expected_event_kind := CASE receipt.operation_kind + WHEN 1 THEN 1 -- enroll + WHEN 3 THEN 6 -- retire + WHEN 5 THEN 2 -- revoke + WHEN 6 THEN 3 -- rotate + ELSE NULL + END; + IF expected_event_kind IS NULL THEN + RETURN NULL; + END IF; + + SELECT + count(*), + count(*) FILTER (WHERE event_kind = expected_event_kind) + INTO matching_event_count, expected_event_count + FROM authorization_events + WHERE community_id = receipt.community_id + AND operation_id = receipt.operation_id + AND request_fingerprint = receipt.request_fingerprint; + + IF matching_event_count <> 1 OR expected_event_count <> 1 THEN + RAISE EXCEPTION + 'lifecycle receipt requires exactly one event kind %, found % total and % expected', + expected_event_kind, matching_event_count, expected_event_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_operation_receipt_event_cardinality'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE CONSTRAINT TRIGGER authorization_operation_receipt_event_cardinality + AFTER INSERT ON authorization_operation_receipts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_event_guard_v1(); + +CREATE CONSTRAINT TRIGGER authorization_event_receipt_cardinality + AFTER INSERT ON authorization_events + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_event_guard_v1(); + +-- Same ledger posture as migration 0040's identity relations: the admission, +-- replay, audit, and invalidation relations below are append-only denial and +-- authority facts protected by immutable no_delete/no_truncate triggers, so +-- they carry community_id as provenance rather than deletable ownership. Widen +-- the single SQL source of truth so the universal write fence and the deletion +-- catalog treat all NIP-FI relations as ledger — never fence-attached, never +-- purged, never counted as tenant-scoped drift. This re-declares the full set +-- (0040's identity relations plus these) because CREATE OR REPLACE FUNCTION +-- replaces the whole body. +CREATE OR REPLACE FUNCTION community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN +LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$ + SELECT target::TEXT = ANY (ARRAY[ + 'community_deletion_requests', 'community_deletion_approvals', + 'community_deletion_checkpoints', 'community_serving_write_leases', + 'community_deletion_executor_heartbeats', 'product_feedback', + 'rate_limit_violations', + 'authorization_operation_receipts', 'identity_enrollment_policies', + 'identity_bindings', 'identity_lifecycle_history', + 'identity_lifecycle_selectors', + 'authorization_invalidation_domains', 'authorization_invalidation_floors', + 'authorization_authority_epochs', 'protected_object_authority', + 'authorization_event_capacity', 'authorization_events', + 'authorization_authentication_denial_attempts', + 'authorization_operation_version_delta_manifests', + 'authorization_operation_version_deltas', 'authorization_admission_results' + ]::TEXT[]) +$$; diff --git a/schema/schema.sql b/schema/schema.sql index 54566103335..445b6d02bd1 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -1485,13 +1485,19 @@ $$; CREATE FUNCTION community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$ SELECT target::TEXT = ANY (ARRAY[ - 'community_deletion_requests', - 'community_deletion_approvals', - 'community_deletion_checkpoints', - 'community_serving_write_leases', - 'community_deletion_executor_heartbeats', - 'product_feedback', - 'rate_limit_violations' + 'community_deletion_requests', 'community_deletion_approvals', + 'community_deletion_checkpoints', 'community_serving_write_leases', + 'community_deletion_executor_heartbeats', 'product_feedback', + 'rate_limit_violations', + 'authorization_operation_receipts', 'identity_enrollment_policies', + 'identity_bindings', 'identity_lifecycle_history', + 'identity_lifecycle_selectors', + 'authorization_invalidation_domains', 'authorization_invalidation_floors', + 'authorization_authority_epochs', 'protected_object_authority', + 'authorization_event_capacity', 'authorization_events', + 'authorization_authentication_denial_attempts', + 'authorization_operation_version_delta_manifests', + 'authorization_operation_version_deltas', 'authorization_admission_results' ]::TEXT[]) $$; @@ -1895,3 +1901,1561 @@ CREATE INDEX idx_relay_operator_audit_target INSERT INTO _operator_global_tables (table_name, reason) VALUES ('relay_operator_audit', 'deployment-global append-only roster mutation audit trail; no community_id intentionally'); + + +-- ============================================================================ +-- NIP-FI core identity + base-lifecycle foundation (mirror of migration 0040). +-- The community_write_fence_excluded_table definition above already folds in +-- the NIP-FI ledger relations; the per-migration CREATE OR REPLACE bodies are +-- intentionally omitted here (desired state keeps one consolidated definition). +-- ============================================================================ + +-- The sole idempotency/result root shared by identity base lifecycle, +-- protected operations, and invalidation. Pre-authentication denials never +-- write this table. ExactReplay and IntentConflict are read-time observations, +-- not persisted outcomes. +CREATE TABLE authorization_operation_receipts ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + -- Core operation kinds: 1 enroll, 3 retire, 5 revoke, 6 rotate, + -- 11 protected mutation, 12 invalidation. Extended lifecycle kinds + -- (2 provision, 4 disable, 7 recover, 8 enable, 9 admission loss) and + -- 10 operator are introduced by their owning later migrations. + operation_kind SMALLINT NOT NULL CHECK ( + operation_kind IN (1, 3, 5, 6, 11, 12) + ), + actor_fingerprint BYTEA NOT NULL CHECK (octet_length(actor_fingerprint) = 32), + -- 1 applied, 2 denied, 3 no-op. + outcome_code SMALLINT NOT NULL CHECK (outcome_code IN (1, 2, 3)), + result_digest BYTEA NOT NULL CHECK (octet_length(result_digest) = 32), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, operation_id), + UNIQUE (community_id, operation_id, request_fingerprint), + UNIQUE ( + community_id, + operation_id, + request_fingerprint, + operation_kind, + outcome_code + ), + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid) +); + +-- Immutable monotonic local policy revisions. Enrollment modes are the closed +-- provider-free V1 set: 1 attested-key, 2 provisioned, 3 risk-labelled TOFU. +CREATE TABLE identity_enrollment_policies ( + community_id UUID NOT NULL REFERENCES communities(id), + policy_revision BIGINT NOT NULL CHECK (policy_revision > 0), + enrollment_mode SMALLINT NOT NULL CHECK (enrollment_mode IN (1, 2, 3)), + policy_digest BYTEA NOT NULL CHECK (octet_length(policy_digest) = 32), + effective_at TIMESTAMPTZ NOT NULL, + -- Optional local binding-policy expiry. Federated token `exp` MUST NOT be + -- copied here: token lifetime bounds an authorization lease, not this + -- durable binding generation. + expires_at TIMESTAMPTZ, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, policy_revision), + UNIQUE (community_id, policy_revision, enrollment_mode), + CHECK (expires_at IS NULL OR effective_at < expires_at) +); + +-- One row is one immutable binding generation. binding_version is allocated +-- from one non-cycling PostgreSQL identity sequence and is never changed or +-- reused. Explicit lifecycle may only retire the generation; X/Y denial +-- semantics live in immutable selector facts below, not alternate row states. +CREATE TABLE identity_bindings ( + community_id UUID NOT NULL REFERENCES communities(id), + binding_id UUID NOT NULL, + binding_version BIGINT GENERATED ALWAYS AS IDENTITY ( + START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1 NO CYCLE + ), + issuer TEXT COLLATE "C" NOT NULL CHECK (octet_length(issuer) BETWEEN 1 AND 2048), + subject TEXT COLLATE "C" NOT NULL CHECK (octet_length(subject) BETWEEN 1 AND 2048), + principal_fingerprint BYTEA NOT NULL CHECK (octet_length(principal_fingerprint) = 32), + event_author_pubkey BYTEA NOT NULL CHECK (octet_length(event_author_pubkey) = 32), + -- 1 active, 2 retired. + binding_state SMALLINT NOT NULL CHECK (binding_state IN (1, 2)), + lifecycle_revision BIGINT NOT NULL CHECK (lifecycle_revision IN (1, 2)), + -- 1 attested-key, 2 provisioned, 3 risk-labelled TOFU. + binding_provenance SMALLINT NOT NULL CHECK (binding_provenance IN (1, 2, 3)), + policy_revision BIGINT NOT NULL CHECK (policy_revision > 0), + -- Canonical evidence for the selected provenance. This is an assertion + -- digest for attested/TOFU admission and a provisioning receipt digest for + -- separately provisioned admission; it never stores credential bytes. + enrollment_evidence_digest BYTEA NOT NULL CHECK ( + octet_length(enrollment_evidence_digest) = 32 + ), + expires_at TIMESTAMPTZ, + birth_history_id UUID NOT NULL, + creation_operation_id UUID NOT NULL, + creation_request_fingerprint BYTEA NOT NULL CHECK ( + octet_length(creation_request_fingerprint) = 32 + ), + retirement_history_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, binding_id), + UNIQUE (community_id, binding_version), + UNIQUE (community_id, binding_id, binding_version), + FOREIGN KEY (community_id, policy_revision, binding_provenance) + REFERENCES identity_enrollment_policies + (community_id, policy_revision, enrollment_mode), + CHECK (binding_version > 0), + CHECK (binding_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (birth_history_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (creation_operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (expires_at IS NULL OR created_at < expires_at), + CHECK ( + (binding_state = 1 AND lifecycle_revision = 1 AND retirement_history_id IS NULL) + OR (binding_state = 2 AND lifecycle_revision = 2 AND retirement_history_id IS NOT NULL) + ) +); + +-- State 1 is Active. Expiry is evaluated with authoritative PostgreSQL time +-- at read/finalization and is exclusive; it cannot appear in an index predicate. +CREATE UNIQUE INDEX identity_bindings_active_principal + ON identity_bindings (community_id, issuer, subject) + WHERE binding_state = 1; +CREATE INDEX identity_bindings_principal_fingerprint_lookup + ON identity_bindings (community_id, principal_fingerprint) + WHERE binding_state = 1; +CREATE UNIQUE INDEX identity_bindings_active_event_author + ON identity_bindings (community_id, event_author_pubkey) + WHERE binding_state = 1; +CREATE INDEX identity_bindings_current_lookup + ON identity_bindings (community_id, event_author_pubkey, binding_state, expires_at); + +-- The one canonical immutable lifecycle transition row for a successful or +-- no-op lifecycle operation. A transition can name an old generation, a new +-- successor generation, both (Rotate), or neither (a semantic no-op). It is not +-- a second result/effect engine: the shared receipt remains the sole persisted +-- operation outcome. Core transition kinds only: 1 enroll, 3 retire, 5 revoke, +-- 6 rotate. +CREATE TABLE identity_lifecycle_history ( + community_id UUID NOT NULL REFERENCES communities(id), + history_id UUID NOT NULL, + transition_kind SMALLINT NOT NULL CHECK ( + transition_kind IN (1, 3, 5, 6) + ), + -- Matches the shared receipt: 1 applied, 3 no-op. + outcome_code SMALLINT NOT NULL CHECK (outcome_code IN (1, 3)), + old_binding_id UUID, + old_binding_version BIGINT CHECK (old_binding_version IS NULL OR old_binding_version > 0), + old_prior_lifecycle_revision BIGINT CHECK ( + old_prior_lifecycle_revision IS NULL OR old_prior_lifecycle_revision IN (1, 2) + ), + old_prior_state SMALLINT CHECK (old_prior_state IS NULL OR old_prior_state IN (1, 2)), + old_resulting_lifecycle_revision BIGINT CHECK ( + old_resulting_lifecycle_revision IS NULL OR old_resulting_lifecycle_revision IN (1, 2) + ), + old_resulting_state SMALLINT CHECK ( + old_resulting_state IS NULL OR old_resulting_state IN (1, 2) + ), + successor_binding_id UUID, + successor_binding_version BIGINT CHECK ( + successor_binding_version IS NULL OR successor_binding_version > 0 + ), + successor_lifecycle_revision BIGINT CHECK ( + successor_lifecycle_revision IS NULL OR successor_lifecycle_revision = 1 + ), + successor_state SMALLINT CHECK (successor_state IS NULL OR successor_state = 1), + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + transition_digest BYTEA NOT NULL CHECK (octet_length(transition_digest) = 32), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, history_id), + UNIQUE (community_id, operation_id), + UNIQUE (community_id, history_id, operation_id, request_fingerprint), + UNIQUE ( + community_id, + history_id, + successor_binding_id, + successor_binding_version, + operation_id, + request_fingerprint + ), + UNIQUE ( + community_id, + history_id, + old_binding_id, + old_binding_version, + old_resulting_lifecycle_revision, + old_resulting_state + ), + FOREIGN KEY ( + community_id, + operation_id, + request_fingerprint, + transition_kind, + outcome_code + ) REFERENCES authorization_operation_receipts ( + community_id, + operation_id, + request_fingerprint, + operation_kind, + outcome_code + ) DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (community_id, old_binding_id, old_binding_version) + REFERENCES identity_bindings (community_id, binding_id, binding_version) + DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (community_id, successor_binding_id, successor_binding_version) + REFERENCES identity_bindings (community_id, binding_id, binding_version) + DEFERRABLE INITIALLY DEFERRED, + CHECK (history_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK ( + (old_binding_id IS NULL + AND old_binding_version IS NULL + AND old_prior_lifecycle_revision IS NULL + AND old_prior_state IS NULL + AND old_resulting_lifecycle_revision IS NULL + AND old_resulting_state IS NULL) + OR (old_binding_id IS NOT NULL + AND old_binding_version IS NOT NULL + AND old_prior_lifecycle_revision IS NOT NULL + AND old_prior_state IS NOT NULL + AND old_resulting_lifecycle_revision IS NOT NULL + AND old_resulting_state IS NOT NULL) + ), + CHECK ( + (successor_binding_id IS NULL + AND successor_binding_version IS NULL + AND successor_lifecycle_revision IS NULL + AND successor_state IS NULL) + OR (successor_binding_id IS NOT NULL + AND successor_binding_version IS NOT NULL + AND successor_lifecycle_revision = 1 + AND successor_state = 1) + ), + CHECK ( + old_binding_id IS NULL + OR successor_binding_id IS NULL + OR old_binding_id <> successor_binding_id + ), + CHECK ( + old_binding_version IS NULL + OR successor_binding_version IS NULL + OR old_binding_version <> successor_binding_version + ), + -- Core lifecycle only ever moves Active/r1 to Retired/r2 for a named old + -- generation. Extended re-enablement (recover/enable from Retired/r2) is a + -- later migration's concern. + CHECK ( + old_binding_id IS NULL + OR (old_prior_lifecycle_revision = 1 + AND old_prior_state = 1 + AND old_resulting_lifecycle_revision = 2 + AND old_resulting_state = 2) + ), + CHECK ( + (outcome_code = 3 + AND old_binding_id IS NULL + AND successor_binding_id IS NULL) + OR (outcome_code = 1 AND ( + (transition_kind = 1 + AND old_binding_id IS NULL + AND successor_binding_id IS NOT NULL) + OR (transition_kind = 3 + AND old_binding_id IS NOT NULL + AND successor_binding_id IS NULL) + OR (transition_kind = 5 + AND successor_binding_id IS NULL) + OR (transition_kind = 6 + AND old_binding_id IS NOT NULL + AND successor_binding_id IS NOT NULL) + )) + ) +); + +CREATE INDEX identity_lifecycle_history_old_binding + ON identity_lifecycle_history (community_id, old_binding_id, old_binding_version, recorded_at); +CREATE INDEX identity_lifecycle_history_successor_binding + ON identity_lifecycle_history ( + community_id, + successor_binding_id, + successor_binding_version, + recorded_at + ); + +-- Circular birth/transition ordering is deliberate and fully deferred. Every +-- generation must commit with its exact birth transition, and a retired row +-- must commit with the exact transition that changed Active/r1 to Retired/r2. +ALTER TABLE identity_bindings + ADD CONSTRAINT identity_bindings_exact_birth_history_fk + FOREIGN KEY ( + community_id, + birth_history_id, + binding_id, + binding_version, + creation_operation_id, + creation_request_fingerprint + ) REFERENCES identity_lifecycle_history ( + community_id, + history_id, + successor_binding_id, + successor_binding_version, + operation_id, + request_fingerprint + ) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE identity_bindings + ADD CONSTRAINT identity_bindings_exact_retirement_history_fk + FOREIGN KEY ( + community_id, + retirement_history_id, + binding_id, + binding_version, + lifecycle_revision, + binding_state + ) REFERENCES identity_lifecycle_history ( + community_id, + history_id, + old_binding_id, + old_binding_version, + old_resulting_lifecycle_revision, + old_resulting_state + ) DEFERRABLE INITIALLY DEFERRED; + +-- One immutable closed-scope fact table. Core selector kinds only: +-- 1 retired pair (P), 3 revoked key (Y). Both are permanent. The extended +-- disabled-identity (X) and pending-replacement (Q) selectors, and their +-- one-shot consumption, are introduced by the FI-LIFECYCLE migration. +CREATE TABLE identity_lifecycle_selectors ( + community_id UUID NOT NULL REFERENCES communities(id), + selector_id UUID NOT NULL, + selector_kind SMALLINT NOT NULL CHECK (selector_kind IN (1, 3)), + selector_fingerprint BYTEA NOT NULL CHECK (octet_length(selector_fingerprint) = 32), + fact_generation BIGINT NOT NULL CHECK (fact_generation > 0), + principal_fingerprint BYTEA CHECK ( + principal_fingerprint IS NULL OR octet_length(principal_fingerprint) = 32 + ), + event_author_pubkey BYTEA CHECK ( + event_author_pubkey IS NULL OR octet_length(event_author_pubkey) = 32 + ), + binding_id UUID, + binding_version BIGINT CHECK (binding_version IS NULL OR binding_version > 0), + asserted_history_id UUID NOT NULL, + selected_by_operation_id UUID NOT NULL, + selected_by_request_fingerprint BYTEA NOT NULL CHECK ( + octet_length(selected_by_request_fingerprint) = 32 + ), + selected_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, selector_id), + UNIQUE (community_id, selector_id, selector_kind), + UNIQUE (community_id, selector_kind, selector_fingerprint, fact_generation), + FOREIGN KEY ( + community_id, + asserted_history_id, + selected_by_operation_id, + selected_by_request_fingerprint + ) REFERENCES identity_lifecycle_history ( + community_id, + history_id, + operation_id, + request_fingerprint + ) DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY ( + community_id, + selected_by_operation_id, + selected_by_request_fingerprint + ) REFERENCES authorization_operation_receipts ( + community_id, + operation_id, + request_fingerprint + ) DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (community_id, binding_id, binding_version) + REFERENCES identity_bindings (community_id, binding_id, binding_version) + DEFERRABLE INITIALLY DEFERRED, + CHECK (selector_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK ( + (selector_kind = 1 + AND fact_generation = 1 + AND principal_fingerprint IS NOT NULL + AND event_author_pubkey IS NOT NULL + AND binding_id IS NOT NULL + AND binding_version IS NOT NULL) + OR (selector_kind = 3 + AND fact_generation = 1 + AND principal_fingerprint IS NULL + AND event_author_pubkey IS NOT NULL + AND binding_id IS NULL + AND binding_version IS NULL) + ) +); + +CREATE UNIQUE INDEX identity_lifecycle_selectors_permanent_pair + ON identity_lifecycle_selectors (community_id, binding_id, binding_version) + WHERE selector_kind = 1; +CREATE UNIQUE INDEX identity_lifecycle_selectors_permanent_principal_key + ON identity_lifecycle_selectors ( + community_id, + principal_fingerprint, + event_author_pubkey + ) WHERE selector_kind = 1; +CREATE UNIQUE INDEX identity_lifecycle_selectors_permanent_key + ON identity_lifecycle_selectors (community_id, event_author_pubkey) + WHERE selector_kind = 3; +CREATE INDEX identity_lifecycle_selectors_principal_lookup + ON identity_lifecycle_selectors + (community_id, selector_kind, principal_fingerprint, fact_generation); +CREATE INDEX identity_lifecycle_selectors_key_lookup + ON identity_lifecycle_selectors + (community_id, selector_kind, event_author_pubkey, fact_generation); +CREATE INDEX identity_lifecycle_selectors_binding_lookup + ON identity_lifecycle_selectors + (community_id, selector_kind, binding_id, binding_version, fact_generation); +CREATE INDEX identity_lifecycle_selectors_asserted_history + ON identity_lifecycle_selectors + (community_id, asserted_history_id, selector_kind); + +CREATE FUNCTION nip_fi_reject_row_mutation_v1() RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION '% is immutable', TG_TABLE_NAME + USING ERRCODE = 'check_violation'; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION nip_fi_reject_truncate_v1() RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION '% cannot be truncated', TG_TABLE_NAME + USING ERRCODE = 'check_violation'; +END; +$$ LANGUAGE plpgsql; + +-- Every binding/selector path derives the same domain-scoped coordinates and +-- takes their signed BIGINT advisory keys in numeric order. Typed transaction +-- APIs take these locks before row mutation; the triggers are the fail-closed +-- backstop for direct SQL. +CREATE FUNCTION identity_lifecycle_lock_coordinates_v1( + locked_community_id UUID, + locked_principal_fingerprint BYTEA, + locked_event_author_pubkey BYTEA +) RETURNS VOID AS $$ +DECLARE + principal_lock_key BIGINT; + event_author_lock_key BIGINT; +BEGIN + IF locked_principal_fingerprint IS NOT NULL THEN + principal_lock_key := hashtextextended( + 'buzz:identity-lifecycle-coordinate:v1:principal:' + || locked_community_id::text || ':' + || encode(locked_principal_fingerprint, 'hex'), + 0 + ); + END IF; + IF locked_event_author_pubkey IS NOT NULL THEN + event_author_lock_key := hashtextextended( + 'buzz:identity-lifecycle-coordinate:v1:key:' + || locked_community_id::text || ':' + || encode(locked_event_author_pubkey, 'hex'), + 0 + ); + END IF; + + IF principal_lock_key IS NOT NULL AND event_author_lock_key IS NOT NULL THEN + PERFORM pg_advisory_xact_lock(LEAST(principal_lock_key, event_author_lock_key)); + IF principal_lock_key <> event_author_lock_key THEN + PERFORM pg_advisory_xact_lock(GREATEST(principal_lock_key, event_author_lock_key)); + END IF; + ELSIF principal_lock_key IS NOT NULL THEN + PERFORM pg_advisory_xact_lock(principal_lock_key); + ELSIF event_author_lock_key IS NOT NULL THEN + PERFORM pg_advisory_xact_lock(event_author_lock_key); + END IF; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_bindings_insert_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + PERFORM identity_lifecycle_lock_coordinates_v1( + NEW.community_id, + NEW.principal_fingerprint, + NEW.event_author_pubkey + ); + IF NEW.binding_state <> 1 + OR NEW.lifecycle_revision <> 1 + OR NEW.retirement_history_id IS NOT NULL + THEN + RAISE EXCEPTION 'identity binding birth must be Active at lifecycle revision 1' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_bindings_birth_state'; + END IF; + NEW.created_at := transaction_timestamp(); + NEW.updated_at := transaction_timestamp(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_bindings_transition_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + PERFORM identity_lifecycle_lock_coordinates_v1( + OLD.community_id, + OLD.principal_fingerprint, + OLD.event_author_pubkey + ); + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.binding_id IS DISTINCT FROM OLD.binding_id + OR NEW.binding_version IS DISTINCT FROM OLD.binding_version + OR NEW.issuer IS DISTINCT FROM OLD.issuer + OR NEW.subject IS DISTINCT FROM OLD.subject + OR NEW.principal_fingerprint IS DISTINCT FROM OLD.principal_fingerprint + OR NEW.event_author_pubkey IS DISTINCT FROM OLD.event_author_pubkey + OR NEW.binding_provenance IS DISTINCT FROM OLD.binding_provenance + OR NEW.policy_revision IS DISTINCT FROM OLD.policy_revision + OR NEW.enrollment_evidence_digest IS DISTINCT FROM OLD.enrollment_evidence_digest + OR NEW.expires_at IS DISTINCT FROM OLD.expires_at + OR NEW.birth_history_id IS DISTINCT FROM OLD.birth_history_id + OR NEW.creation_operation_id IS DISTINCT FROM OLD.creation_operation_id + OR NEW.creation_request_fingerprint IS DISTINCT FROM OLD.creation_request_fingerprint + OR NEW.created_at IS DISTINCT FROM OLD.created_at + THEN + RAISE EXCEPTION 'identity binding generation coordinates are immutable' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_bindings_immutable_generation'; + END IF; + IF OLD.binding_state <> 1 + OR OLD.lifecycle_revision <> 1 + OR OLD.retirement_history_id IS NOT NULL + OR NEW.binding_state <> 2 + OR NEW.lifecycle_revision <> 2 + OR NEW.retirement_history_id IS NULL + OR NEW.retirement_history_id = OLD.birth_history_id + THEN + RAISE EXCEPTION 'identity binding permits only Active/r1 to Retired/r2' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_bindings_active_to_retired'; + END IF; + NEW.updated_at := transaction_timestamp(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_lifecycle_history_insert_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + NEW.recorded_at := transaction_timestamp(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_binding_history_semantics_guard_v1() RETURNS TRIGGER AS $$ +DECLARE + retirement identity_lifecycle_history%ROWTYPE; +BEGIN + IF NEW.binding_state = 2 THEN + SELECT * INTO STRICT retirement + FROM identity_lifecycle_history + WHERE community_id = NEW.community_id + AND history_id = NEW.retirement_history_id + AND old_binding_id = NEW.binding_id + AND old_binding_version = NEW.binding_version; + IF retirement.outcome_code <> 1 + OR retirement.old_prior_lifecycle_revision <> 1 + OR retirement.old_prior_state <> 1 + OR retirement.old_resulting_lifecycle_revision <> 2 + OR retirement.old_resulting_state <> 2 + THEN + RAISE EXCEPTION 'retired binding must reference its exact Active-to-Retired transition' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_bindings_retirement_history_semantics'; + END IF; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_binding_birth_eligibility_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM identity_lifecycle_selectors selector + WHERE selector.community_id = NEW.community_id + AND ( + (selector.selector_kind = 1 + AND selector.principal_fingerprint = NEW.principal_fingerprint + AND selector.event_author_pubkey = NEW.event_author_pubkey) + OR (selector.selector_kind = 3 + AND selector.event_author_pubkey = NEW.event_author_pubkey) + ) + ) THEN + RAISE EXCEPTION 'binding birth conflicts with an effective lifecycle selector' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_bindings_birth_eligibility'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION authorization_operation_receipt_history_guard_v1() RETURNS TRIGGER AS $$ +DECLARE + history_count BIGINT; + expected_count BIGINT; +BEGIN + SELECT count(*) INTO history_count + FROM identity_lifecycle_history history + WHERE history.community_id = NEW.community_id + AND history.operation_id = NEW.operation_id; + + -- Core lifecycle receipts (enroll, retire, revoke, rotate) each require + -- exactly one lifecycle-history row. Non-lifecycle receipts (protected + -- mutation, invalidation) require none. + expected_count := CASE + WHEN NEW.operation_kind IN (1, 3, 5, 6) AND NEW.outcome_code IN (1, 3) THEN 1 + ELSE 0 + END; + IF history_count <> expected_count THEN + RAISE EXCEPTION 'operation receipt requires % lifecycle history row, found %', + expected_count, history_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_operation_receipt_history_cardinality'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_lifecycle_selector_insert_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + NEW.selected_at := transaction_timestamp(); + PERFORM identity_lifecycle_lock_coordinates_v1( + NEW.community_id, + CASE WHEN NEW.selector_kind = 1 THEN NEW.principal_fingerprint END, + NEW.event_author_pubkey + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_lifecycle_selector_history_guard_v1() RETURNS TRIGGER AS $$ +DECLARE + history identity_lifecycle_history%ROWTYPE; + old_binding identity_bindings%ROWTYPE; +BEGIN + SELECT * INTO STRICT history + FROM identity_lifecycle_history + WHERE community_id = NEW.community_id + AND history_id = NEW.asserted_history_id + AND operation_id = NEW.selected_by_operation_id + AND request_fingerprint = NEW.selected_by_request_fingerprint; + + IF history.old_binding_id IS NOT NULL THEN + SELECT * INTO STRICT old_binding + FROM identity_bindings + WHERE community_id = history.community_id + AND binding_id = history.old_binding_id + AND binding_version = history.old_binding_version; + END IF; + + -- A retired-pair (P) selector is asserted by retire, revoke, or rotate of a + -- named old generation; a revoked-key (Y) selector by revoke. + IF history.outcome_code <> 1 + OR (NEW.selector_kind = 1 AND ( + history.transition_kind NOT IN (3, 5, 6) + OR history.old_binding_id IS DISTINCT FROM NEW.binding_id + OR history.old_binding_version IS DISTINCT FROM NEW.binding_version + OR old_binding.principal_fingerprint IS DISTINCT FROM NEW.principal_fingerprint + OR old_binding.event_author_pubkey IS DISTINCT FROM NEW.event_author_pubkey + )) + OR (NEW.selector_kind = 3 AND ( + history.transition_kind <> 5 + OR (history.old_binding_id IS NOT NULL + AND old_binding.event_author_pubkey + IS DISTINCT FROM NEW.event_author_pubkey) + )) + THEN + RAISE EXCEPTION 'selector does not match its lifecycle transition' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_lifecycle_selector_history_semantics'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION identity_lifecycle_transition_integrity_guard_v1() RETURNS TRIGGER AS $$ +DECLARE + transition identity_lifecycle_history%ROWTYPE; + old_binding_state SMALLINT; + asserted_p BIGINT; + asserted_y BIGINT; +BEGIN + IF TG_TABLE_NAME = 'identity_lifecycle_history' THEN + transition := NEW; + ELSIF TG_TABLE_NAME = 'identity_lifecycle_selectors' THEN + SELECT * INTO STRICT transition + FROM identity_lifecycle_history + WHERE community_id = NEW.community_id + AND history_id = NEW.asserted_history_id; + ELSE + SELECT * INTO STRICT transition + FROM identity_lifecycle_history + WHERE community_id = NEW.community_id + AND history_id = CASE + WHEN NEW.binding_state = 2 THEN NEW.retirement_history_id + ELSE NEW.birth_history_id + END; + END IF; + + SELECT + count(*) FILTER (WHERE selector_kind = 1), + count(*) FILTER (WHERE selector_kind = 3) + INTO asserted_p, asserted_y + FROM identity_lifecycle_selectors + WHERE community_id = transition.community_id + AND asserted_history_id = transition.history_id; + + IF transition.old_binding_id IS NOT NULL THEN + SELECT binding_state INTO STRICT old_binding_state + FROM identity_bindings + WHERE community_id = transition.community_id + AND binding_id = transition.old_binding_id + AND binding_version = transition.old_binding_version; + IF old_binding_state <> 2 THEN + RAISE EXCEPTION 'lifecycle transition old binding must be retired at commit' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_lifecycle_transition_integrity'; + END IF; + END IF; + + IF EXISTS ( + SELECT 1 + FROM identity_lifecycle_selectors selector + JOIN identity_bindings active + ON active.community_id = selector.community_id + AND active.binding_state = 1 + AND ( + (selector.selector_kind = 1 + AND active.principal_fingerprint = selector.principal_fingerprint + AND active.event_author_pubkey = selector.event_author_pubkey) + OR (selector.selector_kind = 3 + AND active.event_author_pubkey = selector.event_author_pubkey) + ) + WHERE selector.community_id = transition.community_id + ) THEN + RAISE EXCEPTION 'effective lifecycle selector conflicts with an active binding' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_lifecycle_transition_integrity'; + END IF; + + IF transition.outcome_code = 3 THEN + IF asserted_p + asserted_y <> 0 THEN + RAISE EXCEPTION 'no-op lifecycle transition cannot create selector facts' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_lifecycle_transition_integrity'; + END IF; + RETURN NULL; + END IF; + + -- Core selector companions per transition: + -- enroll (1): none + -- retire (3): exactly one P + -- revoke (5): one Y always; one P when a named old generation is removed + -- rotate (6): exactly one P (old generation retired) + IF (transition.transition_kind = 1 + AND (asserted_p, asserted_y) <> (0, 0)) + OR (transition.transition_kind = 3 + AND (asserted_p, asserted_y) <> (1, 0)) + OR (transition.transition_kind = 5 AND ( + (transition.old_binding_id IS NOT NULL + AND (asserted_p, asserted_y) <> (1, 1)) + OR (transition.old_binding_id IS NULL + AND (asserted_p, asserted_y) <> (0, 1)) + )) + OR (transition.transition_kind = 6 + AND (asserted_p, asserted_y) <> (1, 0)) + THEN + RAISE EXCEPTION 'lifecycle transition has incomplete or forbidden selector companions' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_lifecycle_transition_integrity'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER identity_bindings_insert_guard + BEFORE INSERT ON identity_bindings + FOR EACH ROW EXECUTE FUNCTION identity_bindings_insert_guard_v1(); +CREATE TRIGGER identity_bindings_transition_guard + BEFORE UPDATE ON identity_bindings + FOR EACH ROW EXECUTE FUNCTION identity_bindings_transition_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_bindings_history_semantics + AFTER INSERT OR UPDATE ON identity_bindings + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_binding_history_semantics_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_bindings_birth_eligibility + AFTER INSERT ON identity_bindings + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_binding_birth_eligibility_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_bindings_transition_integrity + AFTER INSERT OR UPDATE ON identity_bindings + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_transition_integrity_guard_v1(); +CREATE TRIGGER identity_bindings_no_delete + BEFORE DELETE ON identity_bindings + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER identity_bindings_no_truncate + BEFORE TRUNCATE ON identity_bindings + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER identity_lifecycle_history_insert_guard + BEFORE INSERT ON identity_lifecycle_history + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_history_insert_guard_v1(); +CREATE CONSTRAINT TRIGGER authorization_operation_receipt_history_cardinality + AFTER INSERT ON authorization_operation_receipts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_history_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_lifecycle_transition_integrity + AFTER INSERT ON identity_lifecycle_history + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_transition_integrity_guard_v1(); + +CREATE TRIGGER identity_lifecycle_selector_insert_guard + BEFORE INSERT ON identity_lifecycle_selectors + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_selector_insert_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_lifecycle_selector_history_semantics + AFTER INSERT ON identity_lifecycle_selectors + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_selector_history_guard_v1(); +CREATE CONSTRAINT TRIGGER identity_lifecycle_selector_transition_integrity + AFTER INSERT ON identity_lifecycle_selectors + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION identity_lifecycle_transition_integrity_guard_v1(); + +CREATE TRIGGER authorization_operation_receipts_immutable + BEFORE UPDATE OR DELETE ON authorization_operation_receipts + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_operation_receipts_no_truncate + BEFORE TRUNCATE ON authorization_operation_receipts + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER identity_enrollment_policies_immutable + BEFORE UPDATE OR DELETE ON identity_enrollment_policies + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER identity_enrollment_policies_no_truncate + BEFORE TRUNCATE ON identity_enrollment_policies + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER identity_lifecycle_history_immutable + BEFORE UPDATE OR DELETE ON identity_lifecycle_history + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER identity_lifecycle_history_no_truncate + BEFORE TRUNCATE ON identity_lifecycle_history + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER identity_lifecycle_selectors_immutable + BEFORE UPDATE OR DELETE ON identity_lifecycle_selectors + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER identity_lifecycle_selectors_no_truncate + BEFORE TRUNCATE ON identity_lifecycle_selectors + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + + +-- ============================================================================ +-- NIP-FI final-admission foundation (mirror of migration 0041). +-- ============================================================================ + +CREATE TABLE authorization_invalidation_domains ( + community_id UUID NOT NULL PRIMARY KEY REFERENCES communities(id), + current_generation BIGINT NOT NULL CHECK (current_generation >= 0), + activated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp() +); + +-- Closed selectors: 1 principal, 2 Nostr key, 3 binding, 4 session, 5 domain, +-- 6 configuration revision. Selector 7 (delegated relationship) and its +-- relationship-revision floor are deferred to the FI-DELEG migration. +CREATE TABLE authorization_invalidation_floors ( + community_id UUID NOT NULL REFERENCES communities(id), + selector_kind SMALLINT NOT NULL CHECK (selector_kind IN (1, 2, 3, 4, 5, 6)), + selector_fingerprint BYTEA NOT NULL CHECK (octet_length(selector_fingerprint) = 32), + floor_generation BIGINT NOT NULL CHECK (floor_generation > 0), + binding_version_floor BIGINT CHECK (binding_version_floor IS NULL OR binding_version_floor > 0), + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, selector_kind, selector_fingerprint), + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED, + CHECK ( + (selector_kind = 3 AND binding_version_floor IS NOT NULL) + OR (selector_kind <> 3 AND binding_version_floor IS NULL) + ) +); + +-- Protected-object kinds: 1 domain, 2 channel, 3 repository, 4 media, +-- 5 moderation target, 6 audio session. Kind 7 is retired: current binding +-- status is connection-local evidence and never a durable protected object. +CREATE TABLE authorization_authority_epochs ( + community_id UUID NOT NULL REFERENCES communities(id), + object_kind SMALLINT NOT NULL CHECK (object_kind IN (1, 2, 3, 4, 5, 6)), + object_key BYTEA NOT NULL CHECK (octet_length(object_key) = 32), + authority_epoch BIGINT NOT NULL CHECK (authority_epoch > 0), + fence BYTEA NOT NULL CHECK ( + octet_length(fence) = 32 AND fence <> decode(repeat('00', 32), 'hex') + ), + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, object_kind, object_key), + UNIQUE ( + community_id, + object_kind, + object_key, + authority_epoch, + fence, + operation_id, + request_fingerprint + ), + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED +); + +-- Direct-final current authority for a protected object. The authorization +-- lease itself is sealed in memory and dies on restart; this durable row is the +-- exact source re-fenced immediately before a protected mutation or emission. +CREATE TABLE protected_object_authority ( + community_id UUID NOT NULL REFERENCES communities(id), + object_kind SMALLINT NOT NULL CHECK (object_kind IN (1, 2, 3, 4, 5, 6)), + object_key BYTEA NOT NULL CHECK (octet_length(object_key) = 32), + capability SMALLINT NOT NULL CHECK ( + capability IN ( + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, + 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, + 28, 29 + ) + ), + actor_pubkey BYTEA NOT NULL CHECK (octet_length(actor_pubkey) = 32), + binding_id UUID NOT NULL, + binding_version BIGINT NOT NULL CHECK (binding_version > 0), + policy_revision BIGINT NOT NULL CHECK (policy_revision > 0), + invalidation_generation BIGINT NOT NULL CHECK (invalidation_generation >= 0), + authority_epoch BIGINT NOT NULL CHECK (authority_epoch > 0), + fence BYTEA NOT NULL CHECK ( + octet_length(fence) = 32 AND fence <> decode(repeat('00', 32), 'hex') + ), + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + PRIMARY KEY (community_id, object_kind, object_key), + FOREIGN KEY (community_id, binding_id, binding_version) + REFERENCES identity_bindings (community_id, binding_id, binding_version) + DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY ( + community_id, + object_kind, + object_key, + authority_epoch, + fence, + operation_id, + request_fingerprint + ) REFERENCES authorization_authority_epochs ( + community_id, + object_kind, + object_key, + authority_epoch, + fence, + operation_id, + request_fingerprint + ) DEFERRABLE INITIALLY DEFERRED, + CHECK (issued_at < expires_at) +); + +-- Explicit immutable-capacity policy required by Enforce mode. Hard ceilings +-- match buzz-auth; installation limits must be sized explicitly below them. +-- V1 has no online pruning/export/reset workflow. +CREATE TABLE authorization_event_capacity ( + community_id UUID NOT NULL PRIMARY KEY REFERENCES communities(id), + max_events_per_domain BIGINT NOT NULL CONSTRAINT authorization_event_capacity_max_events CHECK ( + max_events_per_domain BETWEEN 1 AND 10000 + ), + max_bytes_per_domain BIGINT NOT NULL CONSTRAINT authorization_event_capacity_max_bytes CHECK ( + max_bytes_per_domain BETWEEN 1 AND 16777216 + ), + max_envelope_bytes INTEGER NOT NULL CONSTRAINT authorization_event_capacity_max_envelope CHECK ( + max_envelope_bytes BETWEEN 1 AND 16384 + ), + retained_event_count BIGINT NOT NULL DEFAULT 0 CHECK (retained_event_count >= 0), + retained_envelope_bytes BIGINT NOT NULL DEFAULT 0 CHECK (retained_envelope_bytes >= 0), + -- 1 healthy, 2 audit unavailable/exhausted. Recovery/reset is not a V1 + -- online workflow; enabled runtime latches failure when insertion aborts. + health_state SMALLINT NOT NULL DEFAULT 1 CHECK (health_state IN (1, 2)), + failure_code SMALLINT CHECK (failure_code IS NULL OR failure_code IN (1, 2, 3)), + failure_observed_at TIMESTAMPTZ, + configured_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + CHECK (max_envelope_bytes <= max_bytes_per_domain), + CHECK (retained_event_count <= max_events_per_domain), + CHECK (retained_envelope_bytes <= max_bytes_per_domain), + CHECK ( + (health_state = 1 AND failure_code IS NULL AND failure_observed_at IS NULL) + OR (health_state = 2 AND failure_code IS NOT NULL AND failure_observed_at IS NOT NULL) + ) +); + +-- Durable versioned pseudonymous authorization envelope. event_kind: +-- 1 enrolled, 2 revoked, 3 rotated, 6 retired, 9 operator denied, +-- 10 protected allowed, 11 protected denied, 14 invalidation advanced. +-- The extended-lifecycle audit kinds (4 recovered, 5 principal enabled, +-- 7 principal disabled, 8 admission lost) are deferred to the FI-LIFECYCLE +-- migration, matching 0040's core lifecycle carve. Kinds 12 and 13 are +-- retired: kind 24244 publication/withdrawal is ephemeral connection state and +-- never a durable authorization event. +CREATE TABLE authorization_events ( + community_id UUID NOT NULL REFERENCES communities(id), + event_id UUID NOT NULL, + schema_version SMALLINT NOT NULL DEFAULT 1 CHECK (schema_version = 1), + event_kind SMALLINT NOT NULL CHECK ( + event_kind IN (1, 2, 3, 6, 9, 10, 11, 14) + ), + outcome_code SMALLINT NOT NULL CHECK (outcome_code IN (1, 2, 3, 4, 5)), + reason_code SMALLINT NOT NULL CHECK ( + reason_code IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) + ), + actor_kind SMALLINT NOT NULL CHECK (actor_kind IN (1, 2, 3, 4)), + actor_fingerprint BYTEA CHECK ( + actor_fingerprint IS NULL OR octet_length(actor_fingerprint) = 32 + ), + subject_fingerprint BYTEA CHECK ( + subject_fingerprint IS NULL OR octet_length(subject_fingerprint) = 32 + ), + -- Always retains attempted operation identity. Only unresolved pre-auth + -- event kind 9 omits the canonical receipt fingerprint; authenticated + -- OperatorDenied events remain linked to their exact canonical receipt. + operation_id UUID NOT NULL, + request_fingerprint BYTEA CHECK ( + request_fingerprint IS NULL OR octet_length(request_fingerprint) = 32 + ), + correlation_id UUID NOT NULL, + attempt_id UUID NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL, + accepted_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + canonical_envelope BYTEA NOT NULL CONSTRAINT authorization_events_envelope_size CHECK ( + octet_length(canonical_envelope) BETWEEN 1 AND 16384 + ), + envelope_digest BYTEA NOT NULL CHECK (octet_length(envelope_digest) = 32), + PRIMARY KEY (community_id, event_id), + UNIQUE (community_id, event_id, operation_id), + UNIQUE (community_id, event_id, event_kind, operation_id), + UNIQUE (community_id, operation_id, event_kind, attempt_id), + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED, + CHECK (event_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (correlation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (attempt_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK ( + (actor_kind = 4 AND event_kind = 9 AND request_fingerprint IS NULL) + OR (actor_kind IN (1, 2, 3) AND request_fingerprint IS NOT NULL) + ), + CHECK ( + (actor_kind = 4 AND actor_fingerprint IS NULL AND subject_fingerprint IS NULL) + OR (actor_kind IN (1, 2, 3) AND actor_fingerprint IS NOT NULL) + ) +); + +-- Credential-free pre-authentication denial attempts. The five-column key is +-- exact replay identity; no row or FK occupies canonical operation/result, +-- effect, authority, approval, or consumption state. +CREATE TABLE authorization_authentication_denial_attempts ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + correlation_id UUID NOT NULL, + semantic_fingerprint BYTEA NOT NULL CHECK (octet_length(semantic_fingerprint) = 32), + denial_reason SMALLINT NOT NULL CHECK (denial_reason IN (1, 2, 3)), + expected_revision BIGINT NOT NULL CHECK (expected_revision > 0), + action SMALLINT NOT NULL CHECK (action IN (1, 2, 3, 4, 5, 6, 7, 8)), + reason_code SMALLINT NOT NULL CHECK ( + reason_code IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) + ), + audit_event_id UUID NOT NULL, + audit_event_kind SMALLINT NOT NULL DEFAULT 9 CHECK (audit_event_kind = 9), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY ( + community_id, + operation_id, + correlation_id, + semantic_fingerprint, + denial_reason + ), + UNIQUE (community_id, audit_event_id), + FOREIGN KEY (community_id, audit_event_id, audit_event_kind, operation_id) + REFERENCES authorization_events (community_id, event_id, event_kind, operation_id) + DEFERRABLE INITIALLY DEFERRED, + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (correlation_id <> '00000000-0000-0000-0000-000000000000'::uuid) +); + +-- Exact per-operation authority-version attribution for restore. Empty +-- manifests are valid; every stored component must advance strictly. +CREATE TABLE authorization_operation_version_delta_manifests ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + component_count INTEGER NOT NULL CHECK (component_count BETWEEN 0 AND 1024), + before_digest BYTEA NOT NULL CHECK (octet_length(before_digest) = 32), + after_digest BYTEA NOT NULL CHECK (octet_length(after_digest) = 32), + manifest_digest BYTEA NOT NULL CHECK (octet_length(manifest_digest) = 32), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, operation_id), + UNIQUE (community_id, operation_id, request_fingerprint), + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED +); + +-- component_kind: 1 binding version, 2 policy revision, +-- 3 invalidation generation, 4 authority epoch. Kind 6 (delegated-relationship +-- revision) is deferred to the FI-DELEG migration and kind 7 (lifecycle-selector +-- generation) to the FI-LIFECYCLE migration. Kind 5 is retired with durable +-- client-status revisions; retained kinds keep their original identities. +CREATE TABLE authorization_operation_version_deltas ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + component_kind SMALLINT NOT NULL CHECK (component_kind IN (1, 2, 3, 4)), + component_key BYTEA NOT NULL CHECK (octet_length(component_key) = 32), + before_version BIGINT NOT NULL CHECK (before_version >= 0), + after_version BIGINT NOT NULL, + component_digest BYTEA NOT NULL CHECK (octet_length(component_digest) = 32), + PRIMARY KEY (community_id, operation_id, component_kind, component_key), + FOREIGN KEY (community_id, operation_id) + REFERENCES authorization_operation_version_delta_manifests + (community_id, operation_id), + CHECK (after_version > before_version) +); + +CREATE FUNCTION authorization_event_capacity_before_insert_v1() RETURNS TRIGGER AS $$ +DECLARE + policy authorization_event_capacity%ROWTYPE; + envelope_bytes BIGINT; +BEGIN + SELECT * INTO policy + FROM authorization_event_capacity + WHERE community_id = NEW.community_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'authorization event capacity policy missing' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_event_capacity_policy_required'; + END IF; + IF policy.health_state <> 1 THEN + RAISE EXCEPTION 'authorization audit is unavailable' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_event_capacity_health'; + END IF; + + envelope_bytes := octet_length(NEW.canonical_envelope); + IF envelope_bytes > policy.max_envelope_bytes + OR policy.retained_event_count + 1 > policy.max_events_per_domain + OR policy.retained_envelope_bytes + envelope_bytes > policy.max_bytes_per_domain + THEN + -- The INSERT and protected mutation abort together. The runtime maps + -- this stable constraint to typed CapacityExhausted and latches audit + -- health outside the rolled-back transaction. + RAISE EXCEPTION 'authorization event capacity exhausted' + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_event_capacity_exhausted'; + END IF; + + UPDATE authorization_event_capacity + SET retained_event_count = retained_event_count + 1, + retained_envelope_bytes = retained_envelope_bytes + envelope_bytes, + updated_at = transaction_timestamp() + WHERE community_id = NEW.community_id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_events_capacity + BEFORE INSERT ON authorization_events + FOR EACH ROW EXECUTE FUNCTION authorization_event_capacity_before_insert_v1(); + +CREATE FUNCTION authorization_invalidation_domain_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.activated_at IS DISTINCT FROM OLD.activated_at + OR NEW.current_generation <= OLD.current_generation + OR NEW.updated_at <= OLD.updated_at + THEN + RAISE EXCEPTION 'authorization invalidation activation/generation cannot move backward' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_invalidation_domains_monotonic + BEFORE UPDATE ON authorization_invalidation_domains + FOR EACH ROW EXECUTE FUNCTION authorization_invalidation_domain_guard_v1(); +CREATE TRIGGER authorization_invalidation_domains_no_delete + BEFORE DELETE ON authorization_invalidation_domains + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_invalidation_domains_no_truncate + BEFORE TRUNCATE ON authorization_invalidation_domains + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE FUNCTION authorization_invalidation_floor_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.selector_kind IS DISTINCT FROM OLD.selector_kind + OR NEW.selector_fingerprint IS DISTINCT FROM OLD.selector_fingerprint + OR NEW.floor_generation < OLD.floor_generation + OR COALESCE(NEW.binding_version_floor, 0) < COALESCE(OLD.binding_version_floor, 0) + OR COALESCE(NEW.relationship_revision_floor, 0) + < COALESCE(OLD.relationship_revision_floor, 0) + OR ( + NEW.floor_generation = OLD.floor_generation + AND COALESCE(NEW.binding_version_floor, 0) + = COALESCE(OLD.binding_version_floor, 0) + AND COALESCE(NEW.relationship_revision_floor, 0) + = COALESCE(OLD.relationship_revision_floor, 0) + ) + OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id + OR NEW.updated_at <= OLD.updated_at + THEN + RAISE EXCEPTION 'authorization invalidation floor cannot move backward' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_invalidation_floors_monotonic + BEFORE UPDATE ON authorization_invalidation_floors + FOR EACH ROW EXECUTE FUNCTION authorization_invalidation_floor_guard_v1(); +CREATE TRIGGER authorization_invalidation_floors_no_delete + BEFORE DELETE ON authorization_invalidation_floors + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_invalidation_floors_no_truncate + BEFORE TRUNCATE ON authorization_invalidation_floors + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE FUNCTION authorization_authority_epoch_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.object_kind IS DISTINCT FROM OLD.object_kind + OR NEW.object_key IS DISTINCT FROM OLD.object_key + OR NEW.authority_epoch <= OLD.authority_epoch + OR NEW.fence IS NOT DISTINCT FROM OLD.fence + OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id + OR NEW.updated_at <= OLD.updated_at + THEN + RAISE EXCEPTION 'authorization authority epoch cannot move backward' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_authority_epochs_monotonic + BEFORE UPDATE ON authorization_authority_epochs + FOR EACH ROW EXECUTE FUNCTION authorization_authority_epoch_guard_v1(); +CREATE TRIGGER authorization_authority_epochs_no_delete + BEFORE DELETE ON authorization_authority_epochs + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_authority_epochs_no_truncate + BEFORE TRUNCATE ON authorization_authority_epochs + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE FUNCTION authorization_event_capacity_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.max_events_per_domain IS DISTINCT FROM OLD.max_events_per_domain + OR NEW.max_bytes_per_domain IS DISTINCT FROM OLD.max_bytes_per_domain + OR NEW.max_envelope_bytes IS DISTINCT FROM OLD.max_envelope_bytes + OR NEW.configured_at IS DISTINCT FROM OLD.configured_at + OR NEW.retained_event_count < OLD.retained_event_count + OR NEW.retained_envelope_bytes < OLD.retained_envelope_bytes + OR NEW.updated_at < OLD.updated_at + OR (OLD.health_state = 2 AND ( + NEW.health_state <> 2 + OR NEW.failure_code IS DISTINCT FROM OLD.failure_code + OR NEW.failure_observed_at IS DISTINCT FROM OLD.failure_observed_at + )) + OR (OLD.health_state = 1 AND NEW.health_state = 1 AND ( + NEW.failure_code IS NOT NULL OR NEW.failure_observed_at IS NOT NULL + )) + THEN + RAISE EXCEPTION 'authorization event capacity cannot be reset online' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION protected_object_authority_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.object_kind IS DISTINCT FROM OLD.object_kind + OR NEW.object_key IS DISTINCT FROM OLD.object_key + OR NEW.authority_epoch <= OLD.authority_epoch + OR NEW.fence IS NOT DISTINCT FROM OLD.fence + OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id + OR NEW.issued_at <= OLD.issued_at + THEN + RAISE EXCEPTION 'protected authority replacement requires a new operation and epoch' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_event_capacity_monotonic + BEFORE UPDATE ON authorization_event_capacity + FOR EACH ROW EXECUTE FUNCTION authorization_event_capacity_guard_v1(); +CREATE TRIGGER authorization_event_capacity_no_delete + BEFORE DELETE ON authorization_event_capacity + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_event_capacity_no_truncate + BEFORE TRUNCATE ON authorization_event_capacity + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER authorization_events_immutable + BEFORE UPDATE OR DELETE ON authorization_events + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_events_no_truncate + BEFORE TRUNCATE ON authorization_events + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER authorization_authentication_denial_attempts_immutable + BEFORE UPDATE OR DELETE ON authorization_authentication_denial_attempts + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_authentication_denial_attempts_no_truncate + BEFORE TRUNCATE ON authorization_authentication_denial_attempts + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE FUNCTION authorization_operation_version_delta_cardinality_guard_v1() +RETURNS TRIGGER AS $$ +DECLARE + manifest authorization_operation_version_delta_manifests%ROWTYPE; + actual_component_count BIGINT; +BEGIN + IF TG_TABLE_NAME = 'authorization_operation_version_delta_manifests' THEN + manifest := NEW; + ELSE + SELECT * INTO STRICT manifest + FROM authorization_operation_version_delta_manifests + WHERE community_id = NEW.community_id + AND operation_id = NEW.operation_id + FOR NO KEY UPDATE; + END IF; + + SELECT count(*) INTO actual_component_count + FROM authorization_operation_version_deltas + WHERE community_id = manifest.community_id + AND operation_id = manifest.operation_id; + + IF actual_component_count <> manifest.component_count THEN + RAISE EXCEPTION 'operation version manifest declares % components, found %', + manifest.component_count, actual_component_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_operation_version_delta_cardinality'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE CONSTRAINT TRIGGER authorization_operation_version_delta_manifest_cardinality + AFTER INSERT ON authorization_operation_version_delta_manifests + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operation_version_delta_cardinality_guard_v1(); +CREATE CONSTRAINT TRIGGER authorization_operation_version_delta_component_cardinality + AFTER INSERT ON authorization_operation_version_deltas + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operation_version_delta_cardinality_guard_v1(); + +CREATE TRIGGER authorization_operation_version_delta_manifests_immutable + BEFORE UPDATE OR DELETE ON authorization_operation_version_delta_manifests + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_operation_version_delta_manifests_no_truncate + BEFORE TRUNCATE ON authorization_operation_version_delta_manifests + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER authorization_operation_version_deltas_immutable + BEFORE UPDATE OR DELETE ON authorization_operation_version_deltas + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_operation_version_deltas_no_truncate + BEFORE TRUNCATE ON authorization_operation_version_deltas + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE TRIGGER protected_object_authority_no_delete + BEFORE DELETE ON protected_object_authority + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER protected_object_authority_no_truncate + BEFORE TRUNCATE ON protected_object_authority + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); +CREATE TRIGGER protected_object_authority_strict_replacement + BEFORE UPDATE ON protected_object_authority + FOR EACH ROW EXECUTE FUNCTION protected_object_authority_guard_v1(); + +-- Canonical admission keeps its complete logical intent and the closed, +-- credential-free application result beside the immutable receipt. This is +-- what lets an identical request replay reconstruct the same typed result +-- without repeating membership or other application DML. Object kinds match +-- protected_object_authority: 1 domain, 2 channel, 3 repository, 4 media, +-- 5 moderation target, 6 audio session. +CREATE TABLE authorization_admission_results ( + community_id UUID NOT NULL, + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + semantic_fingerprint BYTEA NOT NULL CHECK ( + octet_length(semantic_fingerprint) = 32 + AND semantic_fingerprint <> decode(repeat('00', 32), 'hex') + ), + object_kind SMALLINT NOT NULL CHECK (object_kind BETWEEN 1 AND 6), + object_key BYTEA NOT NULL CHECK ( + octet_length(object_key) = 32 + AND object_key <> decode(repeat('00', 32), 'hex') + ), + application_type BYTEA CHECK ( + application_type IS NULL + OR (octet_length(application_type) = 32 + AND application_type <> decode(repeat('00', 32), 'hex')) + ), + application_version SMALLINT CHECK (application_version > 0), + application_code SMALLINT CHECK (application_code > 0), + application_payload BYTEA CHECK ( + application_payload IS NULL OR octet_length(application_payload) <= 4096 + ), + application_intent_digest BYTEA CHECK ( + application_intent_digest IS NULL + OR (octet_length(application_intent_digest) = 32 + AND application_intent_digest <> decode(repeat('00', 32), 'hex')) + ), + application_effect_digest BYTEA CHECK ( + application_effect_digest IS NULL + OR (octet_length(application_effect_digest) = 32 + AND application_effect_digest <> decode(repeat('00', 32), 'hex')) + ), + application_result_digest BYTEA CHECK ( + application_result_digest IS NULL + OR (octet_length(application_result_digest) = 32 + AND application_result_digest <> decode(repeat('00', 32), 'hex')) + ), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, operation_id), + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint), + CHECK ( + (application_type IS NULL + AND application_version IS NULL + AND application_code IS NULL + AND application_payload IS NULL + AND application_intent_digest IS NULL + AND application_effect_digest IS NULL + AND application_result_digest IS NULL) + OR (application_type IS NOT NULL + AND application_version IS NOT NULL + AND application_code IS NOT NULL + AND application_payload IS NOT NULL + AND application_intent_digest IS NOT NULL + AND application_effect_digest IS NOT NULL + AND application_result_digest IS NOT NULL) + ) +); + +CREATE TRIGGER authorization_admission_results_no_update + BEFORE UPDATE OR DELETE ON authorization_admission_results + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_admission_results_no_truncate + BEFORE TRUNCATE ON authorization_admission_results + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +-- Every successful/no-op core lifecycle receipt has exactly one privacy-safe +-- audit event with the closed transition-kind mapping. Both directions are +-- deferred so receipt, history, event, selectors, and binding may be inserted +-- in any order inside one transaction but can never commit partially. The +-- extended-lifecycle operation kinds (2 provision, 4 disable, 7 recover, +-- 8 enable, 9 admission loss) and their event kinds arrive with the +-- FI-LIFECYCLE migration; here the mapping covers only enroll/retire/revoke/ +-- rotate. Non-lifecycle receipts (protected mutation, invalidation) carry no +-- audit-event cardinality requirement. +CREATE FUNCTION authorization_operation_receipt_event_guard_v1() +RETURNS TRIGGER AS $$ +DECLARE + receipt authorization_operation_receipts%ROWTYPE; + expected_event_kind SMALLINT; + matching_event_count BIGINT; + expected_event_count BIGINT; +BEGIN + IF TG_TABLE_NAME = 'authorization_operation_receipts' THEN + receipt := NEW; + ELSE + SELECT * INTO receipt + FROM authorization_operation_receipts + WHERE community_id = NEW.community_id + AND operation_id = NEW.operation_id; + IF NOT FOUND THEN + -- Credential-free pre-authentication denials intentionally have no + -- canonical receipt. Their separate FK/shape guards still run. + RETURN NULL; + END IF; + END IF; + + expected_event_kind := CASE receipt.operation_kind + WHEN 1 THEN 1 -- enroll + WHEN 3 THEN 6 -- retire + WHEN 5 THEN 2 -- revoke + WHEN 6 THEN 3 -- rotate + ELSE NULL + END; + IF expected_event_kind IS NULL THEN + RETURN NULL; + END IF; + + SELECT + count(*), + count(*) FILTER (WHERE event_kind = expected_event_kind) + INTO matching_event_count, expected_event_count + FROM authorization_events + WHERE community_id = receipt.community_id + AND operation_id = receipt.operation_id + AND request_fingerprint = receipt.request_fingerprint; + + IF matching_event_count <> 1 OR expected_event_count <> 1 THEN + RAISE EXCEPTION + 'lifecycle receipt requires exactly one event kind %, found % total and % expected', + expected_event_kind, matching_event_count, expected_event_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_operation_receipt_event_cardinality'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE CONSTRAINT TRIGGER authorization_operation_receipt_event_cardinality + AFTER INSERT ON authorization_operation_receipts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_event_guard_v1(); + +CREATE CONSTRAINT TRIGGER authorization_event_receipt_cardinality + AFTER INSERT ON authorization_events + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_event_guard_v1(); + From bfd9d3725279689683892e97139f61cc81aaeb16 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Fri, 28 Aug 2026 13:32:42 -0400 Subject: [PATCH 02/15] fix(buzz-db): drop stale relationship_revision_floor from invalidation guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authorization_invalidation_floor_guard_v1 trigger compared NEW/OLD.relationship_revision_floor, but authorization_invalidation_floors has no such column — a later FI-DELEG field correctly trimmed from the Phase-A table when mining, yet left in the guard body. PL/pgSQL defers record-field resolution, so the function CREATEs and all catalog/parity tests pass, but the first real monotonic floor advancement aborts with 'record NEW has no field relationship_revision_floor', making the floor update path unusable. Remove both comparisons from the migration and its byte-matched schema.sql mirror, and add a behavioral regression test that advances a floor through the live trigger (forward generation and binding_version_floor commit; equal/regressive updates reject) — coverage a deferred PL/pgSQL failure structurally evades in catalog tests. Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> --- crates/buzz-db/src/runtime/migration.rs | 132 ++++++++++++++++++ .../0042_nip_fi_authorization_foundation.sql | 4 - schema/schema.sql | 4 - 3 files changed, 132 insertions(+), 8 deletions(-) diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 10068eaaa44..430301d3b55 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -2838,4 +2838,136 @@ mod tests { "expected immutability rejection, got: {rejected}" ); } + + /// NIP-FI monotonic invalidation-floor advancement must actually run + /// through the `BEFORE UPDATE` guard. PL/pgSQL defers record-field + /// resolution to execution, so a guard that references a column absent from + /// its Phase-A table passes every catalog/parity test yet aborts the first + /// real advancement. This test exercises live UPDATEs: legitimate forward + /// moves on `floor_generation` and `binding_version_floor` must commit, and + /// equal/regressive moves must be rejected. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn authorization_invalidation_floor_advances_through_guard() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(41, &pool) + .await + .expect("apply migrations 1-41"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("floor-guard-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + // Each floor state points at an operation receipt via + // (community_id, operation_id, request_fingerprint). Seed one receipt + // per operation the test advances through. + let operations: [(uuid::Uuid, u8); 4] = [ + (uuid::Uuid::new_v4(), 0x11), + (uuid::Uuid::new_v4(), 0x22), + (uuid::Uuid::new_v4(), 0x33), + (uuid::Uuid::new_v4(), 0x44), + ]; + for (operation_id, fp_byte) in operations { + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 12, $4, 1, $5)", + ) + .bind(community_id) + .bind(operation_id) + .bind(vec![fp_byte; 32]) + .bind(vec![0xAA_u8; 32]) + .bind(vec![0xBB_u8; 32]) + .execute(&pool) + .await + .expect("seed operation receipt"); + } + + // selector_kind 3 requires binding_version_floor, so this row exercises + // both monotonic dimensions the guard still governs. + let selector_fingerprint = vec![0xCC_u8; 32]; + sqlx::query( + "INSERT INTO authorization_invalidation_floors \ + (community_id, selector_kind, selector_fingerprint, floor_generation, \ + binding_version_floor, operation_id, request_fingerprint, updated_at) \ + VALUES ($1, 3, $2, 1, 1, $3, $4, '2026-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(&selector_fingerprint) + .bind(operations[0].0) + .bind(vec![operations[0].1; 32]) + .execute(&pool) + .await + .expect("insert initial invalidation floor"); + + let advance = + |generation: i64, binding_floor: i64, op_index: usize, updated_at: &'static str| { + sqlx::query( + "UPDATE authorization_invalidation_floors \ + SET floor_generation = $1, binding_version_floor = $2, \ + operation_id = $3, request_fingerprint = $4, updated_at = $5::timestamptz \ + WHERE community_id = $6 AND selector_kind = 3 AND selector_fingerprint = $7", + ) + .bind(generation) + .bind(binding_floor) + .bind(operations[op_index].0) + .bind(vec![operations[op_index].1; 32]) + .bind(updated_at) + .bind(community_id) + .bind(selector_fingerprint.clone()) + .execute(&pool) + }; + + // Forward generation advance commits. + advance(2, 1, 1, "2026-01-01T00:01:00Z") + .await + .expect("forward floor_generation advance must pass the guard"); + + // Forward binding_version_floor advance commits (generation unchanged). + advance(2, 2, 2, "2026-01-01T00:02:00Z") + .await + .expect("forward binding_version_floor advance must pass the guard"); + + // Regressive generation is rejected. + let regressive = advance(1, 2, 3, "2026-01-01T00:03:00Z") + .await + .expect_err("regressive floor_generation must be rejected"); + assert!( + regressive.to_string().contains("cannot move backward"), + "expected monotonic rejection, got: {regressive}" + ); + + // Equal floors with only a new operation is a rejected no-op advance. + let no_op = advance(2, 2, 3, "2026-01-01T00:03:00Z") + .await + .expect_err("equal-floor no-op advance must be rejected"); + assert!( + no_op.to_string().contains("cannot move backward"), + "expected no-op rejection, got: {no_op}" + ); + + // The committed state reflects only the two accepted advances. + let (generation, binding_floor): (i64, i64) = sqlx::query_as( + "SELECT floor_generation, binding_version_floor \ + FROM authorization_invalidation_floors \ + WHERE community_id = $1 AND selector_kind = 3 AND selector_fingerprint = $2", + ) + .bind(community_id) + .bind(&selector_fingerprint) + .fetch_one(&pool) + .await + .expect("read final floor state"); + assert_eq!( + (generation, binding_floor), + (2, 2), + "only the accepted forward advances may persist" + ); + } } diff --git a/migrations/0042_nip_fi_authorization_foundation.sql b/migrations/0042_nip_fi_authorization_foundation.sql index b83469dd811..586b34d9e9c 100644 --- a/migrations/0042_nip_fi_authorization_foundation.sql +++ b/migrations/0042_nip_fi_authorization_foundation.sql @@ -381,14 +381,10 @@ BEGIN OR NEW.selector_fingerprint IS DISTINCT FROM OLD.selector_fingerprint OR NEW.floor_generation < OLD.floor_generation OR COALESCE(NEW.binding_version_floor, 0) < COALESCE(OLD.binding_version_floor, 0) - OR COALESCE(NEW.relationship_revision_floor, 0) - < COALESCE(OLD.relationship_revision_floor, 0) OR ( NEW.floor_generation = OLD.floor_generation AND COALESCE(NEW.binding_version_floor, 0) = COALESCE(OLD.binding_version_floor, 0) - AND COALESCE(NEW.relationship_revision_floor, 0) - = COALESCE(OLD.relationship_revision_floor, 0) ) OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id OR NEW.updated_at <= OLD.updated_at diff --git a/schema/schema.sql b/schema/schema.sql index 445b6d02bd1..36968b46971 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -3120,14 +3120,10 @@ BEGIN OR NEW.selector_fingerprint IS DISTINCT FROM OLD.selector_fingerprint OR NEW.floor_generation < OLD.floor_generation OR COALESCE(NEW.binding_version_floor, 0) < COALESCE(OLD.binding_version_floor, 0) - OR COALESCE(NEW.relationship_revision_floor, 0) - < COALESCE(OLD.relationship_revision_floor, 0) OR ( NEW.floor_generation = OLD.floor_generation AND COALESCE(NEW.binding_version_floor, 0) = COALESCE(OLD.binding_version_floor, 0) - AND COALESCE(NEW.relationship_revision_floor, 0) - = COALESCE(OLD.relationship_revision_floor, 0) ) OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id OR NEW.updated_at <= OLD.updated_at From 469e382549db61d725eab3e2481291fd5977fc93 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Fri, 28 Aug 2026 16:17:10 -0400 Subject: [PATCH 03/15] fix(buzz-db): narrow identity_bindings policy FK and add provenance regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FK on identity_bindings previously equated binding_provenance with the enrollment policy's enrollment_mode via a composite reference: (community_id, policy_revision, binding_provenance) → identity_enrollment_policies (community_id, policy_revision, enrollment_mode) This contract-breaks NIP-FI §352 and §424: provenance is determined from operation evidence, not the policy mode. A TOFU-mode policy (mode=3) with an attested-key binding (provenance=1) — a valid and specified admission path — would fail at commit with a FK violation. Narrow the FK to (community_id, policy_revision) → (community_id, policy_revision), which is already the PK of identity_enrollment_policies. The redundant UNIQUE (community_id, policy_revision, enrollment_mode) on identity_enrollment_policies is removed; it existed only to satisfy the old composite FK and has no other consumer. Both changes applied in lockstep to the migration and the schema.sql mirror. The parity assertion in admin_schema_parity_between_desired_state_and_migrations continues to hold. Add behavioral regression identity_binding_provenance_is_independent_of_enrollment_mode: seeds a TOFU-mode policy, inserts an attested-key binding in a single deferred transaction, and asserts the commit succeeds with provenance=1 and mode=3 persisted independently. Mutation-verified: restoring the composite FK causes the test to fail with the exact FK violation (code 23503) the fix removes. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> --- crates/buzz-db/src/runtime/migration.rs | 151 ++++++++++++++++++ .../0041_nip_fi_identity_foundation.sql | 5 +- schema/schema.sql | 5 +- 3 files changed, 155 insertions(+), 6 deletions(-) diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 430301d3b55..f187105f49c 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -2970,4 +2970,155 @@ mod tests { "only the accepted forward advances may persist" ); } + + /// NIP-FI identity FK contract: a binding's provenance is determined from + /// operation evidence and is independent of the enrollment policy's mode. + /// The corrected FK references only `(community_id, policy_revision)`; + /// the original composite FK `(community_id, policy_revision, + /// binding_provenance) → (community_id, policy_revision, enrollment_mode)` + /// would have rejected valid admissions such as TOFU policy + + /// attested-key provenance (NIP-FI.md §352, §424). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn identity_binding_provenance_is_independent_of_enrollment_mode() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(40, &pool) + .await + .expect("apply migrations 1-40"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("provenance-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + // Enrollment policy: mode 3 (TOFU). + let policy_revision: i64 = 1; + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, $2, 3, $3, '2026-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(policy_revision) + .bind(vec![0xA0_u8; 32]) // policy_digest + .execute(&pool) + .await + .expect("insert TOFU enrollment policy"); + + // Insert a binding with provenance 1 (attested-key) under the TOFU + // policy. The circular deferred FK between identity_bindings and + // identity_lifecycle_history requires both to be committed in one + // transaction; all cross-table FKs in this pair are DEFERRABLE + // INITIALLY DEFERRED. A pinned connection is required so that BEGIN + // and each subsequent statement share the same session/transaction. + let binding_id = uuid::Uuid::new_v4(); + let history_id = uuid::Uuid::new_v4(); + let operation_id = uuid::Uuid::new_v4(); + let request_fingerprint = vec![0xAB_u8; 32]; + + let mut conn = pool.acquire().await.expect("acquire connection"); + + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin"); + + // Enrollment history must be inserted BEFORE the operation receipt: + // authorization_operation_receipt_history_guard_v1 fires AFTER INSERT + // on authorization_operation_receipts and checks that lifecycle receipts + // already have exactly one history row. The history → receipt FK is + // DEFERRABLE INITIALLY DEFERRED, so this order is safe. + sqlx::query( + "INSERT INTO identity_lifecycle_history \ + (community_id, history_id, transition_kind, outcome_code, \ + successor_binding_id, successor_binding_version, \ + successor_lifecycle_revision, successor_state, \ + operation_id, request_fingerprint, transition_digest) \ + VALUES ($1, $2, 1, 1, $3, 1, 1, 1, $4, $5, $6)", + ) + .bind(community_id) + .bind(history_id) + .bind(binding_id) + .bind(operation_id) + .bind(&request_fingerprint) + .bind(vec![0xAE_u8; 32]) + .execute(&mut *conn) + .await + .expect("insert lifecycle history"); + + // Operation receipt: kind 1 (enroll), outcome 1 (applied). + // The receipt_history_cardinality trigger fires here and validates the + // history row inserted above. + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 1, $4, 1, $5)", + ) + .bind(community_id) + .bind(operation_id) + .bind(&request_fingerprint) + .bind(vec![0xAC_u8; 32]) + .bind(vec![0xAD_u8; 32]) + .execute(&mut *conn) + .await + .expect("insert operation receipt"); + + // Binding: provenance 1 (attested-key) under TOFU-mode policy. + // Before the FK fix this INSERT would fail at commit with a FK + // violation because 1 (attested-key) ≠ 3 (TOFU mode). + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, binding_id, issuer, subject, \ + principal_fingerprint, event_author_pubkey, \ + binding_state, lifecycle_revision, binding_provenance, \ + policy_revision, enrollment_evidence_digest, \ + birth_history_id, creation_operation_id, \ + creation_request_fingerprint) \ + VALUES ($1, $2, 'https://issuer.example', 'sub-01', \ + $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", + ) + .bind(community_id) + .bind(binding_id) + .bind(vec![0xAF_u8; 32]) // principal_fingerprint + .bind(vec![0xB0_u8; 32]) // event_author_pubkey + .bind(policy_revision) + .bind(vec![0xB1_u8; 32]) // enrollment_evidence_digest + .bind(history_id) + .bind(operation_id) + .bind(&request_fingerprint) + .execute(&mut *conn) + .await + .expect("insert binding"); + + sqlx::query("COMMIT") + .execute(&mut *conn) + .await + .expect("attested-key binding under TOFU policy must commit — FK is on (community_id, policy_revision) only"); + + // Confirm the binding persisted with provenance 1, policy mode 3. + let (stored_provenance, stored_mode): (i16, i16) = sqlx::query_as( + "SELECT b.binding_provenance, p.enrollment_mode \ + FROM identity_bindings b \ + JOIN identity_enrollment_policies p \ + ON p.community_id = b.community_id AND p.policy_revision = b.policy_revision \ + WHERE b.community_id = $1 AND b.binding_id = $2", + ) + .bind(community_id) + .bind(binding_id) + .fetch_one(&pool) + .await + .expect("read persisted binding"); + assert_eq!(stored_provenance, 1, "provenance must be attested-key (1)"); + assert_eq!(stored_mode, 3, "enrollment mode must be TOFU (3)"); + assert_ne!( + stored_provenance, stored_mode, + "provenance and mode are independent: they must differ here" + ); + } } diff --git a/migrations/0041_nip_fi_identity_foundation.sql b/migrations/0041_nip_fi_identity_foundation.sql index e4980babf57..b2311a18f3c 100644 --- a/migrations/0041_nip_fi_identity_foundation.sql +++ b/migrations/0041_nip_fi_identity_foundation.sql @@ -61,7 +61,6 @@ CREATE TABLE identity_enrollment_policies ( expires_at TIMESTAMPTZ, recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), PRIMARY KEY (community_id, policy_revision), - UNIQUE (community_id, policy_revision, enrollment_mode), CHECK (expires_at IS NULL OR effective_at < expires_at) ); @@ -103,9 +102,9 @@ CREATE TABLE identity_bindings ( PRIMARY KEY (community_id, binding_id), UNIQUE (community_id, binding_version), UNIQUE (community_id, binding_id, binding_version), - FOREIGN KEY (community_id, policy_revision, binding_provenance) + FOREIGN KEY (community_id, policy_revision) REFERENCES identity_enrollment_policies - (community_id, policy_revision, enrollment_mode), + (community_id, policy_revision), CHECK (binding_version > 0), CHECK (binding_id <> '00000000-0000-0000-0000-000000000000'::uuid), CHECK (birth_history_id <> '00000000-0000-0000-0000-000000000000'::uuid), diff --git a/schema/schema.sql b/schema/schema.sql index 36968b46971..c6c5c4edb81 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -1956,7 +1956,6 @@ CREATE TABLE identity_enrollment_policies ( expires_at TIMESTAMPTZ, recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), PRIMARY KEY (community_id, policy_revision), - UNIQUE (community_id, policy_revision, enrollment_mode), CHECK (expires_at IS NULL OR effective_at < expires_at) ); @@ -1998,9 +1997,9 @@ CREATE TABLE identity_bindings ( PRIMARY KEY (community_id, binding_id), UNIQUE (community_id, binding_version), UNIQUE (community_id, binding_id, binding_version), - FOREIGN KEY (community_id, policy_revision, binding_provenance) + FOREIGN KEY (community_id, policy_revision) REFERENCES identity_enrollment_policies - (community_id, policy_revision, enrollment_mode), + (community_id, policy_revision), CHECK (binding_version > 0), CHECK (binding_id <> '00000000-0000-0000-0000-000000000000'::uuid), CHECK (birth_history_id <> '00000000-0000-0000-0000-000000000000'::uuid), From 15989f768aff4f9792043067665cf1f3fef6826e Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Fri, 28 Aug 2026 17:56:39 -0400 Subject: [PATCH 04/15] test(buzz-db): add absent-policy FK rejection to provenance regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends identity_binding_provenance_is_independent_of_enrollment_mode with the negative half required for two-sided mutation sensitivity. A second deferred transaction inserts an otherwise-valid identity_bindings row referencing policy_revision 999 (nonexistent in identity_enrollment_policies) and asserts the INSERT fails with SQLSTATE 23503 from the narrowed FK identity_bindings(community_id, policy_revision) → identity_enrollment_policies(community_id, policy_revision). Non-vacuity verified: removing the FK from the migration causes the absent-policy INSERT to succeed (rows_affected: 1) and the expect_err assertion to fire, confirming the negative half detects a dropped or neutered FK. The existing positive half catches the old composite FK; together they give full two-sided coverage. Zero production changes: migrations 0040/0041 and schema.sql are byte-untouched. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> --- crates/buzz-db/src/runtime/migration.rs | 97 +++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index f187105f49c..77afeb57542 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -3120,5 +3120,102 @@ mod tests { stored_provenance, stored_mode, "provenance and mode are independent: they must differ here" ); + + // --- Negative half: absent policy revision --- + // + // Two-sided mutation sensitivity requires that a FK dropped or neutered + // entirely is also detected. A second otherwise-valid deferred + // transaction uses a nonexistent policy_revision (999) and must fail + // with SQLSTATE 23503 — the narrowed FK + // identity_bindings(community_id, policy_revision) + // → identity_enrollment_policies(community_id, policy_revision) + // rejects the row. This FK is not deferred, so it fires at INSERT + // time; a `COMMIT` is unnecessary and not reached. If the FK were + // absent the INSERT would succeed and this assertion would catch the + // regression. + let absent_binding_id = uuid::Uuid::new_v4(); + let absent_history_id = uuid::Uuid::new_v4(); + let absent_operation_id = uuid::Uuid::new_v4(); + let absent_fp = vec![0xC0_u8; 32]; + let nonexistent_policy_revision: i64 = 999; + + let mut conn2 = pool.acquire().await.expect("acquire second connection"); + + sqlx::query("BEGIN") + .execute(&mut *conn2) + .await + .expect("begin absent-policy transaction"); + + // History first (receipt_history_cardinality guard fires on receipt + // insert and requires the history row to already exist). + sqlx::query( + "INSERT INTO identity_lifecycle_history \ + (community_id, history_id, transition_kind, outcome_code, \ + successor_binding_id, successor_binding_version, \ + successor_lifecycle_revision, successor_state, \ + operation_id, request_fingerprint, transition_digest) \ + VALUES ($1, $2, 1, 1, $3, 2, 1, 1, $4, $5, $6)", + ) + .bind(community_id) + .bind(absent_history_id) + .bind(absent_binding_id) + .bind(absent_operation_id) + .bind(&absent_fp) + .bind(vec![0xC1_u8; 32]) + .execute(&mut *conn2) + .await + .expect("insert absent-policy lifecycle history"); + + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 1, $4, 1, $5)", + ) + .bind(community_id) + .bind(absent_operation_id) + .bind(&absent_fp) + .bind(vec![0xC2_u8; 32]) + .bind(vec![0xC3_u8; 32]) + .execute(&mut *conn2) + .await + .expect("insert absent-policy operation receipt"); + + // The policy FK is not deferred; it fires at INSERT, not COMMIT. + let absent_policy_err = sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, binding_id, issuer, subject, \ + principal_fingerprint, event_author_pubkey, \ + binding_state, lifecycle_revision, binding_provenance, \ + policy_revision, enrollment_evidence_digest, \ + birth_history_id, creation_operation_id, \ + creation_request_fingerprint) \ + VALUES ($1, $2, 'https://issuer.example', 'sub-02', \ + $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", + ) + .bind(community_id) + .bind(absent_binding_id) + .bind(vec![0xC4_u8; 32]) // principal_fingerprint (unique, different from first binding) + .bind(vec![0xC5_u8; 32]) // event_author_pubkey (unique, different from first binding) + .bind(nonexistent_policy_revision) + .bind(vec![0xC6_u8; 32]) // enrollment_evidence_digest + .bind(absent_history_id) + .bind(absent_operation_id) + .bind(&absent_fp) + .execute(&mut *conn2) + .await + .expect_err("binding with nonexistent policy_revision must be rejected by the FK"); + assert!( + absent_policy_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23503")) + .unwrap_or(false), + "expected FK violation (23503) for absent policy_revision, got: {absent_policy_err}" + ); + + sqlx::query("ROLLBACK") + .execute(&mut *conn2) + .await + .expect("rollback absent-policy transaction"); } } From 81d5992bb188301629d920eb38d2af0d4beee63a Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Fri, 28 Aug 2026 18:09:54 -0400 Subject: [PATCH 05/15] =?UTF-8?q?chore(buzz-db):=20renumber=20NIP-FI=20mig?= =?UTF-8?q?rations=200040/0041=20=E2=86=92=200041/0042?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main landed 0040_push_message_kinds.sql (#6269) which collides with the previous NIP-FI numbering. Renumber: 0040_nip_fi_identity_foundation.sql → 0041 0041_nip_fi_authorization_foundation.sql → 0042 Update all test references, run_to() calls, and schema.sql comments to match. The push_match_trigger test (migrations[39].version == 40) is unchanged — it covers the push-notification migration at 0040, not NIP-FI. Signed-off-by: Will Pfleger Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> --- crates/buzz-db/src/runtime/migration.rs | 8 ++++---- migrations/0042_nip_fi_authorization_foundation.sql | 4 ++-- schema/schema.sql | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 77afeb57542..10f66bfb22b 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -2852,9 +2852,9 @@ mod tests { let pool = connect_test_pool().await; reset_public_schema(&pool).await; MIGRATOR - .run_to(41, &pool) + .run_to(42, &pool) .await - .expect("apply migrations 1-41"); + .expect("apply migrations 1-42"); let community_id = uuid::Uuid::new_v4(); sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") @@ -2984,9 +2984,9 @@ mod tests { let pool = connect_test_pool().await; reset_public_schema(&pool).await; MIGRATOR - .run_to(40, &pool) + .run_to(41, &pool) .await - .expect("apply migrations 1-40"); + .expect("apply migrations 1-41"); let community_id = uuid::Uuid::new_v4(); sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") diff --git a/migrations/0042_nip_fi_authorization_foundation.sql b/migrations/0042_nip_fi_authorization_foundation.sql index 586b34d9e9c..633d1150708 100644 --- a/migrations/0042_nip_fi_authorization_foundation.sql +++ b/migrations/0042_nip_fi_authorization_foundation.sql @@ -4,7 +4,7 @@ -- audio admission ledger, 30382 projection, delivery queue, exporter claim, -- acknowledgement, retry scheduler, or online retention/compaction workflow. -- --- This migration applies to migration 0040's resulting state. Its scope is the +-- This migration applies to migration 0041's resulting state. Its scope is the -- NIP-FI *final-admission* surface: replay/receipt, audit events, invalidation, -- capacity, protected-object authority, restore version deltas, and the closed -- admission result. Closed vocabularies below carry only the core subset; @@ -716,7 +716,7 @@ CREATE CONSTRAINT TRIGGER authorization_event_receipt_cardinality DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_event_guard_v1(); --- Same ledger posture as migration 0040's identity relations: the admission, +-- Same ledger posture as migration 0041's identity relations: the admission, -- replay, audit, and invalidation relations below are append-only denial and -- authority facts protected by immutable no_delete/no_truncate triggers, so -- they carry community_id as provenance rather than deletable ownership. Widen diff --git a/schema/schema.sql b/schema/schema.sql index c6c5c4edb81..537ea283e4a 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -1904,7 +1904,7 @@ INSERT INTO _operator_global_tables (table_name, reason) VALUES -- ============================================================================ --- NIP-FI core identity + base-lifecycle foundation (mirror of migration 0040). +-- NIP-FI core identity + base-lifecycle foundation (mirror of migration 0041). -- The community_write_fence_excluded_table definition above already folds in -- the NIP-FI ledger relations; the per-migration CREATE OR REPLACE bodies are -- intentionally omitted here (desired state keeps one consolidated definition). @@ -2751,7 +2751,7 @@ CREATE TRIGGER identity_lifecycle_selectors_no_truncate -- ============================================================================ --- NIP-FI final-admission foundation (mirror of migration 0041). +-- NIP-FI final-admission foundation (mirror of migration 0042). -- ============================================================================ CREATE TABLE authorization_invalidation_domains ( @@ -2906,7 +2906,7 @@ CREATE TABLE authorization_event_capacity ( -- 10 protected allowed, 11 protected denied, 14 invalidation advanced. -- The extended-lifecycle audit kinds (4 recovered, 5 principal enabled, -- 7 principal disabled, 8 admission lost) are deferred to the FI-LIFECYCLE --- migration, matching 0040's core lifecycle carve. Kinds 12 and 13 are +-- migration, matching 0041's core lifecycle carve. Kinds 12 and 13 are -- retired: kind 24244 publication/withdrawal is ephemeral connection state and -- never a durable authorization event. CREATE TABLE authorization_events ( From 1016f0a57ef6ee055c2a68da75c5e9640911f0bd Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Sat, 29 Aug 2026 14:03:30 -0400 Subject: [PATCH 06/15] =?UTF-8?q?fix(buzz-db):=20add=20NIP-FI=20Carl=20r2?= =?UTF-8?q?=20guards=20=E2=80=94=20policy=20monotonicity,=20admission=20re?= =?UTF-8?q?sult=20cardinality,=20denial=20attempt=20binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 1 (policy revision monotonicity): add identity_enrollment_policy_revision_guard_v1() BEFORE INSERT on identity_enrollment_policies. Uses a per-community advisory lock via hashtextextended so concurrent writers serialize the max-revision read, then asserts both policy_revision and effective_at strictly exceed the current community maximum (FI-INV-06 — stable assertion policy). Finding 2 (admission result ↔ kind-11 receipt cardinality): add authorization_admission_result_guard_v1(), bidirectional deferred constraint trigger on both authorization_operation_receipts (kind-11 receipt must have exactly one result) and authorization_admission_results (result must attach to a kind-11 receipt). Mirrors the pattern of the existing authorization_operation_receipt_event_guard_v1. Finding 3 (denial event ↔ attempt binding): add authorization_denial_attempt_guard_v1(), bidirectional deferred constraint trigger on both authorization_events (kind-9 event must have exactly one denial attempt) and authorization_authentication_denial_attempts (attempt must reference an existing kind-9 event). The existing FK binds (audit_event_kind=9) but does not require a kind-9 event to have a matching attempt row; this guard closes that gap. All three fixes applied identically in migrations/0041, migrations/0042, and schema/schema.sql; the parity assertion continues to pass. Tests added (all three mutation-sensitive, two-sided): - identity_enrollment_policy_revision_is_monotonic - authorization_admission_result_requires_kind_11_receipt_bidirectional - authorization_denial_attempt_requires_kind_9_event_bidirectional No new tables; fence exclusion list unchanged; #[ignore] deletion suite need not rerun. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> --- crates/buzz-db/src/runtime/migration.rs | 493 ++++++++++++++++++ .../0041_nip_fi_identity_foundation.sql | 53 ++ .../0042_nip_fi_authorization_foundation.sql | 148 ++++++ schema/schema.sql | 201 +++++++ 4 files changed, 895 insertions(+) diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 10f66bfb22b..36382475368 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -3218,4 +3218,497 @@ mod tests { .await .expect("rollback absent-policy transaction"); } + + /// NIP-FI policy-revision monotonicity: each new policy revision for a + /// community must strictly exceed the current maximum revision, and its + /// effective_at must strictly exceed the current maximum effective_at + /// (FI-INV-06 — stable assertion policy). + /// + /// Mutation sensitivity is two-sided: + /// - neutering the guard lets a replayed or backfilled revision through + /// (the positive half detects insertion into a guarded table); + /// - leaving the guard intact rejects equal/regressive inserts (negative + /// halves detect that each rejection fires). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn identity_enrollment_policy_revision_is_monotonic() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(41, &pool) + .await + .expect("apply migrations 1-41"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("policy-mono-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + // First insertion: no prior rows — should always succeed. + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 1, 1, $2, '2026-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA1_u8; 32]) + .execute(&pool) + .await + .expect("first policy insertion (revision 1) must succeed"); + + // Forward advance: revision 2 with effective_at strictly after revision 1. + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 2, 1, $2, '2026-06-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA2_u8; 32]) + .execute(&pool) + .await + .expect("forward advance to revision 2 must succeed"); + + // Negative: replay the same revision (2 <= 2). + let replay_err = sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 2, 1, $2, '2027-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA3_u8; 32]) + .execute(&pool) + .await + .expect_err("replayed revision must be rejected"); + assert!( + replay_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) for replayed revision, got: {replay_err}" + ); + + // Negative: backfill a lower revision (1 < 2). + let backfill_err = sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 1, 2, $2, '2027-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA4_u8; 32]) + .execute(&pool) + .await + .expect_err("backfilled lower revision must be rejected"); + assert!( + backfill_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) for backfilled revision, got: {backfill_err}" + ); + + // Negative: higher revision but effective_at not strictly after max. + let stale_time_err = sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 3, 1, $2, '2026-06-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA5_u8; 32]) + .execute(&pool) + .await + .expect_err("equal effective_at must be rejected even with higher revision"); + assert!( + stale_time_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) for non-advancing effective_at, got: {stale_time_err}" + ); + + // Confirm only revisions 1 and 2 persisted. + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM identity_enrollment_policies WHERE community_id = $1", + ) + .bind(community_id) + .fetch_one(&pool) + .await + .expect("count persisted policy revisions"); + assert_eq!(count, 2, "only the two accepted revisions must persist"); + } + + /// NIP-FI admission-result ↔ kind-11 receipt cardinality: a kind-11 + /// (protected-mutation) receipt must commit with exactly one admission + /// result; an admission result must commit against a kind-11 receipt. + /// + /// Mutation sensitivity is two-sided: + /// - the guard is load-bearing when a kind-11 receipt has no result row + /// (negative A) — without the guard this commits silently; + /// - the guard is load-bearing when a result attaches to a non-kind-11 + /// receipt (negative B) — without the guard this commits silently. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn authorization_admission_result_requires_kind_11_receipt_bidirectional() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("adm-result-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + // Capacity must exist for authorization_events inserts; admission-result + // tests exercise only authorization_operation_receipts and + // authorization_admission_results — no authorization_events rows are + // needed here, but insert capacity anyway to satisfy any trigger + // that reads the policy row defensively. + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, 1000, 16777216, 16384)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("insert event capacity"); + + let mut conn = pool.acquire().await.expect("acquire connection"); + + // --- Positive: kind-11 receipt + admission result in one transaction --- + let op1 = uuid::Uuid::new_v4(); + let fp1 = vec![0xB1_u8; 32]; + + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin"); + + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 11, $4, 1, $5)", + ) + .bind(community_id) + .bind(op1) + .bind(&fp1) + .bind(vec![0xB2_u8; 32]) + .bind(vec![0xB3_u8; 32]) + .execute(&mut *conn) + .await + .expect("insert kind-11 receipt"); + + sqlx::query( + "INSERT INTO authorization_admission_results \ + (community_id, operation_id, request_fingerprint, semantic_fingerprint, \ + object_kind, object_key) \ + VALUES ($1, $2, $3, $4, 1, $5)", + ) + .bind(community_id) + .bind(op1) + .bind(&fp1) + .bind(vec![0xB4_u8; 32]) // semantic_fingerprint + .bind(vec![0xB5_u8; 32]) // object_key + .execute(&mut *conn) + .await + .expect("insert admission result"); + + sqlx::query("COMMIT") + .execute(&mut *conn) + .await + .expect("kind-11 receipt + result must commit"); + drop(conn); + + // --- Negative A: kind-11 receipt without result must be rejected --- + let op2 = uuid::Uuid::new_v4(); + let fp2 = vec![0xC1_u8; 32]; + + let mut conn_a = pool.acquire().await.expect("acquire connection A"); + sqlx::query("BEGIN") + .execute(&mut *conn_a) + .await + .expect("begin"); + + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 11, $4, 1, $5)", + ) + .bind(community_id) + .bind(op2) + .bind(&fp2) + .bind(vec![0xC2_u8; 32]) + .bind(vec![0xC3_u8; 32]) + .execute(&mut *conn_a) + .await + .expect("insert kind-11 receipt for negative A"); + + let no_result_err = sqlx::query("COMMIT") + .execute(&mut *conn_a) + .await + .expect_err("kind-11 receipt without result must be rejected at commit"); + assert!( + no_result_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) for kind-11 without result, got: {no_result_err}" + ); + drop(conn_a); + + // --- Negative B: admission result against non-kind-11 receipt --- + // Use operation_kind 12 (invalidation) — no admission result should + // ever attach to it. The guard fires at COMMIT (deferred trigger). + let op3 = uuid::Uuid::new_v4(); + let fp3 = vec![0xD1_u8; 32]; + + let mut conn_b = pool.acquire().await.expect("acquire connection B"); + sqlx::query("BEGIN") + .execute(&mut *conn_b) + .await + .expect("begin"); + + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 12, $4, 1, $5)", + ) + .bind(community_id) + .bind(op3) + .bind(&fp3) + .bind(vec![0xD2_u8; 32]) + .bind(vec![0xD3_u8; 32]) + .execute(&mut *conn_b) + .await + .expect("insert kind-12 receipt"); + + // The guard is deferred: the INSERT succeeds; the violation surfaces + // at COMMIT when the guard checks that the receipt is kind-11. + sqlx::query( + "INSERT INTO authorization_admission_results \ + (community_id, operation_id, request_fingerprint, semantic_fingerprint, \ + object_kind, object_key) \ + VALUES ($1, $2, $3, $4, 1, $5)", + ) + .bind(community_id) + .bind(op3) + .bind(&fp3) + .bind(vec![0xD4_u8; 32]) + .bind(vec![0xD5_u8; 32]) + .execute(&mut *conn_b) + .await + .expect("result insert must pass — deferred guard fires at commit, not here"); + + let wrong_kind_err = sqlx::query("COMMIT") + .execute(&mut *conn_b) + .await + .expect_err("result against non-kind-11 receipt must be rejected at commit"); + assert!( + wrong_kind_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) for result against non-kind-11 receipt, got: {wrong_kind_err}" + ); + } + + /// NIP-FI denial-attempt ↔ kind-9 event cardinality: a kind-9 + /// (pre-authentication denial) audit event must commit with exactly one + /// denial attempt; a denial attempt must commit with a matching kind-9 + /// audit event. + /// + /// Mutation sensitivity is two-sided: + /// - the event-side guard is load-bearing when a kind-9 event has no + /// attempt row (negative A) — without it this commits silently, making + /// replay reconstruction impossible; + /// - the attempt-side guard is load-bearing when an attempt has no + /// matching event at commit (negative B). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn authorization_denial_attempt_requires_kind_9_event_bidirectional() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("denial-attempt-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + // Capacity is required by the authorization_events BEFORE INSERT trigger. + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, 1000, 16777216, 16384)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("insert event capacity"); + + let mut conn = pool.acquire().await.expect("acquire connection"); + + // --- Positive: kind-9 event + denial attempt in one transaction --- + let op1 = uuid::Uuid::new_v4(); + let event1 = uuid::Uuid::new_v4(); + let corr1 = uuid::Uuid::new_v4(); + let attempt1_id = uuid::Uuid::new_v4(); + + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin"); + + // Insert denial attempt first (FK is deferred). + sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 1, 1, 1, 1, $5, 9)", + ) + .bind(community_id) + .bind(op1) + .bind(corr1) + .bind(vec![0xE1_u8; 32]) // semantic_fingerprint + .bind(event1) + .execute(&mut *conn) + .await + .expect("insert denial attempt before event"); + + // Insert the kind-9 event (actor_kind 4, no request_fingerprint). + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ + '2026-01-01T00:00:00Z', $6, $7)", + ) + .bind(community_id) + .bind(event1) + .bind(op1) + .bind(corr1) + .bind(attempt1_id) + .bind(vec![0xE2_u8; 64]) // canonical_envelope (≤16384 bytes) + .bind(vec![0xE3_u8; 32]) // envelope_digest + .execute(&mut *conn) + .await + .expect("insert kind-9 event"); + + sqlx::query("COMMIT") + .execute(&mut *conn) + .await + .expect("kind-9 event + denial attempt must commit"); + drop(conn); + + // --- Negative A: kind-9 event alone must be rejected at commit --- + let op2 = uuid::Uuid::new_v4(); + let event2 = uuid::Uuid::new_v4(); + let corr2 = uuid::Uuid::new_v4(); + let attempt2_id = uuid::Uuid::new_v4(); + + let mut conn_a = pool.acquire().await.expect("acquire connection A"); + sqlx::query("BEGIN") + .execute(&mut *conn_a) + .await + .expect("begin"); + + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ + '2026-01-01T00:00:00Z', $6, $7)", + ) + .bind(community_id) + .bind(event2) + .bind(op2) + .bind(corr2) + .bind(attempt2_id) + .bind(vec![0xF1_u8; 64]) + .bind(vec![0xF2_u8; 32]) + .execute(&mut *conn_a) + .await + .expect("insert kind-9 event without attempt"); + + let no_attempt_err = sqlx::query("COMMIT") + .execute(&mut *conn_a) + .await + .expect_err("kind-9 event without denial attempt must be rejected at commit"); + assert!( + no_attempt_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) for kind-9 event without attempt, got: {no_attempt_err}" + ); + drop(conn_a); + + // --- Negative B: denial attempt without matching event at commit --- + // The denial attempt's deferred FK to authorization_events fires at + // commit, as does the guard's NOT FOUND branch. Either catches the + // absent event; the guard adds the kind-9 semantic check on top. + let op3 = uuid::Uuid::new_v4(); + let absent_event = uuid::Uuid::new_v4(); // never inserted + let corr3 = uuid::Uuid::new_v4(); + + let mut conn_b = pool.acquire().await.expect("acquire connection B"); + sqlx::query("BEGIN") + .execute(&mut *conn_b) + .await + .expect("begin"); + + sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 2, 1, 1, 2, $5, 9)", + ) + .bind(community_id) + .bind(op3) + .bind(corr3) + .bind(vec![0xFA_u8; 32]) + .bind(absent_event) + .execute(&mut *conn_b) + .await + .expect("insert denial attempt with absent event"); + + let no_event_err = sqlx::query("COMMIT") + .execute(&mut *conn_b) + .await + .expect_err("denial attempt without matching event must be rejected at commit"); + // Deferred FK (23503) or guard check_violation (23514) — either proves + // the absent event is caught. + assert!( + no_event_err + .as_database_error() + .map(|e| { + let code = e.code(); + let c = code.as_deref().unwrap_or(""); + c == "23503" || c == "23514" + }) + .unwrap_or(false), + "expected FK violation (23503) or check_violation (23514) for absent event, got: {no_event_err}" + ); + } } diff --git a/migrations/0041_nip_fi_identity_foundation.sql b/migrations/0041_nip_fi_identity_foundation.sql index b2311a18f3c..586f2312973 100644 --- a/migrations/0041_nip_fi_identity_foundation.sql +++ b/migrations/0041_nip_fi_identity_foundation.sql @@ -413,6 +413,56 @@ CREATE INDEX identity_lifecycle_selectors_asserted_history ON identity_lifecycle_selectors (community_id, asserted_history_id, selector_kind); +-- Serializes policy-revision inserts per community: each new revision must +-- strictly exceed the current maximum, and each effective_at must strictly +-- exceed the current maximum effective_at (FI-INV-06 — stable assertion +-- policy; a revision that moves either coordinate backward is incoherent). +-- The per-community advisory lock prevents two concurrent writers from both +-- passing a plain SELECT MAX() check and committing conflicting revisions. +CREATE FUNCTION identity_enrollment_policy_revision_guard_v1() RETURNS TRIGGER AS $$ +DECLARE + lock_key BIGINT; + max_revision BIGINT; + max_effective_at TIMESTAMPTZ; +BEGIN + -- Acquire a per-community exclusive transaction-scoped advisory lock so + -- that concurrent insertions serialize here. The key is a stable hash of + -- the namespace string and the community_id bytes. + lock_key := hashtextextended( + 'buzz:enrollment-policy-revision:v1:' || NEW.community_id::text, + 0 + ); + PERFORM pg_advisory_xact_lock(lock_key); + + SELECT MAX(policy_revision), MAX(effective_at) + INTO max_revision, max_effective_at + FROM identity_enrollment_policies + WHERE community_id = NEW.community_id; + + IF max_revision IS NOT NULL + AND NEW.policy_revision <= max_revision + THEN + RAISE EXCEPTION + 'policy_revision % does not strictly exceed current maximum % for community %', + NEW.policy_revision, max_revision, NEW.community_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_enrollment_policy_revision_monotonic'; + END IF; + + IF max_effective_at IS NOT NULL + AND NEW.effective_at <= max_effective_at + THEN + RAISE EXCEPTION + 'effective_at % does not strictly exceed current maximum % for community %', + NEW.effective_at, max_effective_at, NEW.community_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_enrollment_policy_revision_monotonic'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + CREATE FUNCTION nip_fi_reject_row_mutation_v1() RETURNS TRIGGER AS $$ BEGIN RAISE EXCEPTION '% is immutable', TG_TABLE_NAME @@ -833,6 +883,9 @@ CREATE TRIGGER authorization_operation_receipts_no_truncate BEFORE TRUNCATE ON authorization_operation_receipts FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); +CREATE TRIGGER identity_enrollment_policies_revision_guard + BEFORE INSERT ON identity_enrollment_policies + FOR EACH ROW EXECUTE FUNCTION identity_enrollment_policy_revision_guard_v1(); CREATE TRIGGER identity_enrollment_policies_immutable BEFORE UPDATE OR DELETE ON identity_enrollment_policies FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); diff --git a/migrations/0042_nip_fi_authorization_foundation.sql b/migrations/0042_nip_fi_authorization_foundation.sql index 633d1150708..b9403c7ae98 100644 --- a/migrations/0042_nip_fi_authorization_foundation.sql +++ b/migrations/0042_nip_fi_authorization_foundation.sql @@ -506,6 +506,85 @@ CREATE TRIGGER authorization_authentication_denial_attempts_no_truncate BEFORE TRUNCATE ON authorization_authentication_denial_attempts FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); +-- Bidirectional deferred guard: a kind-9 (pre-authentication denial) audit +-- event must commit with exactly one denial attempt; a denial attempt must +-- commit with its audit event present and kind-9. Both directions deferred so +-- event and attempt may be inserted in any order inside one transaction. +CREATE FUNCTION authorization_denial_attempt_guard_v1() +RETURNS TRIGGER AS $$ +DECLARE + found_event_kind SMALLINT; + attempt_count BIGINT; +BEGIN + IF TG_TABLE_NAME = 'authorization_events' THEN + -- Firing from the event side: only kind-9 events require a denial row. + IF NEW.event_kind <> 9 THEN + RETURN NULL; + END IF; + + SELECT count(*) INTO attempt_count + FROM authorization_authentication_denial_attempts + WHERE community_id = NEW.community_id + AND audit_event_id = NEW.event_id; + + IF attempt_count <> 1 THEN + RAISE EXCEPTION + 'kind-9 audit event requires exactly one denial attempt, found %', + attempt_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_event_cardinality'; + END IF; + ELSE + -- Firing from the denial-attempt side: verify the audit event is kind-9 + -- and that exactly one denial attempt references it. + SELECT event_kind INTO found_event_kind + FROM authorization_events + WHERE community_id = NEW.community_id + AND event_id = NEW.audit_event_id; + + IF NOT FOUND THEN + RAISE EXCEPTION + 'denial attempt references non-existent audit event %', + NEW.audit_event_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_event_kind'; + END IF; + + IF found_event_kind <> 9 THEN + RAISE EXCEPTION + 'denial attempt audit event must be kind 9, got %', + found_event_kind + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_event_kind'; + END IF; + + SELECT count(*) INTO attempt_count + FROM authorization_authentication_denial_attempts + WHERE community_id = NEW.community_id + AND audit_event_id = NEW.audit_event_id; + + IF attempt_count <> 1 THEN + RAISE EXCEPTION + 'exactly one denial attempt must reference audit event %, found %', + NEW.audit_event_id, attempt_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_event_cardinality'; + END IF; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE CONSTRAINT TRIGGER authorization_denial_attempt_event_cardinality + AFTER INSERT ON authorization_events + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_denial_attempt_guard_v1(); + +CREATE CONSTRAINT TRIGGER authorization_denial_event_attempt_cardinality + AFTER INSERT ON authorization_authentication_denial_attempts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_denial_attempt_guard_v1(); + CREATE FUNCTION authorization_operation_version_delta_cardinality_guard_v1() RETURNS TRIGGER AS $$ DECLARE @@ -644,6 +723,75 @@ CREATE TRIGGER authorization_admission_results_no_truncate BEFORE TRUNCATE ON authorization_admission_results FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); +-- Bidirectional deferred cardinality guard: a kind-11 (protected-mutation) +-- receipt must commit with exactly one admission result; an admission result +-- must commit against a kind-11 receipt. Deferred so receipt and result may +-- be inserted in any order inside one transaction. +CREATE FUNCTION authorization_admission_result_guard_v1() +RETURNS TRIGGER AS $$ +DECLARE + receipt authorization_operation_receipts%ROWTYPE; + result_count BIGINT; +BEGIN + IF TG_TABLE_NAME = 'authorization_operation_receipts' THEN + receipt := NEW; + ELSE + -- Firing from authorization_admission_results: look up the receipt. + SELECT * INTO receipt + FROM authorization_operation_receipts + WHERE community_id = NEW.community_id + AND operation_id = NEW.operation_id; + IF NOT FOUND THEN + -- FK on the result table already guards the non-existent receipt + -- case; this path should not occur in normal operation. + RAISE EXCEPTION + 'admission result references non-existent receipt for operation %', + NEW.operation_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_admission_result_receipt_kind'; + END IF; + END IF; + + -- Non-kind-11 receipts require no admission result. + IF receipt.operation_kind <> 11 THEN + -- If this fired from the result side and the receipt is not kind 11, + -- the result is attaching to the wrong receipt kind. + IF TG_TABLE_NAME = 'authorization_admission_results' THEN + RAISE EXCEPTION + 'admission result may only attach to a kind-11 (protected-mutation) receipt, got kind %', + receipt.operation_kind + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_admission_result_receipt_kind'; + END IF; + RETURN NULL; + END IF; + + SELECT count(*) INTO result_count + FROM authorization_admission_results + WHERE community_id = receipt.community_id + AND operation_id = receipt.operation_id; + + IF result_count <> 1 THEN + RAISE EXCEPTION + 'kind-11 receipt requires exactly one admission result, found %', + result_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_admission_result_cardinality'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE CONSTRAINT TRIGGER authorization_admission_result_receipt_cardinality + AFTER INSERT ON authorization_operation_receipts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_admission_result_guard_v1(); + +CREATE CONSTRAINT TRIGGER authorization_admission_result_result_cardinality + AFTER INSERT ON authorization_admission_results + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_admission_result_guard_v1(); + -- Every successful/no-op core lifecycle receipt has exactly one privacy-safe -- audit event with the closed transition-kind mapping. Both directions are -- deferred so receipt, history, event, selectors, and binding may be inserted diff --git a/schema/schema.sql b/schema/schema.sql index 537ea283e4a..de20d9a09f0 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -2308,6 +2308,56 @@ CREATE INDEX identity_lifecycle_selectors_asserted_history ON identity_lifecycle_selectors (community_id, asserted_history_id, selector_kind); +-- Serializes policy-revision inserts per community: each new revision must +-- strictly exceed the current maximum, and each effective_at must strictly +-- exceed the current maximum effective_at (FI-INV-06 — stable assertion +-- policy; a revision that moves either coordinate backward is incoherent). +-- The per-community advisory lock prevents two concurrent writers from both +-- passing a plain SELECT MAX() check and committing conflicting revisions. +CREATE FUNCTION identity_enrollment_policy_revision_guard_v1() RETURNS TRIGGER AS $$ +DECLARE + lock_key BIGINT; + max_revision BIGINT; + max_effective_at TIMESTAMPTZ; +BEGIN + -- Acquire a per-community exclusive transaction-scoped advisory lock so + -- that concurrent insertions serialize here. The key is a stable hash of + -- the namespace string and the community_id bytes. + lock_key := hashtextextended( + 'buzz:enrollment-policy-revision:v1:' || NEW.community_id::text, + 0 + ); + PERFORM pg_advisory_xact_lock(lock_key); + + SELECT MAX(policy_revision), MAX(effective_at) + INTO max_revision, max_effective_at + FROM identity_enrollment_policies + WHERE community_id = NEW.community_id; + + IF max_revision IS NOT NULL + AND NEW.policy_revision <= max_revision + THEN + RAISE EXCEPTION + 'policy_revision % does not strictly exceed current maximum % for community %', + NEW.policy_revision, max_revision, NEW.community_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_enrollment_policy_revision_monotonic'; + END IF; + + IF max_effective_at IS NOT NULL + AND NEW.effective_at <= max_effective_at + THEN + RAISE EXCEPTION + 'effective_at % does not strictly exceed current maximum % for community %', + NEW.effective_at, max_effective_at, NEW.community_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'identity_enrollment_policy_revision_monotonic'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + CREATE FUNCTION nip_fi_reject_row_mutation_v1() RETURNS TRIGGER AS $$ BEGIN RAISE EXCEPTION '% is immutable', TG_TABLE_NAME @@ -2728,6 +2778,9 @@ CREATE TRIGGER authorization_operation_receipts_no_truncate BEFORE TRUNCATE ON authorization_operation_receipts FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); +CREATE TRIGGER identity_enrollment_policies_revision_guard + BEFORE INSERT ON identity_enrollment_policies + FOR EACH ROW EXECUTE FUNCTION identity_enrollment_policy_revision_guard_v1(); CREATE TRIGGER identity_enrollment_policies_immutable BEFORE UPDATE OR DELETE ON identity_enrollment_policies FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); @@ -3244,6 +3297,85 @@ CREATE TRIGGER authorization_authentication_denial_attempts_no_truncate BEFORE TRUNCATE ON authorization_authentication_denial_attempts FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); +-- Bidirectional deferred guard: a kind-9 (pre-authentication denial) audit +-- event must commit with exactly one denial attempt; a denial attempt must +-- commit with its audit event present and kind-9. Both directions deferred so +-- event and attempt may be inserted in any order inside one transaction. +CREATE FUNCTION authorization_denial_attempt_guard_v1() +RETURNS TRIGGER AS $$ +DECLARE + found_event_kind SMALLINT; + attempt_count BIGINT; +BEGIN + IF TG_TABLE_NAME = 'authorization_events' THEN + -- Firing from the event side: only kind-9 events require a denial row. + IF NEW.event_kind <> 9 THEN + RETURN NULL; + END IF; + + SELECT count(*) INTO attempt_count + FROM authorization_authentication_denial_attempts + WHERE community_id = NEW.community_id + AND audit_event_id = NEW.event_id; + + IF attempt_count <> 1 THEN + RAISE EXCEPTION + 'kind-9 audit event requires exactly one denial attempt, found %', + attempt_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_event_cardinality'; + END IF; + ELSE + -- Firing from the denial-attempt side: verify the audit event is kind-9 + -- and that exactly one denial attempt references it. + SELECT event_kind INTO found_event_kind + FROM authorization_events + WHERE community_id = NEW.community_id + AND event_id = NEW.audit_event_id; + + IF NOT FOUND THEN + RAISE EXCEPTION + 'denial attempt references non-existent audit event %', + NEW.audit_event_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_event_kind'; + END IF; + + IF found_event_kind <> 9 THEN + RAISE EXCEPTION + 'denial attempt audit event must be kind 9, got %', + found_event_kind + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_event_kind'; + END IF; + + SELECT count(*) INTO attempt_count + FROM authorization_authentication_denial_attempts + WHERE community_id = NEW.community_id + AND audit_event_id = NEW.audit_event_id; + + IF attempt_count <> 1 THEN + RAISE EXCEPTION + 'exactly one denial attempt must reference audit event %, found %', + NEW.audit_event_id, attempt_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_event_cardinality'; + END IF; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE CONSTRAINT TRIGGER authorization_denial_attempt_event_cardinality + AFTER INSERT ON authorization_events + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_denial_attempt_guard_v1(); + +CREATE CONSTRAINT TRIGGER authorization_denial_event_attempt_cardinality + AFTER INSERT ON authorization_authentication_denial_attempts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_denial_attempt_guard_v1(); + CREATE FUNCTION authorization_operation_version_delta_cardinality_guard_v1() RETURNS TRIGGER AS $$ DECLARE @@ -3382,6 +3514,75 @@ CREATE TRIGGER authorization_admission_results_no_truncate BEFORE TRUNCATE ON authorization_admission_results FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); +-- Bidirectional deferred cardinality guard: a kind-11 (protected-mutation) +-- receipt must commit with exactly one admission result; an admission result +-- must commit against a kind-11 receipt. Deferred so receipt and result may +-- be inserted in any order inside one transaction. +CREATE FUNCTION authorization_admission_result_guard_v1() +RETURNS TRIGGER AS $$ +DECLARE + receipt authorization_operation_receipts%ROWTYPE; + result_count BIGINT; +BEGIN + IF TG_TABLE_NAME = 'authorization_operation_receipts' THEN + receipt := NEW; + ELSE + -- Firing from authorization_admission_results: look up the receipt. + SELECT * INTO receipt + FROM authorization_operation_receipts + WHERE community_id = NEW.community_id + AND operation_id = NEW.operation_id; + IF NOT FOUND THEN + -- FK on the result table already guards the non-existent receipt + -- case; this path should not occur in normal operation. + RAISE EXCEPTION + 'admission result references non-existent receipt for operation %', + NEW.operation_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_admission_result_receipt_kind'; + END IF; + END IF; + + -- Non-kind-11 receipts require no admission result. + IF receipt.operation_kind <> 11 THEN + -- If this fired from the result side and the receipt is not kind 11, + -- the result is attaching to the wrong receipt kind. + IF TG_TABLE_NAME = 'authorization_admission_results' THEN + RAISE EXCEPTION + 'admission result may only attach to a kind-11 (protected-mutation) receipt, got kind %', + receipt.operation_kind + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_admission_result_receipt_kind'; + END IF; + RETURN NULL; + END IF; + + SELECT count(*) INTO result_count + FROM authorization_admission_results + WHERE community_id = receipt.community_id + AND operation_id = receipt.operation_id; + + IF result_count <> 1 THEN + RAISE EXCEPTION + 'kind-11 receipt requires exactly one admission result, found %', + result_count + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_admission_result_cardinality'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE CONSTRAINT TRIGGER authorization_admission_result_receipt_cardinality + AFTER INSERT ON authorization_operation_receipts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_admission_result_guard_v1(); + +CREATE CONSTRAINT TRIGGER authorization_admission_result_result_cardinality + AFTER INSERT ON authorization_admission_results + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION authorization_admission_result_guard_v1(); + -- Every successful/no-op core lifecycle receipt has exactly one privacy-safe -- audit event with the closed transition-kind mapping. Both directions are -- deferred so receipt, history, event, selectors, and binding may be inserted From 12686a6cd9434648c2de2f2db7b5d7ee27f2a528 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Sat, 29 Aug 2026 14:47:08 -0400 Subject: [PATCH 07/15] fix(schema): address Thufir pass 1 blockers on NIP-FI PR 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three IMPORTANT findings fixed: 1. Drop effective_at monotonicity from policy revision guard identity_enrollment_policy_revision_guard_v1() now enforces only strict-greater policy_revision per community. The effective_at check had no NIP-FI basis (FI-INV-06 defines assertion_policy_id stability, not revision chronology) and would reject legitimately-sequenced revisions — the downstream constructor stamps every immediately- effective revision with Unix epoch, so revision 2 would fail after revision 1 under the old guard. 2. Bind semantic coordinates in denial attempt guard authorization_authentication_denial_attempts gains attempt_id UUID NOT NULL with a deferred FK to authorization_events on (community_id, operation_id, event_kind, attempt_id). The guard authorization_denial_attempt_guard_v1() now additionally compares correlation_id and reason_code between the event and its denial attempt row, raising check_violation (23514) with named constraint authorization_denial_attempt_semantic_binding on mismatch. This closes the Carl finding 3 gap: a kind-9 event for correlation A / reason X can no longer be paired with a denial row carrying correlation B / reason Y. 3. Rewrite regression tests to prove the contracts - Policy test: seeds a gap (100->101) then inserts unused revision 99 and asserts 23514 from the named guard (not 23505, which would fire on a PK duplicate and not prove the monotonic comparison). Adds a two-transaction concurrency regression: two distinct forward revisions (102, 103) race through separate connections; both commit because the advisory lock serializes them and each is valid. - Denial test: replaces the ambiguous negative-B (23503 OR 23514) with three single-coordinate-mismatch cases attributed to the named guard: B1 correlation_id mismatch (23514), B2 reason_code mismatch (23514), B3 attempt_id mismatch (23503 via deferred FK). - Admission test: adds negative C -- mismatched request_fingerprint rejected by the immediate composite FK (23503) at INSERT, proving the coordinate binding half of Carl finding 2. All four changed files byte-identical between migration files and schema/schema.sql (verified by extraction+cmp). All 5 NIP-FI tests, admin_schema_parity, and 2 unit tests pass. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> --- crates/buzz-db/src/runtime/migration.rs | 409 +++++++++++++++--- .../0041_nip_fi_identity_foundation.sql | 24 +- .../0042_nip_fi_authorization_foundation.sql | 55 ++- schema/schema.sql | 79 +++- 4 files changed, 461 insertions(+), 106 deletions(-) diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 36382475368..f0e718318a3 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -3259,7 +3259,7 @@ mod tests { .await .expect("first policy insertion (revision 1) must succeed"); - // Forward advance: revision 2 with effective_at strictly after revision 1. + // Forward advance: revision 2. sqlx::query( "INSERT INTO identity_enrollment_policies \ (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ @@ -3271,72 +3271,145 @@ mod tests { .await .expect("forward advance to revision 2 must succeed"); - // Negative: replay the same revision (2 <= 2). - let replay_err = sqlx::query( + // Seed a gap: skip from 2 to 100, then advance to 101. + sqlx::query( "INSERT INTO identity_enrollment_policies \ (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 2, 1, $2, '2027-01-01T00:00:00Z')", + VALUES ($1, 100, 1, $2, '2027-01-01T00:00:00Z')", ) .bind(community_id) .bind(vec![0xA3_u8; 32]) .execute(&pool) .await - .expect_err("replayed revision must be rejected"); - assert!( - replay_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "expected check_violation (23514) for replayed revision, got: {replay_err}" - ); + .expect("jump to revision 100 must succeed"); - // Negative: backfill a lower revision (1 < 2). - let backfill_err = sqlx::query( + sqlx::query( "INSERT INTO identity_enrollment_policies \ (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 1, 2, $2, '2027-01-01T00:00:00Z')", + VALUES ($1, 101, 1, $2, '2027-06-01T00:00:00Z')", ) .bind(community_id) .bind(vec![0xA4_u8; 32]) .execute(&pool) .await - .expect_err("backfilled lower revision must be rejected"); + .expect("advance to revision 101 must succeed"); + + // Negative: unused lower revision 99 — not a PK duplicate (never inserted), + // but the guard must reject it because 99 < MAX(100, 101). This is the + // case a plain PK constraint cannot catch; the named guard must fire. + let backfill_err = sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 99, 1, $2, '2028-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA5_u8; 32]) + .execute(&pool) + .await + .expect_err("unused lower revision 99 must be rejected by the guard"); assert!( backfill_err .as_database_error() .map(|e| e.code().as_deref() == Some("23514")) .unwrap_or(false), - "expected check_violation (23514) for backfilled revision, got: {backfill_err}" + "expected check_violation (23514) from identity_enrollment_policy_revision_monotonic \ + guard for backfilled revision 99, got: {backfill_err}" ); - // Negative: higher revision but effective_at not strictly after max. - let stale_time_err = sqlx::query( + // Negative: equal revision (101 <= 101) — different from a PK duplicate + // because we use a different policy_digest, so the PK is not violated; + // the guard still fires on the <= check. + let replay_err = sqlx::query( "INSERT INTO identity_enrollment_policies \ (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 3, 1, $2, '2026-06-01T00:00:00Z')", + VALUES ($1, 101, 2, $2, '2028-01-01T00:00:00Z')", ) .bind(community_id) - .bind(vec![0xA5_u8; 32]) + .bind(vec![0xA6_u8; 32]) .execute(&pool) .await - .expect_err("equal effective_at must be rejected even with higher revision"); + .expect_err("equal revision must be rejected"); + // PK (23505) fires before guard on exact duplicates, both prove the insert + // cannot commit; accept either code as evidence. assert!( - stale_time_err + replay_err .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) + .map(|e| { + let code = e.code(); + let c = code.as_deref().unwrap_or(""); + c == "23514" || c == "23505" + }) .unwrap_or(false), - "expected check_violation (23514) for non-advancing effective_at, got: {stale_time_err}" + "expected check_violation (23514) or unique_violation (23505) for replayed revision, \ + got: {replay_err}" + ); + + // Concurrency regression: race two distinct forward revisions (102 and 103) + // on separate connections. The per-community advisory lock must serialize + // them so that exactly one commits — not zero, not two. + // + // Strategy: begin both transactions before either acquires the lock, then + // commit them sequentially. The guard holds the lock for the duration of + // its transaction, so the second commit must succeed (not deadlock) because + // the first has already released. + let pool2 = pool.clone(); + let pool3 = pool.clone(); + let community_id2 = community_id; + + let (tx1_result, tx2_result) = tokio::join!( + tokio::spawn(async move { + let mut conn = pool.acquire().await.expect("acquire conn1"); + sqlx::query("BEGIN").execute(&mut *conn).await.expect("begin1"); + let r = sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 102, 1, $2, '2029-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xB1_u8; 32]) + .execute(&mut *conn) + .await; + let commit_r = sqlx::query("COMMIT").execute(&mut *conn).await; + (r, commit_r) + }), + tokio::spawn(async move { + // Small delay so tx1 tends to start first; not required for + // correctness — either order is valid under the guard. + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let mut conn = pool2.acquire().await.expect("acquire conn2"); + sqlx::query("BEGIN").execute(&mut *conn).await.expect("begin2"); + let r = sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 103, 1, $2, '2029-06-01T00:00:00Z')", + ) + .bind(community_id2) + .bind(vec![0xB2_u8; 32]) + .execute(&mut *conn) + .await; + let commit_r = sqlx::query("COMMIT").execute(&mut *conn).await; + (r, commit_r) + }), ); - // Confirm only revisions 1 and 2 persisted. + let (insert1, commit1) = tx1_result.expect("tx1 task completed"); + let (insert2, commit2) = tx2_result.expect("tx2 task completed"); + insert1.expect("tx1 INSERT must succeed (deferred guard at commit)"); + insert2.expect("tx2 INSERT must succeed (deferred guard at commit)"); + // Both distinct forward revisions should commit: the advisory lock + // serializes them, so both 102 and 103 are individually valid. + commit1.expect("tx1 COMMIT must succeed for distinct forward revision 102"); + commit2.expect("tx2 COMMIT must succeed for distinct forward revision 103"); + + // Confirm exactly 6 policy revisions are now present (1, 2, 100, 101, 102, 103). let count: i64 = sqlx::query_scalar( "SELECT count(*) FROM identity_enrollment_policies WHERE community_id = $1", ) .bind(community_id) - .fetch_one(&pool) + .fetch_one(&pool3) .await .expect("count persisted policy revisions"); - assert_eq!(count, 2, "only the two accepted revisions must persist"); + assert_eq!(count, 6, "all six accepted revisions must persist"); } /// NIP-FI admission-result ↔ kind-11 receipt cardinality: a kind-11 @@ -3521,6 +3594,66 @@ mod tests { .unwrap_or(false), "expected check_violation (23514) for result against non-kind-11 receipt, got: {wrong_kind_err}" ); + drop(conn_b); + + // --- Negative C: mismatched request_fingerprint rejected by composite FK --- + // The admission result table has an immediate composite FK + // (community_id, operation_id, request_fingerprint) + // REFERENCES authorization_operation_receipts(...) + // A result referencing a receipt that exists but with a different + // request_fingerprint must be rejected. This exercises the semantic half + // of Carl finding 2 — cardinality is handled by the deferred trigger; + // coordinate binding is handled by the structural FK. + let op4 = uuid::Uuid::new_v4(); + let fp4_receipt = vec![0xE1_u8; 32]; // fingerprint stored in the receipt + let fp4_wrong = vec![0xE2_u8; 32]; // wrong fingerprint used in the result + + let mut conn_c = pool.acquire().await.expect("acquire connection C"); + sqlx::query("BEGIN") + .execute(&mut *conn_c) + .await + .expect("begin"); + + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 11, $4, 1, $5)", + ) + .bind(community_id) + .bind(op4) + .bind(&fp4_receipt) + .bind(vec![0xE3_u8; 32]) + .bind(vec![0xE4_u8; 32]) + .execute(&mut *conn_c) + .await + .expect("insert kind-11 receipt for negative C"); + + // The admission result FK is immediate (not deferred), so the INSERT + // itself rejects a fingerprint with no matching receipt row. + let wrong_fp_err = sqlx::query( + "INSERT INTO authorization_admission_results \ + (community_id, operation_id, request_fingerprint, semantic_fingerprint, \ + object_kind, object_key) \ + VALUES ($1, $2, $3, $4, 1, $5)", + ) + .bind(community_id) + .bind(op4) + .bind(&fp4_wrong) // wrong fingerprint — no matching receipt row + .bind(vec![0xE5_u8; 32]) + .bind(vec![0xE6_u8; 32]) + .execute(&mut *conn_c) + .await + .expect_err("result with mismatched request_fingerprint must be rejected at INSERT"); + // Immediate composite FK fires as foreign_key_violation (23503). + assert!( + wrong_fp_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23503")) + .unwrap_or(false), + "expected foreign_key_violation (23503) for mismatched request_fingerprint, got: {wrong_fp_err}" + ); + sqlx::query("ROLLBACK").execute(&mut *conn_c).await.ok(); } /// NIP-FI denial-attempt ↔ kind-9 event cardinality: a kind-9 @@ -3532,8 +3665,9 @@ mod tests { /// - the event-side guard is load-bearing when a kind-9 event has no /// attempt row (negative A) — without it this commits silently, making /// replay reconstruction impossible; - /// - the attempt-side guard is load-bearing when an attempt has no - /// matching event at commit (negative B). + /// - the attempt-side guard is load-bearing for semantic mismatches (negatives + /// B1–B3) — the old deferred FK only checks event existence/kind and would + /// not catch a correlation, reason_code, or attempt_id mismatch. #[tokio::test] #[ignore = "requires Postgres"] async fn authorization_denial_attempt_requires_kind_9_event_bidirectional() { @@ -3576,18 +3710,19 @@ mod tests { .await .expect("begin"); - // Insert denial attempt first (FK is deferred). + // Insert denial attempt first (FKs are deferred). sqlx::query( "INSERT INTO authorization_authentication_denial_attempts \ (community_id, operation_id, correlation_id, semantic_fingerprint, \ denial_reason, expected_revision, action, reason_code, \ - audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 1, $5, 9)", + attempt_id, audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 1, 1, 1, 1, $5, $6, 9)", ) .bind(community_id) .bind(op1) .bind(corr1) .bind(vec![0xE1_u8; 32]) // semantic_fingerprint + .bind(attempt1_id) .bind(event1) .execute(&mut *conn) .await @@ -3663,52 +3798,198 @@ mod tests { ); drop(conn_a); - // --- Negative B: denial attempt without matching event at commit --- - // The denial attempt's deferred FK to authorization_events fires at - // commit, as does the guard's NOT FOUND branch. Either catches the - // absent event; the guard adds the kind-9 semantic check on top. - let op3 = uuid::Uuid::new_v4(); - let absent_event = uuid::Uuid::new_v4(); // never inserted - let corr3 = uuid::Uuid::new_v4(); + // --- Negatives B1-B3: semantic coordinate mismatches, each attributed to + // the named guard (23514), not the old deferred FK (23503). Each case + // inserts a valid event then a denial attempt that matches everywhere + // except one coordinate; the guard must fire for that mismatch. - let mut conn_b = pool.acquire().await.expect("acquire connection B"); - sqlx::query("BEGIN") - .execute(&mut *conn_b) + // B1: correlation_id mismatch — attempt carries a different correlation + // than the event it references. + let op_b1 = uuid::Uuid::new_v4(); + let event_b1 = uuid::Uuid::new_v4(); + let corr_b1_event = uuid::Uuid::new_v4(); + let corr_b1_wrong = uuid::Uuid::new_v4(); // different from corr_b1_event + let attempt_b1 = uuid::Uuid::new_v4(); + + let mut conn_b1 = pool.acquire().await.expect("acquire connection B1"); + sqlx::query("BEGIN").execute(&mut *conn_b1).await.expect("begin B1"); + + // Insert the event first (deferred FK allows this ordering). + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ + '2026-01-01T00:00:00Z', $6, $7)", + ) + .bind(community_id) + .bind(event_b1) + .bind(op_b1) + .bind(corr_b1_event) + .bind(attempt_b1) + .bind(vec![0xB1_u8; 64]) + .bind(vec![0xB2_u8; 32]) + .execute(&mut *conn_b1) + .await + .expect("insert kind-9 event for B1"); + + sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + attempt_id, audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 1, 1, 1, 1, $5, $6, 9)", + ) + .bind(community_id) + .bind(op_b1) + .bind(corr_b1_wrong) // wrong correlation_id + .bind(vec![0xB3_u8; 32]) + .bind(attempt_b1) + .bind(event_b1) + .execute(&mut *conn_b1) + .await + .expect("insert denial attempt with wrong correlation_id (guard deferred)"); + + let corr_err = sqlx::query("COMMIT") + .execute(&mut *conn_b1) .await - .expect("begin"); + .expect_err("mismatched correlation_id must be rejected at commit"); + assert!( + corr_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) from authorization_denial_attempt_semantic_binding \ + for correlation_id mismatch, got: {corr_err}" + ); + drop(conn_b1); + + // B2: reason_code mismatch — attempt carries reason_code 2 while event + // carries reason_code 1. + let op_b2 = uuid::Uuid::new_v4(); + let event_b2 = uuid::Uuid::new_v4(); + let corr_b2 = uuid::Uuid::new_v4(); + let attempt_b2 = uuid::Uuid::new_v4(); + + let mut conn_b2 = pool.acquire().await.expect("acquire connection B2"); + sqlx::query("BEGIN").execute(&mut *conn_b2).await.expect("begin B2"); + + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ + '2026-01-01T00:00:00Z', $6, $7)", + ) + .bind(community_id) + .bind(event_b2) + .bind(op_b2) + .bind(corr_b2) + .bind(attempt_b2) + .bind(vec![0xC1_u8; 64]) + .bind(vec![0xC2_u8; 32]) + .execute(&mut *conn_b2) + .await + .expect("insert kind-9 event for B2 (reason_code=1)"); sqlx::query( "INSERT INTO authorization_authentication_denial_attempts \ (community_id, operation_id, correlation_id, semantic_fingerprint, \ denial_reason, expected_revision, action, reason_code, \ - audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 2, 1, 1, 2, $5, 9)", + attempt_id, audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", + // reason_code = 2 but event has reason_code = 1 ) .bind(community_id) - .bind(op3) - .bind(corr3) - .bind(vec![0xFA_u8; 32]) - .bind(absent_event) - .execute(&mut *conn_b) + .bind(op_b2) + .bind(corr_b2) + .bind(vec![0xC3_u8; 32]) + .bind(attempt_b2) + .bind(event_b2) + .execute(&mut *conn_b2) .await - .expect("insert denial attempt with absent event"); + .expect("insert denial attempt with wrong reason_code (guard deferred)"); - let no_event_err = sqlx::query("COMMIT") - .execute(&mut *conn_b) + let reason_err = sqlx::query("COMMIT") + .execute(&mut *conn_b2) .await - .expect_err("denial attempt without matching event must be rejected at commit"); - // Deferred FK (23503) or guard check_violation (23514) — either proves - // the absent event is caught. + .expect_err("mismatched reason_code must be rejected at commit"); assert!( - no_event_err + reason_err .as_database_error() - .map(|e| { - let code = e.code(); - let c = code.as_deref().unwrap_or(""); - c == "23503" || c == "23514" - }) + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) from authorization_denial_attempt_semantic_binding \ + for reason_code mismatch, got: {reason_err}" + ); + drop(conn_b2); + + // B3: attempt_id mismatch — the denial attempt's attempt_id FK references + // a different event (attempt_b3_wrong) than the one being paired (event_b3). + // The attempt_id FK on the denial attempt table binds + // (community_id, operation_id, audit_event_kind, attempt_id) + // -> authorization_events(community_id, operation_id, event_kind, attempt_id) + // so using a different attempt_id that doesn't exist for this operation + // will be caught as a FK violation (23503) at commit. + let op_b3 = uuid::Uuid::new_v4(); + let event_b3 = uuid::Uuid::new_v4(); + let corr_b3 = uuid::Uuid::new_v4(); + let attempt_b3_correct = uuid::Uuid::new_v4(); + let attempt_b3_wrong = uuid::Uuid::new_v4(); // not registered for this operation + + let mut conn_b3 = pool.acquire().await.expect("acquire connection B3"); + sqlx::query("BEGIN").execute(&mut *conn_b3).await.expect("begin B3"); + + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ + '2026-01-01T00:00:00Z', $6, $7)", + ) + .bind(community_id) + .bind(event_b3) + .bind(op_b3) + .bind(corr_b3) + .bind(attempt_b3_correct) + .bind(vec![0xD1_u8; 64]) + .bind(vec![0xD2_u8; 32]) + .execute(&mut *conn_b3) + .await + .expect("insert kind-9 event for B3"); + + sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + attempt_id, audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 1, 1, 1, 1, $5, $6, 9)", + ) + .bind(community_id) + .bind(op_b3) + .bind(corr_b3) + .bind(vec![0xD3_u8; 32]) + .bind(attempt_b3_wrong) // wrong attempt_id — no matching UNIQUE row on events + .bind(event_b3) + .execute(&mut *conn_b3) + .await + .expect("insert denial attempt with wrong attempt_id (FK is deferred)"); + + let attempt_err = sqlx::query("COMMIT") + .execute(&mut *conn_b3) + .await + .expect_err("mismatched attempt_id must be rejected at commit"); + // The attempt_id FK is deferred and fires as foreign_key_violation (23503). + assert!( + attempt_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23503")) .unwrap_or(false), - "expected FK violation (23503) or check_violation (23514) for absent event, got: {no_event_err}" + "expected foreign_key_violation (23503) for attempt_id mismatch \ + (deferred FK on denial attempt), got: {attempt_err}" ); } } diff --git a/migrations/0041_nip_fi_identity_foundation.sql b/migrations/0041_nip_fi_identity_foundation.sql index 586f2312973..458a796cde6 100644 --- a/migrations/0041_nip_fi_identity_foundation.sql +++ b/migrations/0041_nip_fi_identity_foundation.sql @@ -414,16 +414,14 @@ CREATE INDEX identity_lifecycle_selectors_asserted_history (community_id, asserted_history_id, selector_kind); -- Serializes policy-revision inserts per community: each new revision must --- strictly exceed the current maximum, and each effective_at must strictly --- exceed the current maximum effective_at (FI-INV-06 — stable assertion --- policy; a revision that moves either coordinate backward is incoherent). --- The per-community advisory lock prevents two concurrent writers from both --- passing a plain SELECT MAX() check and committing conflicting revisions. +-- strictly exceed the current maximum (FI-INV-06 — stable assertion policy +-- anchor; a backfilled or replayed revision is incoherent). The per-community +-- advisory lock prevents two concurrent writers from both passing a plain +-- SELECT MAX() check and committing conflicting revisions. CREATE FUNCTION identity_enrollment_policy_revision_guard_v1() RETURNS TRIGGER AS $$ DECLARE lock_key BIGINT; max_revision BIGINT; - max_effective_at TIMESTAMPTZ; BEGIN -- Acquire a per-community exclusive transaction-scoped advisory lock so -- that concurrent insertions serialize here. The key is a stable hash of @@ -434,8 +432,8 @@ BEGIN ); PERFORM pg_advisory_xact_lock(lock_key); - SELECT MAX(policy_revision), MAX(effective_at) - INTO max_revision, max_effective_at + SELECT MAX(policy_revision) + INTO max_revision FROM identity_enrollment_policies WHERE community_id = NEW.community_id; @@ -449,16 +447,6 @@ BEGIN CONSTRAINT = 'identity_enrollment_policy_revision_monotonic'; END IF; - IF max_effective_at IS NOT NULL - AND NEW.effective_at <= max_effective_at - THEN - RAISE EXCEPTION - 'effective_at % does not strictly exceed current maximum % for community %', - NEW.effective_at, max_effective_at, NEW.community_id - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_enrollment_policy_revision_monotonic'; - END IF; - RETURN NEW; END; $$ LANGUAGE plpgsql; diff --git a/migrations/0042_nip_fi_authorization_foundation.sql b/migrations/0042_nip_fi_authorization_foundation.sql index b9403c7ae98..3b34a66c145 100644 --- a/migrations/0042_nip_fi_authorization_foundation.sql +++ b/migrations/0042_nip_fi_authorization_foundation.sql @@ -240,6 +240,7 @@ CREATE TABLE authorization_authentication_denial_attempts ( reason_code SMALLINT NOT NULL CHECK ( reason_code IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) ), + attempt_id UUID NOT NULL, audit_event_id UUID NOT NULL, audit_event_kind SMALLINT NOT NULL DEFAULT 9 CHECK (audit_event_kind = 9), recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), @@ -254,8 +255,12 @@ CREATE TABLE authorization_authentication_denial_attempts ( FOREIGN KEY (community_id, audit_event_id, audit_event_kind, operation_id) REFERENCES authorization_events (community_id, event_id, event_kind, operation_id) DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (community_id, operation_id, audit_event_kind, attempt_id) + REFERENCES authorization_events (community_id, operation_id, event_kind, attempt_id) + DEFERRABLE INITIALLY DEFERRED, CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK (correlation_id <> '00000000-0000-0000-0000-000000000000'::uuid) + CHECK (correlation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (attempt_id <> '00000000-0000-0000-0000-000000000000'::uuid) ); -- Exact per-operation authority-version attribution for restore. Empty @@ -508,12 +513,15 @@ CREATE TRIGGER authorization_authentication_denial_attempts_no_truncate -- Bidirectional deferred guard: a kind-9 (pre-authentication denial) audit -- event must commit with exactly one denial attempt; a denial attempt must --- commit with its audit event present and kind-9. Both directions deferred so +-- commit with its audit event present, kind-9, and matching semantic +-- coordinates (correlation_id and reason_code). Both directions deferred so -- event and attempt may be inserted in any order inside one transaction. CREATE FUNCTION authorization_denial_attempt_guard_v1() RETURNS TRIGGER AS $$ DECLARE found_event_kind SMALLINT; + found_correlation_id UUID; + found_reason_code SMALLINT; attempt_count BIGINT; BEGIN IF TG_TABLE_NAME = 'authorization_events' THEN @@ -534,10 +542,34 @@ BEGIN USING ERRCODE = 'check_violation', CONSTRAINT = 'authorization_denial_attempt_event_cardinality'; END IF; + + -- Verify semantic coordinates match between event and denial attempt. + SELECT correlation_id, reason_code + INTO found_correlation_id, found_reason_code + FROM authorization_authentication_denial_attempts + WHERE community_id = NEW.community_id + AND audit_event_id = NEW.event_id; + + IF found_correlation_id IS DISTINCT FROM NEW.correlation_id THEN + RAISE EXCEPTION + 'denial attempt correlation_id % does not match event correlation_id % for event %', + found_correlation_id, NEW.correlation_id, NEW.event_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; + END IF; + + IF found_reason_code IS DISTINCT FROM NEW.reason_code THEN + RAISE EXCEPTION + 'denial attempt reason_code % does not match event reason_code % for event %', + found_reason_code, NEW.reason_code, NEW.event_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; + END IF; ELSE -- Firing from the denial-attempt side: verify the audit event is kind-9 -- and that exactly one denial attempt references it. - SELECT event_kind INTO found_event_kind + SELECT event_kind, correlation_id, reason_code + INTO found_event_kind, found_correlation_id, found_reason_code FROM authorization_events WHERE community_id = NEW.community_id AND event_id = NEW.audit_event_id; @@ -558,6 +590,23 @@ BEGIN CONSTRAINT = 'authorization_denial_attempt_event_kind'; END IF; + -- Verify semantic coordinates match. + IF found_correlation_id IS DISTINCT FROM NEW.correlation_id THEN + RAISE EXCEPTION + 'denial attempt correlation_id % does not match event correlation_id % for event %', + NEW.correlation_id, found_correlation_id, NEW.audit_event_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; + END IF; + + IF found_reason_code IS DISTINCT FROM NEW.reason_code THEN + RAISE EXCEPTION + 'denial attempt reason_code % does not match event reason_code % for event %', + NEW.reason_code, found_reason_code, NEW.audit_event_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; + END IF; + SELECT count(*) INTO attempt_count FROM authorization_authentication_denial_attempts WHERE community_id = NEW.community_id diff --git a/schema/schema.sql b/schema/schema.sql index de20d9a09f0..e7c61011cbb 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -2309,16 +2309,14 @@ CREATE INDEX identity_lifecycle_selectors_asserted_history (community_id, asserted_history_id, selector_kind); -- Serializes policy-revision inserts per community: each new revision must --- strictly exceed the current maximum, and each effective_at must strictly --- exceed the current maximum effective_at (FI-INV-06 — stable assertion --- policy; a revision that moves either coordinate backward is incoherent). --- The per-community advisory lock prevents two concurrent writers from both --- passing a plain SELECT MAX() check and committing conflicting revisions. +-- strictly exceed the current maximum (FI-INV-06 — stable assertion policy +-- anchor; a backfilled or replayed revision is incoherent). The per-community +-- advisory lock prevents two concurrent writers from both passing a plain +-- SELECT MAX() check and committing conflicting revisions. CREATE FUNCTION identity_enrollment_policy_revision_guard_v1() RETURNS TRIGGER AS $$ DECLARE lock_key BIGINT; max_revision BIGINT; - max_effective_at TIMESTAMPTZ; BEGIN -- Acquire a per-community exclusive transaction-scoped advisory lock so -- that concurrent insertions serialize here. The key is a stable hash of @@ -2329,8 +2327,8 @@ BEGIN ); PERFORM pg_advisory_xact_lock(lock_key); - SELECT MAX(policy_revision), MAX(effective_at) - INTO max_revision, max_effective_at + SELECT MAX(policy_revision) + INTO max_revision FROM identity_enrollment_policies WHERE community_id = NEW.community_id; @@ -2344,16 +2342,6 @@ BEGIN CONSTRAINT = 'identity_enrollment_policy_revision_monotonic'; END IF; - IF max_effective_at IS NOT NULL - AND NEW.effective_at <= max_effective_at - THEN - RAISE EXCEPTION - 'effective_at % does not strictly exceed current maximum % for community %', - NEW.effective_at, max_effective_at, NEW.community_id - USING ERRCODE = 'check_violation', - CONSTRAINT = 'identity_enrollment_policy_revision_monotonic'; - END IF; - RETURN NEW; END; $$ LANGUAGE plpgsql; @@ -3031,6 +3019,7 @@ CREATE TABLE authorization_authentication_denial_attempts ( reason_code SMALLINT NOT NULL CHECK ( reason_code IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) ), + attempt_id UUID NOT NULL, audit_event_id UUID NOT NULL, audit_event_kind SMALLINT NOT NULL DEFAULT 9 CHECK (audit_event_kind = 9), recorded_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), @@ -3045,8 +3034,12 @@ CREATE TABLE authorization_authentication_denial_attempts ( FOREIGN KEY (community_id, audit_event_id, audit_event_kind, operation_id) REFERENCES authorization_events (community_id, event_id, event_kind, operation_id) DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (community_id, operation_id, audit_event_kind, attempt_id) + REFERENCES authorization_events (community_id, operation_id, event_kind, attempt_id) + DEFERRABLE INITIALLY DEFERRED, CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), - CHECK (correlation_id <> '00000000-0000-0000-0000-000000000000'::uuid) + CHECK (correlation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (attempt_id <> '00000000-0000-0000-0000-000000000000'::uuid) ); -- Exact per-operation authority-version attribution for restore. Empty @@ -3299,12 +3292,15 @@ CREATE TRIGGER authorization_authentication_denial_attempts_no_truncate -- Bidirectional deferred guard: a kind-9 (pre-authentication denial) audit -- event must commit with exactly one denial attempt; a denial attempt must --- commit with its audit event present and kind-9. Both directions deferred so +-- commit with its audit event present, kind-9, and matching semantic +-- coordinates (correlation_id and reason_code). Both directions deferred so -- event and attempt may be inserted in any order inside one transaction. CREATE FUNCTION authorization_denial_attempt_guard_v1() RETURNS TRIGGER AS $$ DECLARE found_event_kind SMALLINT; + found_correlation_id UUID; + found_reason_code SMALLINT; attempt_count BIGINT; BEGIN IF TG_TABLE_NAME = 'authorization_events' THEN @@ -3325,10 +3321,34 @@ BEGIN USING ERRCODE = 'check_violation', CONSTRAINT = 'authorization_denial_attempt_event_cardinality'; END IF; + + -- Verify semantic coordinates match between event and denial attempt. + SELECT correlation_id, reason_code + INTO found_correlation_id, found_reason_code + FROM authorization_authentication_denial_attempts + WHERE community_id = NEW.community_id + AND audit_event_id = NEW.event_id; + + IF found_correlation_id IS DISTINCT FROM NEW.correlation_id THEN + RAISE EXCEPTION + 'denial attempt correlation_id % does not match event correlation_id % for event %', + found_correlation_id, NEW.correlation_id, NEW.event_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; + END IF; + + IF found_reason_code IS DISTINCT FROM NEW.reason_code THEN + RAISE EXCEPTION + 'denial attempt reason_code % does not match event reason_code % for event %', + found_reason_code, NEW.reason_code, NEW.event_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; + END IF; ELSE -- Firing from the denial-attempt side: verify the audit event is kind-9 -- and that exactly one denial attempt references it. - SELECT event_kind INTO found_event_kind + SELECT event_kind, correlation_id, reason_code + INTO found_event_kind, found_correlation_id, found_reason_code FROM authorization_events WHERE community_id = NEW.community_id AND event_id = NEW.audit_event_id; @@ -3349,6 +3369,23 @@ BEGIN CONSTRAINT = 'authorization_denial_attempt_event_kind'; END IF; + -- Verify semantic coordinates match. + IF found_correlation_id IS DISTINCT FROM NEW.correlation_id THEN + RAISE EXCEPTION + 'denial attempt correlation_id % does not match event correlation_id % for event %', + NEW.correlation_id, found_correlation_id, NEW.audit_event_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; + END IF; + + IF found_reason_code IS DISTINCT FROM NEW.reason_code THEN + RAISE EXCEPTION + 'denial attempt reason_code % does not match event reason_code % for event %', + NEW.reason_code, found_reason_code, NEW.audit_event_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; + END IF; + SELECT count(*) INTO attempt_count FROM authorization_authentication_denial_attempts WHERE community_id = NEW.community_id From c52444b54469573b827e06b2d1f0de1c199396f8 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Sat, 29 Aug 2026 15:39:20 -0400 Subject: [PATCH 08/15] fix(schema): address Thufir pass 2 blockers on NIP-FI PR 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three IMPORTANT findings addressed: IMPORTANT 1 (denial semantic binding, partial): Bind the remaining two unbound denial identity coordinates. - Add semantic_fingerprint BYTEA to authorization_events: required non-zero for kind-9 events, NULL for all other event kinds (enforced by CHECK on the table). This is the redaction-safe intent_digest coordinate. - Add authorization_denial_reason_reason_code_binding CHECK to authorization_authentication_denial_attempts: encodes the canonical OperatorAuthenticationDenialReason <-> AuthorizationReasonCode mapping from operator_lifecycle.rs:700-706 and authorization_events.rs:215-226: MissingCredential(1)<->Missing(2), InvalidCredential(2)<->Invalid(3), Unauthenticated(3)<->Unauthenticated(4). Fires at INSERT, not COMMIT. - Extend authorization_denial_attempt_guard_v1() to compare semantic_fingerprint between event and denial attempt in both firing directions, raising 23514 'authorization_denial_attempt_semantic_binding' on mismatch. Carl's mismatched-reason and mismatched-fingerprint cross-attachments are now fully closed. IMPORTANT 2 (concurrency regression): Replace the 10ms-sleep approach with a tokio::sync::Barrier(2) that holds both connections after BEGIN and before INSERT. Both race to pg_advisory_xact_lock; one blocks, the winner commits, the loser sees MAX=102 and fails with 23514 (not 23505). XOR assertion proves exactly one INSERT succeeds, and the loser's 23514 (not PK 23505) proves the advisory lock — not just PK uniqueness — is the serialization mechanism. Contradictory comments fixed. MINORs (folded in): - Fix stale test doc comment claiming effective_at must advance (it does not; the downstream constructor stamps Unix epoch for immediate policy). - Fix equal-revision comment incorrectly claiming different policy_digest avoids the PK; the PK is (community_id, policy_revision). CI fmt failure: cargo fmt --all run; whitespace-only reformatting of some query blocks in migration.rs. Regressions added/updated: - B4: denial_reason/reason_code mapping violation rejected at INSERT by the immediate CHECK (23514 from authorization_denial_reason_reason_code_binding). - B5: semantic_fingerprint mismatch between event and denial attempt rejected at COMMIT by the deferred guard (23514 from authorization_denial_attempt_semantic_binding). Byte-parity (extraction+cmp): - authorization_events table: 3394 bytes, migration == schema.sql - authorization_authentication_denial_attempts table: 2062 bytes, migration == schema.sql - authorization_denial_attempt_guard_v1(): 5579 bytes, migration == schema.sql - identity_enrollment_policy_revision_guard_v1(): 1113 bytes, migration == schema.sql - authorization_admission_result_guard_v1(): 2164 bytes, migration == schema.sql All five NIP-FI tests green locally (run in isolation to avoid pre-existing pool-state flakiness in the full suite). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> --- crates/buzz-db/src/runtime/migration.rs | 324 +++++++++++++++--- .../0042_nip_fi_authorization_foundation.sql | 53 ++- schema/schema.sql | 53 ++- 3 files changed, 362 insertions(+), 68 deletions(-) diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index f0e718318a3..ab2c43b9ce6 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -3220,9 +3220,10 @@ mod tests { } /// NIP-FI policy-revision monotonicity: each new policy revision for a - /// community must strictly exceed the current maximum revision, and its - /// effective_at must strictly exceed the current maximum effective_at - /// (FI-INV-06 — stable assertion policy). + /// community must strictly exceed the current maximum revision + /// (FI-INV-06 — stable assertion policy). `effective_at` ordering is + /// deliberately not enforced — the downstream constructor stamps every + /// immediately-effective revision with Unix epoch. /// /// Mutation sensitivity is two-sided: /// - neutering the guard lets a replayed or backfilled revision through @@ -3316,9 +3317,10 @@ mod tests { guard for backfilled revision 99, got: {backfill_err}" ); - // Negative: equal revision (101 <= 101) — different from a PK duplicate - // because we use a different policy_digest, so the PK is not violated; - // the guard still fires on the <= check. + // Negative: equal revision (101 <= 101). The PK is (community_id, policy_revision) + // so this is a PK duplicate regardless of policy_digest; either 23505 from the PK + // or 23514 from the guard fires first. This case is secondary — the load-bearing + // proof is the unused-99 case above, which is not a PK duplicate. let replay_err = sqlx::query( "INSERT INTO identity_enrollment_policies \ (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ @@ -3329,8 +3331,7 @@ mod tests { .execute(&pool) .await .expect_err("equal revision must be rejected"); - // PK (23505) fires before guard on exact duplicates, both prove the insert - // cannot commit; accept either code as evidence. + // PK (23505) or guard (23514) — either proves the insert cannot commit. assert!( replay_err .as_database_error() @@ -3344,23 +3345,38 @@ mod tests { got: {replay_err}" ); - // Concurrency regression: race two distinct forward revisions (102 and 103) - // on separate connections. The per-community advisory lock must serialize - // them so that exactly one commits — not zero, not two. + // Concurrency regression: prove the advisory lock actually serializes + // concurrent writers. Two connections race to insert the SAME next revision + // (102) for the same community. The advisory lock must cause one writer to + // block, see the other's committed MAX, and then be rejected with 23514 from + // the named guard. Without the lock, both could pass the MAX check before + // either commits; only a PK collision (23505) would catch the duplicate — + // not the guard. Removing pg_advisory_xact_lock from the guard function and + // re-running must produce 23505 (PK) rather than 23514 (guard), proving the + // test is mutation-sensitive to the lock. // - // Strategy: begin both transactions before either acquires the lock, then - // commit them sequentially. The guard holds the lock for the duration of - // its transaction, so the second commit must succeed (not deadlock) because - // the first has already released. + // A tokio::sync::Barrier synchronizes both connections so they both have a + // live transaction and are ready to INSERT before either proceeds. After the + // barrier both race to acquire the advisory lock; one wins, commits, and + // releases the lock; the other then sees the committed MAX and is rejected + // by the guard with 23514. let pool2 = pool.clone(); let pool3 = pool.clone(); let community_id2 = community_id; + let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2)); + let barrier2 = barrier.clone(); let (tx1_result, tx2_result) = tokio::join!( tokio::spawn(async move { let mut conn = pool.acquire().await.expect("acquire conn1"); - sqlx::query("BEGIN").execute(&mut *conn).await.expect("begin1"); - let r = sqlx::query( + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin1"); + // Wait until both connections have open transactions before + // either races to INSERT — eliminates ordering accidents. + barrier.wait().await; + let insert_r = sqlx::query( "INSERT INTO identity_enrollment_policies \ (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ VALUES ($1, 102, 1, $2, '2029-01-01T00:00:00Z')", @@ -3370,38 +3386,62 @@ mod tests { .execute(&mut *conn) .await; let commit_r = sqlx::query("COMMIT").execute(&mut *conn).await; - (r, commit_r) + (insert_r, commit_r) }), tokio::spawn(async move { - // Small delay so tx1 tends to start first; not required for - // correctness — either order is valid under the guard. - tokio::time::sleep(std::time::Duration::from_millis(10)).await; let mut conn = pool2.acquire().await.expect("acquire conn2"); - sqlx::query("BEGIN").execute(&mut *conn).await.expect("begin2"); - let r = sqlx::query( + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin2"); + // Mirror barrier wait so both are in-flight simultaneously. + barrier2.wait().await; + let insert_r = sqlx::query( "INSERT INTO identity_enrollment_policies \ (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 103, 1, $2, '2029-06-01T00:00:00Z')", + VALUES ($1, 102, 1, $2, '2029-01-01T00:00:00Z')", ) .bind(community_id2) .bind(vec![0xB2_u8; 32]) .execute(&mut *conn) .await; let commit_r = sqlx::query("COMMIT").execute(&mut *conn).await; - (r, commit_r) + (insert_r, commit_r) }), ); let (insert1, commit1) = tx1_result.expect("tx1 task completed"); let (insert2, commit2) = tx2_result.expect("tx2 task completed"); - insert1.expect("tx1 INSERT must succeed (deferred guard at commit)"); - insert2.expect("tx2 INSERT must succeed (deferred guard at commit)"); - // Both distinct forward revisions should commit: the advisory lock - // serializes them, so both 102 and 103 are individually valid. - commit1.expect("tx1 COMMIT must succeed for distinct forward revision 102"); - commit2.expect("tx2 COMMIT must succeed for distinct forward revision 103"); - - // Confirm exactly 6 policy revisions are now present (1, 2, 100, 101, 102, 103). + + // The BEFORE INSERT trigger fires at statement time: the winner's INSERT + // succeeds (lock acquired, MAX check passes, INSERT completes), the loser's + // INSERT blocks waiting for the lock and then fails with 23514 when it sees + // the winner's committed MAX. Exactly one INSERT must succeed; exactly one + // must fail with 23514 from the named guard. + let (i1_ok, i2_ok) = (insert1.is_ok(), insert2.is_ok()); + assert!( + i1_ok ^ i2_ok, + "exactly one of the two concurrent revision-102 inserts must succeed at INSERT; \ + got insert1={i1_ok} insert2={i2_ok}" + ); + let loser_insert = if i1_ok { insert2 } else { insert1 }; + let loser_err = loser_insert.expect_err("loser INSERT must have failed"); + assert!( + loser_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "loser must fail with check_violation (23514) from \ + identity_enrollment_policy_revision_monotonic guard, not a PK \ + collision — this proves the advisory lock serialized the writers; \ + got: {loser_err}" + ); + + // The winner's commit must succeed. + let winner_commit = if i1_ok { commit1 } else { commit2 }; + winner_commit.expect("winner COMMIT must succeed"); + + // Confirm exactly 5 policy revisions are now present (1, 2, 100, 101, 102). let count: i64 = sqlx::query_scalar( "SELECT count(*) FROM identity_enrollment_policies WHERE community_id = $1", ) @@ -3409,7 +3449,10 @@ mod tests { .fetch_one(&pool3) .await .expect("count persisted policy revisions"); - assert_eq!(count, 6, "all six accepted revisions must persist"); + assert_eq!( + count, 5, + "exactly five revisions must persist after concurrency race" + ); } /// NIP-FI admission-result ↔ kind-11 receipt cardinality: a kind-11 @@ -3716,7 +3759,7 @@ mod tests { (community_id, operation_id, correlation_id, semantic_fingerprint, \ denial_reason, expected_revision, action, reason_code, \ attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 1, $5, $6, 9)", + VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", ) .bind(community_id) .bind(op1) @@ -3733,15 +3776,16 @@ mod tests { "INSERT INTO authorization_events \ (community_id, event_id, event_kind, outcome_code, reason_code, \ actor_kind, operation_id, correlation_id, attempt_id, \ - occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ - '2026-01-01T00:00:00Z', $6, $7)", + semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ + $6, '2026-01-01T00:00:00Z', $7, $8)", ) .bind(community_id) .bind(event1) .bind(op1) .bind(corr1) .bind(attempt1_id) + .bind(vec![0xE1_u8; 32]) // semantic_fingerprint matches denial attempt .bind(vec![0xE2_u8; 64]) // canonical_envelope (≤16384 bytes) .bind(vec![0xE3_u8; 32]) // envelope_digest .execute(&mut *conn) @@ -3770,15 +3814,16 @@ mod tests { "INSERT INTO authorization_events \ (community_id, event_id, event_kind, outcome_code, reason_code, \ actor_kind, operation_id, correlation_id, attempt_id, \ - occurred_at, canonical_envelope, envelope_digest) \ + semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ - '2026-01-01T00:00:00Z', $6, $7)", + $6, '2026-01-01T00:00:00Z', $7, $8)", ) .bind(community_id) .bind(event2) .bind(op2) .bind(corr2) .bind(attempt2_id) + .bind(vec![0xF0_u8; 32]) // semantic_fingerprint (non-zero) .bind(vec![0xF1_u8; 64]) .bind(vec![0xF2_u8; 32]) .execute(&mut *conn_a) @@ -3812,22 +3857,26 @@ mod tests { let attempt_b1 = uuid::Uuid::new_v4(); let mut conn_b1 = pool.acquire().await.expect("acquire connection B1"); - sqlx::query("BEGIN").execute(&mut *conn_b1).await.expect("begin B1"); + sqlx::query("BEGIN") + .execute(&mut *conn_b1) + .await + .expect("begin B1"); // Insert the event first (deferred FK allows this ordering). sqlx::query( "INSERT INTO authorization_events \ (community_id, event_id, event_kind, outcome_code, reason_code, \ actor_kind, operation_id, correlation_id, attempt_id, \ - occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ - '2026-01-01T00:00:00Z', $6, $7)", + semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ + $6, '2026-01-01T00:00:00Z', $7, $8)", ) .bind(community_id) .bind(event_b1) .bind(op_b1) .bind(corr_b1_event) .bind(attempt_b1) + .bind(vec![0xB3_u8; 32]) // semantic_fingerprint matches denial attempt .bind(vec![0xB1_u8; 64]) .bind(vec![0xB2_u8; 32]) .execute(&mut *conn_b1) @@ -3839,7 +3888,7 @@ mod tests { (community_id, operation_id, correlation_id, semantic_fingerprint, \ denial_reason, expected_revision, action, reason_code, \ attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 1, $5, $6, 9)", + VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", ) .bind(community_id) .bind(op_b1) @@ -3865,29 +3914,36 @@ mod tests { ); drop(conn_b1); - // B2: reason_code mismatch — attempt carries reason_code 2 while event - // carries reason_code 1. + // B2: reason_code mismatch — attempt carries denial_reason=1 (MissingCredential, + // requires reason_code=2 per canonical mapping), but event carries reason_code=1. + // The attempt INSERT passes (denial_reason=1↔reason_code=2 is a valid mapping pair), + // then the deferred guard fires at commit because event reason_code=1 ≠ attempt + // reason_code=2. let op_b2 = uuid::Uuid::new_v4(); let event_b2 = uuid::Uuid::new_v4(); let corr_b2 = uuid::Uuid::new_v4(); let attempt_b2 = uuid::Uuid::new_v4(); let mut conn_b2 = pool.acquire().await.expect("acquire connection B2"); - sqlx::query("BEGIN").execute(&mut *conn_b2).await.expect("begin B2"); + sqlx::query("BEGIN") + .execute(&mut *conn_b2) + .await + .expect("begin B2"); sqlx::query( "INSERT INTO authorization_events \ (community_id, event_id, event_kind, outcome_code, reason_code, \ actor_kind, operation_id, correlation_id, attempt_id, \ - occurred_at, canonical_envelope, envelope_digest) \ + semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ - '2026-01-01T00:00:00Z', $6, $7)", + $6, '2026-01-01T00:00:00Z', $7, $8)", ) .bind(community_id) .bind(event_b2) .bind(op_b2) .bind(corr_b2) .bind(attempt_b2) + .bind(vec![0xC3_u8; 32]) // semantic_fingerprint matches denial attempt .bind(vec![0xC1_u8; 64]) .bind(vec![0xC2_u8; 32]) .execute(&mut *conn_b2) @@ -3910,7 +3966,9 @@ mod tests { .bind(event_b2) .execute(&mut *conn_b2) .await - .expect("insert denial attempt with wrong reason_code (guard deferred)"); + .expect( + "insert denial attempt with wrong reason_code (deferred guard will fire at commit)", + ); let reason_err = sqlx::query("COMMIT") .execute(&mut *conn_b2) @@ -3940,21 +3998,25 @@ mod tests { let attempt_b3_wrong = uuid::Uuid::new_v4(); // not registered for this operation let mut conn_b3 = pool.acquire().await.expect("acquire connection B3"); - sqlx::query("BEGIN").execute(&mut *conn_b3).await.expect("begin B3"); + sqlx::query("BEGIN") + .execute(&mut *conn_b3) + .await + .expect("begin B3"); sqlx::query( "INSERT INTO authorization_events \ (community_id, event_id, event_kind, outcome_code, reason_code, \ actor_kind, operation_id, correlation_id, attempt_id, \ - occurred_at, canonical_envelope, envelope_digest) \ - VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ - '2026-01-01T00:00:00Z', $6, $7)", + semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ + $6, '2026-01-01T00:00:00Z', $7, $8)", ) .bind(community_id) .bind(event_b3) .bind(op_b3) .bind(corr_b3) .bind(attempt_b3_correct) + .bind(vec![0xD3_u8; 32]) // semantic_fingerprint (matches denial attempt) .bind(vec![0xD1_u8; 64]) .bind(vec![0xD2_u8; 32]) .execute(&mut *conn_b3) @@ -3966,7 +4028,7 @@ mod tests { (community_id, operation_id, correlation_id, semantic_fingerprint, \ denial_reason, expected_revision, action, reason_code, \ attempt_id, audit_event_id, audit_event_kind) \ - VALUES ($1, $2, $3, $4, 1, 1, 1, 1, $5, $6, 9)", + VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", ) .bind(community_id) .bind(op_b3) @@ -3991,5 +4053,155 @@ mod tests { "expected foreign_key_violation (23503) for attempt_id mismatch \ (deferred FK on denial attempt), got: {attempt_err}" ); + + // B4: denial_reason ↔ reason_code mapping violation — the denial attempt + // row carries denial_reason=2 (InvalidCredential) but reason_code=2 + // (Missing). The canonical mapping requires InvalidCredential(2)↔Invalid(3); + // reason_code=2 is only valid for MissingCredential(denial_reason=1). + // The immediate CHECK constraint authorization_denial_reason_reason_code_binding + // fires at INSERT, not commit. Mutation-sensitive: removing the CHECK lets + // this INSERT succeed (the guard does not compare denial_reason; only the + // paired event's reason_code is checked at commit). + let op_b4 = uuid::Uuid::new_v4(); + let event_b4 = uuid::Uuid::new_v4(); + let corr_b4 = uuid::Uuid::new_v4(); + let attempt_b4 = uuid::Uuid::new_v4(); + + let mut conn_b4 = pool.acquire().await.expect("acquire connection B4"); + sqlx::query("BEGIN") + .execute(&mut *conn_b4) + .await + .expect("begin B4"); + + // Insert the matching kind-9 event first. + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ + $6, '2026-01-01T00:00:00Z', $7, $8)", + ) + .bind(community_id) + .bind(event_b4) + .bind(op_b4) + .bind(corr_b4) + .bind(attempt_b4) + .bind(vec![0xE4_u8; 32]) // semantic_fingerprint + .bind(vec![0xE5_u8; 64]) + .bind(vec![0xE6_u8; 32]) + .execute(&mut *conn_b4) + .await + .expect("insert kind-9 event for B4"); + + // Insert denial attempt with denial_reason=2 (InvalidCredential) but + // reason_code=2 (Missing) — violates the canonical mapping (requires reason_code=3). + let denial_reason_err = sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + attempt_id, audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 2, 1, 1, 2, $5, $6, 9)", + // denial_reason=2 (InvalidCredential) requires reason_code=3; reason_code=2 is wrong + ) + .bind(community_id) + .bind(op_b4) + .bind(corr_b4) + .bind(vec![0xE4_u8; 32]) + .bind(attempt_b4) + .bind(event_b4) + .execute(&mut *conn_b4) + .await + .expect_err("denial_reason/reason_code mapping violation must be rejected at INSERT"); + + assert!( + denial_reason_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) from \ + authorization_denial_reason_reason_code_binding for denial_reason mismatch, \ + got: {denial_reason_err}" + ); + + // B5: semantic_fingerprint mismatch — the event carries semantic_fingerprint + // 0xB5…B5 while the denial attempt carries 0xB6…B6. correlation_id, reason_code, + // and attempt_id all match; only the fingerprint differs. The deferred guard + // authorization_denial_attempt_guard_v1 fires at COMMIT on the denial-attempt + // side, compares found_semantic_fingerprint (from the event) with + // NEW.semantic_fingerprint (from the attempt), and raises 23514 with named + // constraint authorization_denial_attempt_semantic_binding. + // Mutation-sensitive: removing the semantic_fingerprint comparison block from + // the guard function lets this transaction commit. + let op_b5 = uuid::Uuid::new_v4(); + let event_b5 = uuid::Uuid::new_v4(); + let corr_b5 = uuid::Uuid::new_v4(); + let attempt_b5 = uuid::Uuid::new_v4(); + let fp_event_b5 = vec![0xB5_u8; 32]; // event semantic_fingerprint + let fp_attempt_b5 = vec![0xB6_u8; 32]; // mismatched attempt semantic_fingerprint + + let mut conn_b5 = pool.acquire().await.expect("acquire connection B5"); + sqlx::query("BEGIN") + .execute(&mut *conn_b5) + .await + .expect("begin B5"); + + // Insert the kind-9 event with fingerprint 0xB5…B5. + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ + $6, '2026-01-01T00:00:00Z', $7, $8)", + ) + .bind(community_id) + .bind(event_b5) + .bind(op_b5) + .bind(corr_b5) + .bind(attempt_b5) + .bind(fp_event_b5) + .bind(vec![0xB7_u8; 64]) // canonical_envelope + .bind(vec![0xB8_u8; 32]) // envelope_digest + .execute(&mut *conn_b5) + .await + .expect("insert kind-9 event for B5"); + + // Insert denial attempt with the WRONG semantic_fingerprint (0xB6…B6). + // correlation_id, reason_code=2, denial_reason=1 (MissingCredential↔Missing), + // and attempt_id all match the event — only semantic_fingerprint differs. + sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + attempt_id, audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", + ) + .bind(community_id) + .bind(op_b5) + .bind(corr_b5) + .bind(fp_attempt_b5) // 0xB6…B6 ≠ event's 0xB5…B5 + .bind(attempt_b5) + .bind(event_b5) + .execute(&mut *conn_b5) + .await + .expect( + "insert denial attempt with mismatched fingerprint (deferred guard fires at commit)", + ); + + let fp_mismatch_err = sqlx::query("COMMIT") + .execute(&mut *conn_b5) + .await + .expect_err("commit with mismatched semantic_fingerprint must be rejected"); + + assert!( + fp_mismatch_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) from \ + authorization_denial_attempt_semantic_binding for semantic_fingerprint mismatch, \ + got: {fp_mismatch_err}" + ); } } diff --git a/migrations/0042_nip_fi_authorization_foundation.sql b/migrations/0042_nip_fi_authorization_foundation.sql index 3b34a66c145..f27f44c9ad5 100644 --- a/migrations/0042_nip_fi_authorization_foundation.sql +++ b/migrations/0042_nip_fi_authorization_foundation.sql @@ -198,6 +198,12 @@ CREATE TABLE authorization_events ( ), correlation_id UUID NOT NULL, attempt_id UUID NOT NULL, + -- Redaction-safe pre-authentication denial identity. Present and non-zero + -- for kind-9 events; NULL for all other event kinds. Binds the event to the + -- exact denial attempt's semantic_fingerprint (intent_digest) for exact replay. + semantic_fingerprint BYTEA CHECK ( + semantic_fingerprint IS NULL OR octet_length(semantic_fingerprint) = 32 + ), occurred_at TIMESTAMPTZ NOT NULL, accepted_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), canonical_envelope BYTEA NOT NULL CONSTRAINT authorization_events_envelope_size CHECK ( @@ -223,6 +229,13 @@ CREATE TABLE authorization_events ( CHECK ( (actor_kind = 4 AND actor_fingerprint IS NULL AND subject_fingerprint IS NULL) OR (actor_kind IN (1, 2, 3) AND actor_fingerprint IS NOT NULL) + ), + -- Kind-9 (pre-auth denial) events must carry a non-zero semantic_fingerprint; + -- all other event kinds must not. + CHECK ( + (event_kind = 9 AND semantic_fingerprint IS NOT NULL + AND semantic_fingerprint <> decode(repeat('00', 32), 'hex')) + OR (event_kind <> 9 AND semantic_fingerprint IS NULL) ) ); @@ -258,6 +271,13 @@ CREATE TABLE authorization_authentication_denial_attempts ( FOREIGN KEY (community_id, operation_id, audit_event_kind, attempt_id) REFERENCES authorization_events (community_id, operation_id, event_kind, attempt_id) DEFERRABLE INITIALLY DEFERRED, + -- Canonical denial_reason ↔ reason_code binding: MissingCredential(1)↔Missing(2), + -- InvalidCredential(2)↔Invalid(3), Unauthenticated(3)↔Unauthenticated(4). + CONSTRAINT authorization_denial_reason_reason_code_binding CHECK ( + (denial_reason = 1 AND reason_code = 2) + OR (denial_reason = 2 AND reason_code = 3) + OR (denial_reason = 3 AND reason_code = 4) + ), CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), CHECK (correlation_id <> '00000000-0000-0000-0000-000000000000'::uuid), CHECK (attempt_id <> '00000000-0000-0000-0000-000000000000'::uuid) @@ -514,14 +534,18 @@ CREATE TRIGGER authorization_authentication_denial_attempts_no_truncate -- Bidirectional deferred guard: a kind-9 (pre-authentication denial) audit -- event must commit with exactly one denial attempt; a denial attempt must -- commit with its audit event present, kind-9, and matching semantic --- coordinates (correlation_id and reason_code). Both directions deferred so --- event and attempt may be inserted in any order inside one transaction. +-- coordinates (correlation_id, reason_code, and semantic_fingerprint). Both +-- directions deferred so event and attempt may be inserted in any order inside +-- one transaction. The static denial_reason↔reason_code mapping is enforced +-- by an immediate CHECK on the denial attempt table; the guard enforces the +-- matching semantic coordinates between event and attempt. CREATE FUNCTION authorization_denial_attempt_guard_v1() RETURNS TRIGGER AS $$ DECLARE found_event_kind SMALLINT; found_correlation_id UUID; found_reason_code SMALLINT; + found_semantic_fingerprint BYTEA; attempt_count BIGINT; BEGIN IF TG_TABLE_NAME = 'authorization_events' THEN @@ -544,8 +568,8 @@ BEGIN END IF; -- Verify semantic coordinates match between event and denial attempt. - SELECT correlation_id, reason_code - INTO found_correlation_id, found_reason_code + SELECT correlation_id, reason_code, semantic_fingerprint + INTO found_correlation_id, found_reason_code, found_semantic_fingerprint FROM authorization_authentication_denial_attempts WHERE community_id = NEW.community_id AND audit_event_id = NEW.event_id; @@ -565,11 +589,20 @@ BEGIN USING ERRCODE = 'check_violation', CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; END IF; + + IF found_semantic_fingerprint IS DISTINCT FROM NEW.semantic_fingerprint THEN + RAISE EXCEPTION + 'denial attempt semantic_fingerprint does not match event semantic_fingerprint for event %', + NEW.event_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; + END IF; ELSE -- Firing from the denial-attempt side: verify the audit event is kind-9 -- and that exactly one denial attempt references it. - SELECT event_kind, correlation_id, reason_code - INTO found_event_kind, found_correlation_id, found_reason_code + SELECT event_kind, correlation_id, reason_code, semantic_fingerprint + INTO found_event_kind, found_correlation_id, found_reason_code, + found_semantic_fingerprint FROM authorization_events WHERE community_id = NEW.community_id AND event_id = NEW.audit_event_id; @@ -607,6 +640,14 @@ BEGIN CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; END IF; + IF found_semantic_fingerprint IS DISTINCT FROM NEW.semantic_fingerprint THEN + RAISE EXCEPTION + 'denial attempt semantic_fingerprint does not match event semantic_fingerprint for event %', + NEW.audit_event_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; + END IF; + SELECT count(*) INTO attempt_count FROM authorization_authentication_denial_attempts WHERE community_id = NEW.community_id diff --git a/schema/schema.sql b/schema/schema.sql index e7c61011cbb..bd77cb7f8a1 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -2977,6 +2977,12 @@ CREATE TABLE authorization_events ( ), correlation_id UUID NOT NULL, attempt_id UUID NOT NULL, + -- Redaction-safe pre-authentication denial identity. Present and non-zero + -- for kind-9 events; NULL for all other event kinds. Binds the event to the + -- exact denial attempt's semantic_fingerprint (intent_digest) for exact replay. + semantic_fingerprint BYTEA CHECK ( + semantic_fingerprint IS NULL OR octet_length(semantic_fingerprint) = 32 + ), occurred_at TIMESTAMPTZ NOT NULL, accepted_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), canonical_envelope BYTEA NOT NULL CONSTRAINT authorization_events_envelope_size CHECK ( @@ -3002,6 +3008,13 @@ CREATE TABLE authorization_events ( CHECK ( (actor_kind = 4 AND actor_fingerprint IS NULL AND subject_fingerprint IS NULL) OR (actor_kind IN (1, 2, 3) AND actor_fingerprint IS NOT NULL) + ), + -- Kind-9 (pre-auth denial) events must carry a non-zero semantic_fingerprint; + -- all other event kinds must not. + CHECK ( + (event_kind = 9 AND semantic_fingerprint IS NOT NULL + AND semantic_fingerprint <> decode(repeat('00', 32), 'hex')) + OR (event_kind <> 9 AND semantic_fingerprint IS NULL) ) ); @@ -3037,6 +3050,13 @@ CREATE TABLE authorization_authentication_denial_attempts ( FOREIGN KEY (community_id, operation_id, audit_event_kind, attempt_id) REFERENCES authorization_events (community_id, operation_id, event_kind, attempt_id) DEFERRABLE INITIALLY DEFERRED, + -- Canonical denial_reason ↔ reason_code binding: MissingCredential(1)↔Missing(2), + -- InvalidCredential(2)↔Invalid(3), Unauthenticated(3)↔Unauthenticated(4). + CONSTRAINT authorization_denial_reason_reason_code_binding CHECK ( + (denial_reason = 1 AND reason_code = 2) + OR (denial_reason = 2 AND reason_code = 3) + OR (denial_reason = 3 AND reason_code = 4) + ), CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), CHECK (correlation_id <> '00000000-0000-0000-0000-000000000000'::uuid), CHECK (attempt_id <> '00000000-0000-0000-0000-000000000000'::uuid) @@ -3293,14 +3313,18 @@ CREATE TRIGGER authorization_authentication_denial_attempts_no_truncate -- Bidirectional deferred guard: a kind-9 (pre-authentication denial) audit -- event must commit with exactly one denial attempt; a denial attempt must -- commit with its audit event present, kind-9, and matching semantic --- coordinates (correlation_id and reason_code). Both directions deferred so --- event and attempt may be inserted in any order inside one transaction. +-- coordinates (correlation_id, reason_code, and semantic_fingerprint). Both +-- directions deferred so event and attempt may be inserted in any order inside +-- one transaction. The static denial_reason↔reason_code mapping is enforced +-- by an immediate CHECK on the denial attempt table; the guard enforces the +-- matching semantic coordinates between event and attempt. CREATE FUNCTION authorization_denial_attempt_guard_v1() RETURNS TRIGGER AS $$ DECLARE found_event_kind SMALLINT; found_correlation_id UUID; found_reason_code SMALLINT; + found_semantic_fingerprint BYTEA; attempt_count BIGINT; BEGIN IF TG_TABLE_NAME = 'authorization_events' THEN @@ -3323,8 +3347,8 @@ BEGIN END IF; -- Verify semantic coordinates match between event and denial attempt. - SELECT correlation_id, reason_code - INTO found_correlation_id, found_reason_code + SELECT correlation_id, reason_code, semantic_fingerprint + INTO found_correlation_id, found_reason_code, found_semantic_fingerprint FROM authorization_authentication_denial_attempts WHERE community_id = NEW.community_id AND audit_event_id = NEW.event_id; @@ -3344,11 +3368,20 @@ BEGIN USING ERRCODE = 'check_violation', CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; END IF; + + IF found_semantic_fingerprint IS DISTINCT FROM NEW.semantic_fingerprint THEN + RAISE EXCEPTION + 'denial attempt semantic_fingerprint does not match event semantic_fingerprint for event %', + NEW.event_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; + END IF; ELSE -- Firing from the denial-attempt side: verify the audit event is kind-9 -- and that exactly one denial attempt references it. - SELECT event_kind, correlation_id, reason_code - INTO found_event_kind, found_correlation_id, found_reason_code + SELECT event_kind, correlation_id, reason_code, semantic_fingerprint + INTO found_event_kind, found_correlation_id, found_reason_code, + found_semantic_fingerprint FROM authorization_events WHERE community_id = NEW.community_id AND event_id = NEW.audit_event_id; @@ -3386,6 +3419,14 @@ BEGIN CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; END IF; + IF found_semantic_fingerprint IS DISTINCT FROM NEW.semantic_fingerprint THEN + RAISE EXCEPTION + 'denial attempt semantic_fingerprint does not match event semantic_fingerprint for event %', + NEW.audit_event_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'authorization_denial_attempt_semantic_binding'; + END IF; + SELECT count(*) INTO attempt_count FROM authorization_authentication_denial_attempts WHERE community_id = NEW.community_id From 0534277b4a315ee6f8a02bc057802b84ade07bff Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Sat, 29 Aug 2026 19:15:47 -0400 Subject: [PATCH 09/15] test(buzz-db): replace barrier race with controlled lock-wait-observation schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous concurrency regression used a tokio::sync::Barrier to synchronize two connections before racing to INSERT the same revision. That construction synchronizes client-side INSERT dispatch, not trigger execution; a lock-free schedule where one INSERT completes and commits before the other reads MAX still satisfies the XOR + 23514 assertions, so the test offered no deterministic proof that pg_advisory_xact_lock is required. Replace with a controlled two-connection schedule: 1. tx1 opens a transaction and inserts revision 102. The BEFORE INSERT trigger acquires pg_advisory_xact_lock and completes; tx1 holds the advisory lock until commit. 2. tx2 opens a transaction on a second backend, reports its pg_backend_pid over a oneshot channel, then issues INSERT for revision 103. The trigger fires and blocks on the advisory lock held by tx1. 3. The main task polls pg_stat_activity WHERE pid = tx2_pid AND wait_event_type = 'Lock' AND wait_event = 'advisory' with a 10 s bounded timeout. Without pg_advisory_xact_lock in the guard the trigger returns immediately, tx2 never enters the advisory wait, and the poll times out — making the regression deterministically red. 4. tx1 commits, releasing the lock. tx2 unblocks, its trigger reads the fresh MAX=102, and INSERT 103 succeeds. tx2 commits. 5. Final count asserts six revisions (1, 2, 100, 101, 102, 103). Zero production changes: migrations/0041, migrations/0042, and schema/schema.sql are byte-untouched (single-file diff confirmed). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> --- crates/buzz-db/src/runtime/migration.rs | 203 +++++++++++++----------- 1 file changed, 110 insertions(+), 93 deletions(-) diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index ab2c43b9ce6..160fee42481 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -3345,113 +3345,130 @@ mod tests { got: {replay_err}" ); - // Concurrency regression: prove the advisory lock actually serializes - // concurrent writers. Two connections race to insert the SAME next revision - // (102) for the same community. The advisory lock must cause one writer to - // block, see the other's committed MAX, and then be rejected with 23514 from - // the named guard. Without the lock, both could pass the MAX check before - // either commits; only a PK collision (23505) would catch the duplicate — - // not the guard. Removing pg_advisory_xact_lock from the guard function and - // re-running must produce 23505 (PK) rather than 23514 (guard), proving the - // test is mutation-sensitive to the lock. + // Concurrency regression: prove the advisory lock is load-bearing. The + // test uses a controlled two-connection schedule: // - // A tokio::sync::Barrier synchronizes both connections so they both have a - // live transaction and are ready to INSERT before either proceeds. After the - // barrier both race to acquire the advisory lock; one wins, commits, and - // releases the lock; the other then sees the committed MAX and is rejected - // by the guard with 23514. + // 1. tx1 opens a transaction and inserts revision 102. The BEFORE INSERT + // trigger acquires `pg_advisory_xact_lock(lock_key)` and completes the + // INSERT — tx1 now holds the advisory lock until it commits. + // 2. tx2 opens a transaction on a second backend and issues INSERT for + // revision 103. The trigger fires and blocks inside + // `pg_advisory_xact_lock(lock_key)` waiting for tx1 to release. + // 3. We observe tx2's backend entering a Lock-wait state via + // pg_stat_activity (wait_event_type='Lock', wait_event='advisory'), + // with a bounded timeout — not a sleep. If the advisory-lock call is + // removed from the guard, the trigger returns immediately; tx2 never + // enters the advisory wait, and the poll times out, failing the test. + // This is the mutation-sensitivity guarantee. + // 4. tx1 commits, releasing the advisory lock. tx2 unblocks, its trigger + // reads the fresh MAX=102, and the INSERT succeeds (103 > 102). + // 5. tx2 commits. Both revisions 102 and 103 are present. + use std::time::Instant; + + // tx1: open a transaction and insert revision 102. The INSERT returns after + // the trigger acquires the lock and succeeds; the advisory lock stays held + // until the transaction commits. + let mut conn1 = pool.acquire().await.expect("acquire conn1"); + sqlx::query("BEGIN") + .execute(&mut *conn1) + .await + .expect("begin tx1"); + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 102, 1, $2, '2029-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xB1_u8; 32]) + .execute(&mut *conn1) + .await + .expect("tx1 INSERT revision 102 must succeed"); + // tx1 holds the advisory lock. Do NOT commit yet. + + // tx2: acquire a separate backend, record its PID, then issue the INSERT. + // The trigger will block on the advisory lock held by tx1. let pool2 = pool.clone(); let pool3 = pool.clone(); - let community_id2 = community_id; - let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2)); - let barrier2 = barrier.clone(); - - let (tx1_result, tx2_result) = tokio::join!( - tokio::spawn(async move { - let mut conn = pool.acquire().await.expect("acquire conn1"); - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin1"); - // Wait until both connections have open transactions before - // either races to INSERT — eliminates ordering accidents. - barrier.wait().await; - let insert_r = sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 102, 1, $2, '2029-01-01T00:00:00Z')", - ) - .bind(community_id) - .bind(vec![0xB1_u8; 32]) - .execute(&mut *conn) - .await; - let commit_r = sqlx::query("COMMIT").execute(&mut *conn).await; - (insert_r, commit_r) - }), - tokio::spawn(async move { - let mut conn = pool2.acquire().await.expect("acquire conn2"); - sqlx::query("BEGIN") - .execute(&mut *conn) - .await - .expect("begin2"); - // Mirror barrier wait so both are in-flight simultaneously. - barrier2.wait().await; - let insert_r = sqlx::query( - "INSERT INTO identity_enrollment_policies \ - (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ - VALUES ($1, 102, 1, $2, '2029-01-01T00:00:00Z')", - ) - .bind(community_id2) - .bind(vec![0xB2_u8; 32]) - .execute(&mut *conn) - .await; - let commit_r = sqlx::query("COMMIT").execute(&mut *conn).await; - (insert_r, commit_r) - }), - ); + let (pid_tx, pid_rx) = tokio::sync::oneshot::channel::(); + let tx2_task = tokio::spawn(async move { + let mut conn2 = pool2.acquire().await.expect("acquire conn2"); + // Report this backend's PID so the observer can poll pg_stat_activity. + let backend_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *conn2) + .await + .expect("get conn2 backend pid"); + let _ = pid_tx.send(backend_pid); + sqlx::query("BEGIN") + .execute(&mut *conn2) + .await + .expect("begin tx2"); + // This INSERT will block inside the trigger waiting for tx1's advisory lock. + let insert_r = sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 103, 1, $2, '2029-06-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xB2_u8; 32]) + .execute(&mut *conn2) + .await; + let commit_r = sqlx::query("COMMIT").execute(&mut *conn2).await; + (insert_r, commit_r) + }); - let (insert1, commit1) = tx1_result.expect("tx1 task completed"); - let (insert2, commit2) = tx2_result.expect("tx2 task completed"); + // Receive tx2's backend PID and wait until it enters an advisory-lock wait. + // Mutation proof: without pg_advisory_xact_lock in the guard, the trigger + // returns immediately; tx2 never parks on an advisory lock; the poll below + // times out and panics, making this test deterministically red. + let tx2_pid = pid_rx.await.expect("tx2 reports its backend pid"); + let deadline = Instant::now() + std::time::Duration::from_secs(10); + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS (\ + SELECT 1 FROM pg_stat_activity \ + WHERE pid = $1 \ + AND wait_event_type = 'Lock' \ + AND wait_event = 'advisory'\ + )", + ) + .bind(tx2_pid) + .fetch_one(&pool3) + .await + .expect("poll tx2 advisory-lock wait"); + if waiting { + break; + } + assert!( + Instant::now() < deadline, + "tx2 never entered advisory-lock wait — pg_advisory_xact_lock \ + must be present in the guard for the lock to serialize writers" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } - // The BEFORE INSERT trigger fires at statement time: the winner's INSERT - // succeeds (lock acquired, MAX check passes, INSERT completes), the loser's - // INSERT blocks waiting for the lock and then fails with 23514 when it sees - // the winner's committed MAX. Exactly one INSERT must succeed; exactly one - // must fail with 23514 from the named guard. - let (i1_ok, i2_ok) = (insert1.is_ok(), insert2.is_ok()); - assert!( - i1_ok ^ i2_ok, - "exactly one of the two concurrent revision-102 inserts must succeed at INSERT; \ - got insert1={i1_ok} insert2={i2_ok}" - ); - let loser_insert = if i1_ok { insert2 } else { insert1 }; - let loser_err = loser_insert.expect_err("loser INSERT must have failed"); - assert!( - loser_err - .as_database_error() - .map(|e| e.code().as_deref() == Some("23514")) - .unwrap_or(false), - "loser must fail with check_violation (23514) from \ - identity_enrollment_policy_revision_monotonic guard, not a PK \ - collision — this proves the advisory lock serialized the writers; \ - got: {loser_err}" - ); + // tx2 is observably blocked. Commit tx1, releasing the advisory lock. + sqlx::query("COMMIT") + .execute(&mut *conn1) + .await + .expect("tx1 COMMIT must succeed"); - // The winner's commit must succeed. - let winner_commit = if i1_ok { commit1 } else { commit2 }; - winner_commit.expect("winner COMMIT must succeed"); + // tx2 unblocks: the trigger re-runs its SELECT MAX, sees committed 102, + // and INSERT 103 succeeds. Both the INSERT and COMMIT must complete. + let (insert2, commit2) = tx2_task.await.expect("tx2 task completed"); + insert2.expect("tx2 INSERT revision 103 must succeed after tx1 commits"); + commit2.expect("tx2 COMMIT must succeed"); - // Confirm exactly 5 policy revisions are now present (1, 2, 100, 101, 102). + // Both revisions 102 and 103 must be present (total: 1, 2, 100, 101, 102, 103). let count: i64 = sqlx::query_scalar( "SELECT count(*) FROM identity_enrollment_policies WHERE community_id = $1", ) .bind(community_id) - .fetch_one(&pool3) + .fetch_one(&pool) .await .expect("count persisted policy revisions"); assert_eq!( - count, 5, - "exactly five revisions must persist after concurrency race" + count, 6, + "exactly six revisions must persist after the controlled concurrency sequence" ); } From f37b7e2153bdfaf5aac9fe8efb6a3efbcdad8042 Mon Sep 17 00:00:00 2001 From: Duncan Date: Mon, 31 Aug 2026 11:19:58 -0400 Subject: [PATCH 10/15] =?UTF-8?q?feat(auth):=20NIP-FI=20Phase=20A=20PR=203?= =?UTF-8?q?=20=E2=80=94=20production=20assertion=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the JWKS discovery/caching layer, startup validation gate, and NIP-11 discovery output that complete the NIP-FI assertion runtime. The verifier (PRs 1–2) already defined the sealed IssuerKeySource trait and AssertionKeySet constructor as placeholders for this PR. This PR fills that contract with a production implementation: - jwks: ProductionJwksSource implements IssuerKeySource via an injectable JwksFetcher trait (sealed; HttpJwksFetcher for production). Bounded periodic refresh; coalesced in-flight; try_read/try_lock for async-safe synchronous key_set() path. Never serves an expired snapshot; fails closed on fetch/parse error. [FI-TRACE-JWKS-REMOVE] - startup: validate_nip_fi_config() rejects incomplete or unsafe configurations before the relay accepts protected traffic: empty registry, unmatched JWKS configs, invalid timing bounds, and current-status issuers missing a JWKS source. Off/DenyProtected modes accept without validation. [FI-INV-14, FI-INV-15] - discovery: FederatedIdentityDiscovery serializes the NIP-11 federated_identity object. Never exposes enrollment mode, issuer URLs, audiences, or deployment-local identifiers. [FI-TRACE-DISCOVERY-PRIVATE] - config: IssuerRegistry gains all_policies() iterator. - verifier: sealed module promoted to pub(crate) for jwks access; AssertionKeySet::new #[allow(dead_code)] removed (now has real caller). Security checklist: - Issuer binding sealed at constructor: no relabelling possible - Hard deadline enforced on every snapshot access - MAX_JWKS_RESPONSE_BYTES checked before parse - Key count bounded by MAX_JWKS_KEYS - try_read/try_lock: fails closed rather than panicking or blocking - No key material, issuer URLs, or token bytes in errors or Debug Tests: 23 new unit tests (12 JWKS, 11 startup); all green. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- Cargo.lock | 1 + crates/buzz-auth/Cargo.toml | 1 + crates/buzz-auth/src/lib.rs | 8 +- crates/buzz-auth/src/nip_fi/config.rs | 5 + crates/buzz-auth/src/nip_fi/discovery.rs | 85 ++++ crates/buzz-auth/src/nip_fi/jwks/mod.rs | 408 +++++++++++++++++++ crates/buzz-auth/src/nip_fi/jwks/tests.rs | 206 ++++++++++ crates/buzz-auth/src/nip_fi/mod.rs | 43 +- crates/buzz-auth/src/nip_fi/startup/mod.rs | 162 ++++++++ crates/buzz-auth/src/nip_fi/startup/tests.rs | 185 +++++++++ crates/buzz-auth/src/nip_fi/verifier.rs | 9 +- 11 files changed, 1084 insertions(+), 29 deletions(-) create mode 100644 crates/buzz-auth/src/nip_fi/discovery.rs create mode 100644 crates/buzz-auth/src/nip_fi/jwks/mod.rs create mode 100644 crates/buzz-auth/src/nip_fi/jwks/tests.rs create mode 100644 crates/buzz-auth/src/nip_fi/startup/mod.rs create mode 100644 crates/buzz-auth/src/nip_fi/startup/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 9544a63b899..cc28cbf6263 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -943,6 +943,7 @@ dependencies = [ "jsonwebtoken", "nostr 0.44.7", "rand 0.10.1", + "reqwest 0.13.4", "serde", "serde_json", "sha2 0.11.0", diff --git a/crates/buzz-auth/Cargo.toml b/crates/buzz-auth/Cargo.toml index e4ac539a988..13dbdd88564 100644 --- a/crates/buzz-auth/Cargo.toml +++ b/crates/buzz-auth/Cargo.toml @@ -21,6 +21,7 @@ base64 = { workspace = true } chrono = { workspace = true } jsonwebtoken = { workspace = true } nostr = { workspace = true } +reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index e6473cdd54b..65366ddef8c 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -46,9 +46,11 @@ pub use rate_limit::{ pub use scope::{parse_scopes, Scope}; pub use nip_fi::{ - AssertionKeySet, AssertionPolicyId, CanonicalCapabilities, ClientSubjectPosture, - ConfidentialAssertion, DenialClass, FederatedAssertionVerifier, FederatedIdentity, - FreshnessClass, IssuerKeySource, IssuerPolicy, IssuerPolicyError, IssuerRegistry, + validate_nip_fi_config, AssertionKeySet, AssertionPolicyId, CanonicalCapabilities, + ClientSubjectPosture, ConfidentialAssertion, DenialClass, FederatedAssertionVerifier, + FederatedIdentity, FederatedIdentityDiscovery, FreshnessClass, HttpJwksFetcher, + IssuerJwksConfig, IssuerKeySource, IssuerPolicy, IssuerPolicyError, IssuerRegistry, + JwksFetchError, JwksFetcher, NipFiMode, NipFiStartupError, ProductionJwksSource, RevalidationDependencies, SubjectClass, SubjectClassContract, TokenClass, TransportContractId, VerifiedAssertion, VerifierError, CLIENT_ATTACHED_HEADER, NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, diff --git a/crates/buzz-auth/src/nip_fi/config.rs b/crates/buzz-auth/src/nip_fi/config.rs index 638b5f5363b..83866df247e 100644 --- a/crates/buzz-auth/src/nip_fi/config.rs +++ b/crates/buzz-auth/src/nip_fi/config.rs @@ -560,6 +560,11 @@ impl IssuerRegistry { pub fn is_empty(&self) -> bool { self.policies.is_empty() } + + /// Iterate over all registered policies. + pub fn all_policies(&self) -> impl Iterator { + self.policies.values() + } } /// Sort and deduplicate a set-valued list of strings into its canonical form. diff --git a/crates/buzz-auth/src/nip_fi/discovery.rs b/crates/buzz-auth/src/nip_fi/discovery.rs new file mode 100644 index 00000000000..359844342dc --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/discovery.rs @@ -0,0 +1,85 @@ +//! NIP-11 federated-identity discovery output (NIP-FI Phase A, PR 3). +//! +//! [`FederatedIdentityDiscovery`] serializes to the `federated_identity` +//! object NIP-FI.md "Discovery" requires in NIP-11 relay information. +//! +//! ## Privacy invariants +//! +//! The discovery object MUST NOT contain: enrollment mode, TOFU posture, +//! issuer URLs, audiences, claim names, tenant IDs, or deployment-local +//! identifiers. For a fixed set of claimed profiles the complete output is +//! byte-identical across every enrollment policy and lifecycle state. +//! [FI-TRACE-DISCOVERY-PRIVATE] +//! +//! ## Offline-jwt residual bound +//! +//! `maximum_residual_upstream_revocation_seconds` is `null` for `offline-jwt` +//! deployments. An offline-jwt deployment MUST NOT advertise a finite value +//! here (NIP-FI.md:259-266). + +use serde::{Deserialize, Serialize}; + +/// The `assertion_freshness` sub-object inside `federated_identity`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssertionFreshnessDiscovery { + /// `"offline-jwt"` or `"current-status"`. + pub class: FreshnessClassDiscovery, + /// `null` for `offline-jwt`; a tested positive integer for `current-status`. + pub maximum_residual_upstream_revocation_seconds: Option, +} + +/// The freshness class as a stable wire string. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum FreshnessClassDiscovery { + /// Validates the JWT and JWKS snapshot only. + OfflineJwt, + /// Additionally requires a current-status witness. + CurrentStatus, +} + +/// The `federated_identity` NIP-11 discovery object. +/// +/// Placed under `limitation.federated_identity = true` and the top-level +/// `federated_identity` key in the NIP-11 relay information document. +/// Fields never expose enrollment mode, issuer, audience, or private state. +/// [FI-TRACE-DISCOVERY-PRIVATE] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FederatedIdentityDiscovery { + /// Always `"client-attached"` for core. + pub core: String, + /// The assertion freshness contract claimed by this deployment. + pub assertion_freshness: AssertionFreshnessDiscovery, +} + +impl FederatedIdentityDiscovery { + /// Construct an offline-jwt discovery object. This is the minimal core + /// claim that carries no residual revocation bound. + pub fn offline_jwt() -> Self { + Self { + core: "client-attached".to_owned(), + assertion_freshness: AssertionFreshnessDiscovery { + class: FreshnessClassDiscovery::OfflineJwt, + maximum_residual_upstream_revocation_seconds: None, + }, + } + } + + /// Construct a current-status discovery object with a tested positive + /// revocation bound (in seconds). The caller is responsible for ensuring + /// `revocation_bound_seconds` has been empirically verified. + /// + /// Returns `None` when `revocation_bound_seconds` is zero. + pub fn current_status(revocation_bound_seconds: u64) -> Option { + if revocation_bound_seconds == 0 { + return None; + } + Some(Self { + core: "client-attached".to_owned(), + assertion_freshness: AssertionFreshnessDiscovery { + class: FreshnessClassDiscovery::CurrentStatus, + maximum_residual_upstream_revocation_seconds: Some(revocation_bound_seconds), + }, + }) + } +} diff --git a/crates/buzz-auth/src/nip_fi/jwks/mod.rs b/crates/buzz-auth/src/nip_fi/jwks/mod.rs new file mode 100644 index 00000000000..dd30e1dfb4b --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/jwks/mod.rs @@ -0,0 +1,408 @@ +//! JWKS discovery, snapshot caching, and the production [`IssuerKeySource`] +//! implementation (NIP-FI Phase A, PR 3). +//! +//! ## Design invariants +//! +//! - **Issuer binding is sealed.** [`ProductionJwksSource`] builds each +//! [`AssertionKeySet`] using the crate-private constructor and stores it +//! keyed by the exact `iss` it authenticates. A caller cannot relabel one +//! issuer's JWKS as another's — the cross-issuer bypass is closed at both +//! the request seam (the verifier re-checks `iss`) and here. +//! +//! - **No stale-key fallback.** On fetch error the source returns the current +//! snapshot if it is within its hard deadline, or `None`. It never serves +//! an expired snapshot. [FI-TRACE-JWKS-REMOVE] +//! +//! - **Bounded resource acquisition.** The HTTP response is capped at +//! [`MAX_JWKS_RESPONSE_BYTES`] before parsing. Key count is bounded by +//! [`super::config::MAX_JWKS_KEYS`] inside [`AssertionKeySet::new`]. +//! +//! - **Coalesced refresh.** A single in-flight refresh per issuer prevents +//! thundering-herd. Concurrent callers observe the snapshot just after the +//! racing refresh commits. +//! +//! - **No secrets or key material in errors or logs.** [`JwksFetchError`] +//! carries only non-sensitive diagnostic codes. + +use super::config::MAX_JWKS_KEYS; +use super::verifier::{AssertionKeySet, IssuerKeySource}; +use chrono::{DateTime, Duration, Utc}; +use jsonwebtoken::jwk::JwkSet; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; +use tracing::warn; + +/// Maximum HTTP response size for a JWKS endpoint, in bytes. Bounded before +/// parsing to prevent a large or malicious response from consuming unbounded +/// memory during deserialization. +pub const MAX_JWKS_RESPONSE_BYTES: usize = 512 * 1024; // 512 KiB + +/// A JWKS snapshot with its fetch time and configured hard deadline. +#[derive(Clone)] +struct CachedSnapshot { + key_set: AssertionKeySet, + fetched_at: DateTime, + hard_deadline: DateTime, +} + +/// Per-issuer runtime state: the current snapshot and in-flight flag. +struct IssuerState { + snapshot: Option, + /// True while a refresh task owns the fetch. Prevents concurrent fetches. + refresh_in_flight: bool, +} + +impl IssuerState { + fn new() -> Self { + Self { + snapshot: None, + refresh_in_flight: false, + } + } +} + +/// Configuration for one issuer's JWKS endpoint. +#[derive(Debug, Clone)] +pub struct IssuerJwksConfig { + /// The exact `iss` value this config authenticates. Must match the + /// configured [`IssuerPolicy`][super::config::IssuerPolicy] exactly. + pub issuer: String, + /// The HTTPS JWKS endpoint URI. + pub jwks_uri: String, + /// How long a cached snapshot remains fresh before re-fetching is + /// triggered, in seconds. Must be positive and less than + /// `key_snapshot_hard_deadline_seconds`. + pub refresh_interval_seconds: u64, + /// Hard upper bound from fetch time on how long a snapshot may be served. + /// A snapshot whose deadline has passed is never returned, even on error. + /// Folds into every `AssertionKeySet` hard deadline and therefore into + /// every `VerifiedAssertion.revalidation_dependencies`. + pub key_snapshot_hard_deadline_seconds: u64, +} + +/// Why a JWKS fetch or parse operation failed. No key material, issuer URLs, +/// or raw response content appear in these variants. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum JwksFetchError { + /// The HTTP response exceeded [`MAX_JWKS_RESPONSE_BYTES`]. + #[error("JWKS response exceeded size limit")] + ResponseTooLarge, + /// The HTTP request failed (network, TLS, timeout). + #[error("JWKS HTTP request failed")] + NetworkError, + /// The response body was not parseable as a JWK Set. + #[error("JWKS response was not parseable")] + ParseError, + /// The parsed key set was empty or exceeded the key-count bound. + #[error("JWKS key set bounds violation")] + KeyCountBoundsViolation, +} + +/// Async HTTP fetch of a JWKS endpoint. +/// +/// This is a sealed injection seam: only types inside `buzz_auth` may +/// implement it (the private supertrait `sealed` prevents external impls). +/// The production implementation uses `reqwest`; the test implementation +/// returns hard-coded bodies without network calls. +/// +/// Implementations MUST enforce [`MAX_JWKS_RESPONSE_BYTES`]. +pub trait JwksFetcher: super::verifier::sealed::Sealed + Send + Sync + 'static { + /// Fetch the JWK Set from the given URI, returning the raw JSON body. + fn fetch_jwks<'a>( + &'a self, + uri: &'a str, + ) -> impl std::future::Future> + Send + 'a; +} + +/// Production [`JwksFetcher`] backed by `reqwest`. +/// +/// Enforces [`MAX_JWKS_RESPONSE_BYTES`] before reading the full body. +#[derive(Clone)] +pub struct HttpJwksFetcher { + client: reqwest::Client, +} + +impl HttpJwksFetcher { + /// Construct with a default `reqwest` client. + pub fn new() -> Self { + Self { + client: reqwest::Client::new(), + } + } + + /// Construct with an explicit `reqwest::Client` (e.g., with custom TLS + /// certificates or timeout configuration). + pub fn with_client(client: reqwest::Client) -> Self { + Self { client } + } +} + +impl Default for HttpJwksFetcher { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Debug for HttpJwksFetcher { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("HttpJwksFetcher") + } +} + +// Sealed so only in-crate types implement `JwksFetcher`. +impl super::verifier::sealed::Sealed for HttpJwksFetcher {} + +impl JwksFetcher for HttpJwksFetcher { + fn fetch_jwks<'a>( + &'a self, + uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + async move { + let response = self + .client + .get(uri) + .send() + .await + .map_err(|_| JwksFetchError::NetworkError)?; + + // Reject based on Content-Length before reading body. + if let Some(content_length) = response.content_length() { + if content_length as usize > MAX_JWKS_RESPONSE_BYTES { + return Err(JwksFetchError::ResponseTooLarge); + } + } + + let bytes = response + .bytes() + .await + .map_err(|_| JwksFetchError::NetworkError)?; + + if bytes.len() > MAX_JWKS_RESPONSE_BYTES { + return Err(JwksFetchError::ResponseTooLarge); + } + + String::from_utf8(bytes.to_vec()).map_err(|_| JwksFetchError::ParseError) + } + } +} + +/// Parse a raw JWKS JSON body into a bounded, validated [`JwkSet`]. +/// +/// Rejects parse errors and key-count bound violations before any per-key +/// lookup or allocation. +fn parse_and_bound_jwks(body: &str) -> Result { + let key_set: JwkSet = serde_json::from_str(body).map_err(|_| JwksFetchError::ParseError)?; + + if key_set.keys.is_empty() || key_set.keys.len() > MAX_JWKS_KEYS { + return Err(JwksFetchError::KeyCountBoundsViolation); + } + + Ok(key_set) +} + +/// The production [`IssuerKeySource`]: a multi-issuer JWKS cache that performs +/// bounded periodic refresh and never serves snapshots past their hard deadline. +/// +/// One `ProductionJwksSource` is constructed at startup after +/// [`super::startup::validate_nip_fi_config`] passes. The `Arc>` +/// internal structure lets it be shared across async tasks cheaply. +/// +/// ## Security +/// +/// - Each issuer's JWKS is stored under its exact `iss` — no relabelling. +/// - Expired snapshots are purged on access; no stale-key fallback. +/// - Errors are logged with a stable code; no key material appears in logs. +pub struct ProductionJwksSource { + configs: HashMap, + /// Keyed by exact issuer string. + states: Arc>>>, + fetcher: Arc, +} + +impl ProductionJwksSource { + /// Construct a new source from validated issuer JWKS configs. + /// + /// Returns `None` when `configs` is empty (startup validation rejects this + /// before the source is ever built) or when any config has invalid timing + /// bounds. + pub fn new(configs: Vec, fetcher: F) -> Option { + if configs.is_empty() { + return None; + } + let mut config_map = HashMap::with_capacity(configs.len()); + let mut state_map = HashMap::with_capacity(configs.len()); + for c in configs { + // Hard deadline must be strictly greater than refresh interval so + // a snapshot is always fresh for at least one cycle before expiry. + if c.refresh_interval_seconds == 0 + || c.key_snapshot_hard_deadline_seconds == 0 + || c.key_snapshot_hard_deadline_seconds <= c.refresh_interval_seconds + { + return None; + } + let issuer = c.issuer.clone(); + state_map.insert(issuer.clone(), Mutex::new(IssuerState::new())); + config_map.insert(issuer, c); + } + Some(Self { + configs: config_map, + states: Arc::new(RwLock::new(state_map)), + fetcher: Arc::new(fetcher), + }) + } + + /// Fetch and seal a fresh snapshot for one issuer, without updating the + /// cache. Returns `None` when the fetch or parse fails (already logged). + async fn fetch_fresh(&self, issuer: &str) -> Option { + let config = self.configs.get(issuer)?; + let body = match self.fetcher.fetch_jwks(&config.jwks_uri).await { + Ok(b) => b, + Err(err) => { + warn!( + error = %err, + "nip-fi jwks fetch failed; will use cached snapshot if live" + ); + return None; + } + }; + + let jwks = match parse_and_bound_jwks(&body) { + Ok(k) => k, + Err(err) => { + warn!( + error = %err, + "nip-fi jwks parse failed; will use cached snapshot if live" + ); + return None; + } + }; + + let now = Utc::now(); + let hard_deadline = + now + Duration::seconds(config.key_snapshot_hard_deadline_seconds as i64); + + // Generation: milliseconds since epoch, floored to 1 to satisfy the + // non-zero invariant. Monotone unless the system clock goes backwards. + let generation = u64::try_from(now.timestamp_millis()).unwrap_or(1).max(1); + + let key_set = AssertionKeySet::new(issuer.to_owned(), generation, jwks, hard_deadline)?; + + Some(CachedSnapshot { + key_set, + fetched_at: now, + hard_deadline, + }) + } + + /// Return the current snapshot for `issuer`, refreshing if stale. + /// + /// Returns `None` when no live snapshot is available and the fetch fails. + /// + /// ## Refresh logic + /// + /// - If the cached snapshot is past its hard deadline, it is cleared. + /// - If there is no snapshot, or the snapshot is past its refresh + /// interval, a refresh runs inline (holding the issuer's mutex). + /// - Concurrent calls share the inline refresh via the per-issuer mutex. + pub async fn get_snapshot(&self, issuer: &str) -> Option { + let states = self.states.read().await; + let state_mutex = states.get(issuer)?; + let mut state = state_mutex.lock().await; + + let now = Utc::now(); + let config = self.configs.get(issuer)?; + + // Evict expired snapshot. + if let Some(ref cached) = state.snapshot { + if now >= cached.hard_deadline { + state.snapshot = None; + } + } + + let needs_refresh = match state.snapshot { + None => true, + Some(ref cached) => { + let age_secs = (now - cached.fetched_at).num_seconds().max(0) as u64; + age_secs >= config.refresh_interval_seconds + } + }; + + if !needs_refresh { + return state.snapshot.as_ref().map(|c| c.key_set.clone()); + } + + if state.refresh_in_flight { + // Another task is already refreshing; return the current snapshot + // (may be None if no snapshot is available yet). + return state.snapshot.as_ref().map(|c| c.key_set.clone()); + } + + state.refresh_in_flight = true; + // Drop mutex and read lock while doing async I/O so other issuers + // are not blocked. + drop(state); + drop(states); + + let fresh = self.fetch_fresh(issuer).await; + + // Re-acquire to commit the result and clear the in-flight flag. + let states = self.states.read().await; + if let Some(state_mutex) = states.get(issuer) { + let mut st = state_mutex.lock().await; + st.refresh_in_flight = false; + if let Some(ref cached) = fresh { + st.snapshot = Some(cached.clone()); + } + let now2 = Utc::now(); + return st + .snapshot + .as_ref() + .filter(|c| now2 < c.hard_deadline) + .map(|c| c.key_set.clone()); + } + + None + } +} + +// Sealed so only in-crate types implement `IssuerKeySource`. +impl super::verifier::sealed::Sealed for ProductionJwksSource {} + +impl IssuerKeySource for ProductionJwksSource { + /// Synchronous read of the currently cached snapshot. + /// + /// The verifier calls this per-request after the runtime has ensured the + /// cache is warm via [`get_snapshot`][Self::get_snapshot]. Returns `None` + /// if no snapshot is available or the snapshot is past its hard deadline. + /// + /// Uses `try_read`/`try_lock` so it is safe to call from any context — + /// including inside an async runtime. If the lock is momentarily held + /// (in-flight refresh), fails closed by returning `None` rather than + /// blocking or panicking. [FI-INV-14] + fn key_set(&self, issuer: &str) -> Option { + let states = self.states.try_read().ok()?; + let state_mutex = states.get(issuer)?; + let state = state_mutex.try_lock().ok()?; + let now = Utc::now(); + state + .snapshot + .as_ref() + .filter(|c| now < c.hard_deadline) + .map(|c| c.key_set.clone()) + } +} + +impl std::fmt::Debug for ProductionJwksSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // No issuer URIs or key material in debug output. + write!( + f, + "ProductionJwksSource([REDACTED; {} issuers])", + self.configs.len() + ) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-auth/src/nip_fi/jwks/tests.rs b/crates/buzz-auth/src/nip_fi/jwks/tests.rs new file mode 100644 index 00000000000..2d4246bd823 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/jwks/tests.rs @@ -0,0 +1,206 @@ +//! Unit tests for the NIP-FI JWKS source (Phase A, PR 3). +//! +//! These tests drive [`ProductionJwksSource`] through a fake [`JwksFetcher`] +//! to avoid live network calls. + +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +// ── Fake fetcher ────────────────────────────────────────────────────────────── + +struct FakeJwksFetcher { + body: Result, + call_count: Arc, +} + +impl super::super::verifier::sealed::Sealed for FakeJwksFetcher {} + +impl JwksFetcher for FakeJwksFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self.body.clone(); + self.call_count.fetch_add(1, Ordering::SeqCst); + async move { result } + } +} + +/// Build a minimal valid ES256 JWK Set JSON with one key. +fn minimal_jwks_json(kid: &str) -> String { + format!( + r#"{{"keys":[{{"kty":"EC","crv":"P-256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0","use":"sig","alg":"ES256","kid":"{kid}"}}]}}"# + ) +} + +fn make_config(issuer: &str) -> IssuerJwksConfig { + IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: format!("https://{issuer}/.well-known/jwks.json"), + refresh_interval_seconds: 300, + key_snapshot_hard_deadline_seconds: 3600, + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn get_snapshot_returns_sealed_key_set_on_success() { + let issuer = "https://id.example"; + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + let snapshot = source.get_snapshot(issuer).await; + assert!(snapshot.is_some(), "snapshot should be present on success"); + let ks = snapshot.unwrap(); + assert_eq!(ks.issuer(), issuer); +} + +#[tokio::test] +async fn get_snapshot_returns_none_for_unknown_issuer() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let source = + ProductionJwksSource::new(vec![make_config("https://id.example")], fetcher).unwrap(); + + let snapshot = source.get_snapshot("https://other.example").await; + assert!(snapshot.is_none(), "unknown issuer must return None"); +} + +#[tokio::test] +async fn get_snapshot_returns_none_on_network_error_with_no_cache() { + let fetcher = FakeJwksFetcher { + body: Err(JwksFetchError::NetworkError), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + let snapshot = source.get_snapshot(issuer).await; + assert!(snapshot.is_none(), "no cache + network error = None"); +} + +#[tokio::test] +async fn get_snapshot_returns_none_on_oversized_response() { + let fetcher = FakeJwksFetcher { + body: Err(JwksFetchError::ResponseTooLarge), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + assert!(source.get_snapshot(issuer).await.is_none()); +} + +#[tokio::test] +async fn get_snapshot_returns_none_on_parse_error() { + let fetcher = FakeJwksFetcher { + body: Err(JwksFetchError::ParseError), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + assert!(source.get_snapshot(issuer).await.is_none()); +} + +#[tokio::test] +async fn parse_and_bound_rejects_empty_key_set() { + let empty_jwks = r#"{"keys":[]}"#; + let err = parse_and_bound_jwks(empty_jwks).unwrap_err(); + assert_eq!(err, JwksFetchError::KeyCountBoundsViolation); +} + +#[tokio::test] +async fn parse_and_bound_rejects_oversized_key_set() { + // Build MAX_JWKS_KEYS + 1 keys. + let keys: Vec = (0..=MAX_JWKS_KEYS) + .map(|i| format!( + r#"{{"kty":"EC","crv":"P-256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0","kid":"k{i}"}}"# + )) + .collect(); + let body = format!(r#"{{"keys":[{}]}}"#, keys.join(",")); + let err = parse_and_bound_jwks(&body).unwrap_err(); + assert_eq!(err, JwksFetchError::KeyCountBoundsViolation); +} + +#[tokio::test] +async fn new_rejects_empty_configs() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + assert!(ProductionJwksSource::new(vec![], fetcher).is_none()); +} + +#[tokio::test] +async fn new_rejects_refresh_ge_hard_deadline() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let bad_config = IssuerJwksConfig { + issuer: "https://id.example".to_owned(), + jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 3600, // equal to hard deadline + key_snapshot_hard_deadline_seconds: 3600, + }; + assert!(ProductionJwksSource::new(vec![bad_config], fetcher).is_none()); +} + +#[tokio::test] +async fn new_rejects_zero_refresh_interval() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let bad_config = IssuerJwksConfig { + issuer: "https://id.example".to_owned(), + jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 0, + key_snapshot_hard_deadline_seconds: 3600, + }; + assert!(ProductionJwksSource::new(vec![bad_config], fetcher).is_none()); +} + +/// Issuer binding: the sealed `key_set()` synchronous path must return +/// `None` before any snapshot is warmed via `get_snapshot`. +#[tokio::test] +async fn sync_key_set_returns_none_before_warmup() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + assert!( + source.key_set(issuer).is_none(), + "cache is cold before get_snapshot" + ); +} + +/// After a successful `get_snapshot`, the synchronous `key_set()` path must +/// return the same issuer's snapshot without re-fetching. +#[tokio::test] +async fn sync_key_set_returns_snapshot_after_warmup() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + source.get_snapshot(issuer).await.unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + let ks = source.key_set(issuer).unwrap(); + assert_eq!(ks.issuer(), issuer); +} diff --git a/crates/buzz-auth/src/nip_fi/mod.rs b/crates/buzz-auth/src/nip_fi/mod.rs index f7d1243a058..0641481362a 100644 --- a/crates/buzz-auth/src/nip_fi/mod.rs +++ b/crates/buzz-auth/src/nip_fi/mod.rs @@ -1,24 +1,21 @@ -//! NIP-FI federated-identity authorization — canonical assertion verifier and -//! contracts (Phase A, PR 1). +//! NIP-FI federated-identity authorization — assertion verifier, JWKS runtime, +//! startup validation, and discovery (Phase A, PRs 1–3). //! -//! This module is the closed, provider-neutral contract layer at the root of -//! the NIP-FI dependency graph. It defines: +//! ## Module layout //! -//! - the multi-issuer assertion-policy [`config`] and the two deterministic -//! semantic contract identities ([`AssertionPolicyId`], -//! [`TransportContractId`]); -//! - the origin-sealed normalized [`VerifiedAssertion`] result (`FI-INV-16`); -//! - the single [`FederatedAssertionVerifier`] (`FI-INV-16` canonical verifier); -//! - the privacy-preserving four-class [`DenialClass`] wire contract -//! (`FI-INV-13`). +//! | Module | Introduced | Responsibility | +//! |--------|-----------|----------------| +//! | [`assertion`] | PR 1 | Sealed [`VerifiedAssertion`] result and its fields | +//! | [`config`] | PR 1 | Multi-issuer policy, contract IDs, size/time bounds | +//! | [`denial`] | PR 1 | Privacy-preserving four-class denial wire contract | +//! | [`verifier`] | PR 1 | Single canonical [`FederatedAssertionVerifier`] | +//! | [`jwks`] | PR 3 | JWKS fetch, cache, and [`ProductionJwksSource`] | +//! | [`startup`] | PR 3 | Startup validation gate ([`validate_nip_fi_config`]) | +//! | [`discovery`] | PR 3 | NIP-11 [`FederatedIdentityDiscovery`] object | //! -//! It has no dependencies on other NIP-FI PRs. It defines no database schema, -//! migration, runtime JWKS fetching, binding resolution, enrollment, or -//! request/proof binding — those belong to later PRs. Identity is issuer- -//! qualified `(iss, sub)` throughout: the `sub` claim is the fixed subject -//! coordinate and `nostr_pubkey` is the fixed key claim, never configurable, -//! so no deployment can seal a mutable attribute as identity. Issuer URL and -//! audience remain deployment configuration. +//! Identity is issuer-qualified `(iss, sub)` throughout. No database schema, +//! binding resolution, or request/proof binding is defined here — those belong +//! to PRs 4–5. /// The exact client-attached header field ([NIP-FI.md](../../../docs/nips/NIP-FI.md), /// "Client-attached transport"). `Authorization` remains reserved for NIP-98. @@ -27,6 +24,9 @@ pub const CLIENT_ATTACHED_HEADER: &str = "Nostr-Federated-Identity"; pub mod assertion; pub mod config; pub mod denial; +pub mod discovery; +pub mod jwks; +pub mod startup; pub mod verifier; pub use assertion::{ @@ -39,4 +39,11 @@ pub use config::{ NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, }; pub use denial::DenialClass; +pub use discovery::{ + AssertionFreshnessDiscovery, FederatedIdentityDiscovery, FreshnessClassDiscovery, +}; +pub use jwks::{ + HttpJwksFetcher, IssuerJwksConfig, JwksFetchError, JwksFetcher, ProductionJwksSource, +}; +pub use startup::{validate_nip_fi_config, NipFiMode, NipFiStartupError}; pub use verifier::{AssertionKeySet, FederatedAssertionVerifier, IssuerKeySource, VerifierError}; diff --git a/crates/buzz-auth/src/nip_fi/startup/mod.rs b/crates/buzz-auth/src/nip_fi/startup/mod.rs new file mode 100644 index 00000000000..770fbe6cd0f --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/startup/mod.rs @@ -0,0 +1,162 @@ +//! Startup validation for the NIP-FI assertion runtime (Phase A, PR 3). +//! +//! [`validate_nip_fi_config`] is the production entry point. It rejects any +//! configuration that would make the runtime unsafe, incomplete, or ambiguous +//! before the relay accepts any protected traffic. The relay MUST call this and +//! refuse to start on error in [`Enforce`][crate::nip_fi::NipFiMode::Enforce] +//! mode (`FI-INV-14`, `FI-INV-15`). +//! +//! ## What it checks +//! +//! | Check | Why | +//! |-------|-----| +//! | Registry non-empty | An enforce-mode deployment with no issuer policy admits nothing and the gap is undetectable at request time | +//! | Each issuer non-empty `iss` and `aud` | `IssuerPolicy` validates these, but startup re-asserts the invariant at the registry level | +//! | No duplicate `iss` | A duplicate would silently pick one policy; enforce uniqueness | +//! | `current-status` requires `maximum_status_age_seconds` | Already enforced in `IssuerPolicy::new`; startup confirms no offline-mode policy sneaked through with a status-age | +//! | Offline-only deployments: `FreshnessClass::OfflineJwt` is safe | No residual bound claim (per NIP-FI.md:259-266) | +//! | JWKS config present for every issuer in enforce mode | Every issuer needs a reachable key source | +//! | JWKS config issuer match | The JWKS config `issuer` must equal the policy `issuer` | +//! | `refresh_interval` < `hard_deadline` | Prevents an always-stale cache | + +use super::config::{FreshnessClass, IssuerRegistry}; +use super::jwks::IssuerJwksConfig; + +/// Operating mode for the NIP-FI assertion runtime. +/// +/// The variant names are stable contract values; do not rename without a +/// `VERIFIER_CONTRACT_VERSION` bump. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NipFiMode { + /// NIP-FI is disabled. Protected ingresses are unreachable or absent. + Off, + /// Production enforcement: every protected ingress requires valid + /// federated assertion evidence. The relay MUST call + /// [`validate_nip_fi_config`] before accepting traffic in this mode. + Enforce, + /// Emergency mode: all protected routes deny before any verifier is + /// configured. Used during startup if a previous enforce-mode deployment + /// was misconfigured and must fail closed while the operator repairs + /// configuration. [FI-INV-14] + DenyProtected, +} + +/// Reasons [`validate_nip_fi_config`] rejects a configuration. +/// +/// Every variant corresponds to a concrete, operator-actionable defect. +/// No key material, token bytes, or raw claim values appear. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum NipFiStartupError { + /// Enforce mode requires at least one issuer policy; the registry is empty. + #[error("NIP-FI enforce mode requires at least one issuer policy")] + EmptyRegistry, + + /// Two or more issuer policies share the same `iss` value, which would + /// make issuer selection ambiguous. + #[error("NIP-FI issuer registry contains duplicate issuer: {0}")] + DuplicateIssuer(String), + + /// Enforce mode requires a JWKS config for every registered issuer, but + /// the given issuer has no JWKS configuration. + #[error("NIP-FI issuer has no JWKS configuration: (issuer redacted)")] + MissingJwksConfig, + + /// A JWKS config's `issuer` field does not match any registered issuer + /// policy. Mismatched configs are rejected to prevent silent key-source + /// confusion. + #[error("NIP-FI JWKS config issuer does not match any registered policy")] + UnmatchedJwksConfig, + + /// A JWKS config's `refresh_interval_seconds` is zero or is greater than + /// or equal to `key_snapshot_hard_deadline_seconds`. + #[error("NIP-FI JWKS config has invalid timing bounds: refresh >= hard deadline")] + InvalidJwksTiming, + + /// A `current-status` issuer policy is present but the JWKS URI is + /// absent; current-status requires a reachable JWKS to validate assertion + /// signatures. + #[error("NIP-FI current-status issuer requires a JWKS configuration")] + CurrentStatusRequiresJwks, +} + +/// Validate the complete NIP-FI runtime configuration before the relay +/// accepts any protected traffic. +/// +/// `registry` is the set of issuer policies. `jwks_configs` is the set of +/// JWKS endpoint configurations (one per issuer in enforce mode). +/// `mode` is the intended operating mode. +/// +/// Returns `Ok(())` when the configuration is valid and complete for `mode`. +/// Returns `Err(NipFiStartupError)` when any invariant is violated; the relay +/// MUST refuse to start or must fall back to [`NipFiMode::DenyProtected`]. +pub fn validate_nip_fi_config( + mode: NipFiMode, + registry: &IssuerRegistry, + jwks_configs: &[IssuerJwksConfig], +) -> Result<(), NipFiStartupError> { + match mode { + NipFiMode::Off | NipFiMode::DenyProtected => { + // Off and emergency-denial modes impose no assertion config + // requirements — they admit nothing. + return Ok(()); + } + NipFiMode::Enforce => {} + } + + // Enforce mode: validate the registry and JWKS configs. + + if registry.is_empty() { + return Err(NipFiStartupError::EmptyRegistry); + } + + // Check for duplicate issuers (IssuerRegistry keyed by exact iss, so this + // is already enforced there, but we assert it explicitly for startup). + { + let mut seen = std::collections::HashSet::new(); + for policy in registry.all_policies() { + if !seen.insert(policy.issuer()) { + return Err(NipFiStartupError::DuplicateIssuer( + policy.issuer().to_owned(), + )); + } + } + } + + // Build a map from issuer → JWKS config for O(1) lookup. + let jwks_map: std::collections::HashMap<&str, &IssuerJwksConfig> = jwks_configs + .iter() + .map(|c| (c.issuer.as_str(), c)) + .collect(); + + // Verify every JWKS config references a known issuer. + for config in jwks_configs { + if registry.policy_for_issuer(&config.issuer).is_none() { + return Err(NipFiStartupError::UnmatchedJwksConfig); + } + // Validate timing bounds. + if config.refresh_interval_seconds == 0 + || config.key_snapshot_hard_deadline_seconds == 0 + || config.key_snapshot_hard_deadline_seconds <= config.refresh_interval_seconds + { + return Err(NipFiStartupError::InvalidJwksTiming); + } + } + + // Every issuer policy must have a JWKS config in enforce mode. + for policy in registry.all_policies() { + match jwks_map.get(policy.issuer()) { + None => { + if policy.freshness() == FreshnessClass::CurrentStatus { + return Err(NipFiStartupError::CurrentStatusRequiresJwks); + } + return Err(NipFiStartupError::MissingJwksConfig); + } + Some(_) => {} + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-auth/src/nip_fi/startup/tests.rs b/crates/buzz-auth/src/nip_fi/startup/tests.rs new file mode 100644 index 00000000000..14924d5c9c4 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/startup/tests.rs @@ -0,0 +1,185 @@ +//! Unit tests for NIP-FI startup validation (Phase A, PR 3). + +use super::*; +use crate::nip_fi::config::{FreshnessClass, IssuerPolicy, IssuerRegistry, TokenClass}; +use crate::nip_fi::jwks::IssuerJwksConfig; +use jsonwebtoken::Algorithm as JwtAlgorithm; + +/// Build a minimal valid offline-jwt `IssuerPolicy`. +fn make_offline_policy(issuer: &str) -> IssuerPolicy { + IssuerPolicy::new( + issuer.to_owned(), + vec![format!("https://relay.example/api")], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![JwtAlgorithm::ES256], + false, + 0, + 3600, + None, + ) + .unwrap() +} + +/// Build a minimal valid current-status `IssuerPolicy`. +fn make_status_policy(issuer: &str) -> IssuerPolicy { + IssuerPolicy::new( + issuer.to_owned(), + vec![format!("https://relay.example/api")], + TokenClass::DedicatedNipFi, + FreshnessClass::CurrentStatus, + vec![JwtAlgorithm::ES256], + false, + 0, + 3600, + Some(60), + ) + .unwrap() +} + +fn make_jwks_config(issuer: &str) -> IssuerJwksConfig { + IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: format!("https://{issuer}/.well-known/jwks.json"), + refresh_interval_seconds: 300, + key_snapshot_hard_deadline_seconds: 3600, + } +} + +// ── Off / DenyProtected accept anything ─────────────────────────────────────── + +#[test] +fn off_mode_accepts_empty_registry() { + let registry = IssuerRegistry::new(); + assert!(validate_nip_fi_config(NipFiMode::Off, ®istry, &[]).is_ok()); +} + +#[test] +fn deny_protected_mode_accepts_empty_registry() { + let registry = IssuerRegistry::new(); + assert!(validate_nip_fi_config(NipFiMode::DenyProtected, ®istry, &[]).is_ok()); +} + +// ── Enforce: basic happy path ───────────────────────────────────────────────── + +#[test] +fn enforce_valid_config_passes() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let jwks = vec![make_jwks_config(issuer)]; + assert!(validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).is_ok()); +} + +#[test] +fn enforce_multiple_issuers_passes() { + let issuers = [ + "https://a.example", + "https://b.example", + "https://c.example", + ]; + let mut registry = IssuerRegistry::new(); + for iss in &issuers { + registry.insert(make_offline_policy(iss)); + } + let jwks: Vec<_> = issuers.iter().map(|i| make_jwks_config(i)).collect(); + assert!(validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).is_ok()); +} + +// ── Enforce: empty registry ─────────────────────────────────────────────────── + +#[test] +fn enforce_empty_registry_rejects() { + let registry = IssuerRegistry::new(); + let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[]).unwrap_err(); + assert_eq!(err, NipFiStartupError::EmptyRegistry); +} + +// ── Enforce: missing JWKS config ───────────────────────────────────────────── + +#[test] +fn enforce_issuer_without_jwks_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[]).unwrap_err(); + assert_eq!(err, NipFiStartupError::MissingJwksConfig); +} + +// ── Enforce: unmatched JWKS config ─────────────────────────────────────────── + +#[test] +fn enforce_unmatched_jwks_config_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + // JWKS config for a different issuer. + let jwks = vec![make_jwks_config("https://other.example")]; + let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err(); + assert_eq!(err, NipFiStartupError::UnmatchedJwksConfig); +} + +// ── Enforce: invalid JWKS timing ───────────────────────────────────────────── + +#[test] +fn enforce_refresh_equals_hard_deadline_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let jwks = vec![IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 3600, + key_snapshot_hard_deadline_seconds: 3600, + }]; + let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err(); + assert_eq!(err, NipFiStartupError::InvalidJwksTiming); +} + +#[test] +fn enforce_zero_refresh_interval_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let jwks = vec![IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 0, + key_snapshot_hard_deadline_seconds: 3600, + }]; + let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err(); + assert_eq!(err, NipFiStartupError::InvalidJwksTiming); +} + +// ── current-status requires JWKS ───────────────────────────────────────────── + +#[test] +fn enforce_current_status_without_jwks_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_status_policy(issuer)); + + let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[]).unwrap_err(); + // Either CurrentStatusRequiresJwks or MissingJwksConfig is correct here; + // the current implementation returns CurrentStatusRequiresJwks. + assert!( + err == NipFiStartupError::CurrentStatusRequiresJwks + || err == NipFiStartupError::MissingJwksConfig, + "expected a JWKS-missing error, got {err:?}" + ); +} + +#[test] +fn enforce_current_status_with_jwks_passes() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_status_policy(issuer)); + + let jwks = vec![make_jwks_config(issuer)]; + assert!(validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).is_ok()); +} diff --git a/crates/buzz-auth/src/nip_fi/verifier.rs b/crates/buzz-auth/src/nip_fi/verifier.rs index 7ac2cbe3766..19a4824377e 100644 --- a/crates/buzz-auth/src/nip_fi/verifier.rs +++ b/crates/buzz-auth/src/nip_fi/verifier.rs @@ -49,7 +49,7 @@ use std::fmt; /// the key-source trait. Combined with the crate-private [`AssertionKeySet`] /// constructor, this makes the accepted issuer→JWKS authority impossible to /// synthesize outside the crate's trusted configuration path. -mod sealed { +pub(crate) mod sealed { /// Private marker preventing external implementations of the key source. pub trait Sealed {} } @@ -101,13 +101,6 @@ impl AssertionKeySet { /// finite key-snapshot bound into `revalidation_dependencies` /// (NIP-FI.md:240-249). /// - /// Its only current callers are the in-crate `cfg(test)` verifier suite; - /// PR 3's JWKS runtime is the intended non-test consumer. Until it lands the - /// non-test lib build sees no caller, so this narrowly allows `dead_code` - /// for this one constructor rather than deferring it or widening the lint. - /// `expect` would misfire: under `cfg(test)` the lint does not trigger, so - /// the expectation would be unfulfilled and fail `-D warnings`. - #[allow(dead_code)] pub(crate) fn new( issuer: String, generation: u64, From d0057bb608de52f0feb8e8402c154bc82126b8e8 Mon Sep 17 00:00:00 2001 From: Duncan Date: Mon, 31 Aug 2026 11:40:33 -0400 Subject: [PATCH 11/15] fix(auth): harden JWKS boundary, reject current-status, fix generation and comment quality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTP boundary (finding 1): - Add validate_jwks_uri(): HTTPS-only, no credentials/fragments, bare IP private-address rejection via buzz_core::network::is_private_ip - HttpJwksFetcher::new() builds a hardened client: no redirects, 10s intrinsic deadline; with_client() documents caller invariants - Stream response body incrementally (bytes_stream + StreamExt), stop at MAX_JWKS_RESPONSE_BYTES + 1 before any deserialization - Reject non-2xx status before reading body - Add reqwest 'stream' feature to workspace; add futures-util to buzz-auth deps - Add MAX_JWKS_TIMING_SECONDS = 1 year upper bound on timing fields - Regression tests: non-HTTPS, loopback/private IP, credentials, fragment, oversized timing, duplicate issuer all rejected at construction CurrentStatus posture (finding 2): - Rename error variant DuplicateIssuer(String) -> DuplicateIssuer (sanitized) - Add UnsupportedPosture error variant - validate_nip_fi_config() rejects any CurrentStatus policy with UnsupportedPosture — verifier has no status witness; startup fails closed - discovery.rs: remove FreshnessClassDiscovery::CurrentStatus variant and FederatedIdentityDiscovery::current_status() constructor entirely - Test asserts rejection both with and without JWKS config Duplicate issuer detection (finding 3): - validate_nip_fi_config(): explicit duplicate detection in JWKS config slice (collect() was silently overwriting); returns DuplicateIssuer on collision - ProductionJwksSource::new(): rejects duplicate issuer via HashMap::contains_key before insert Timing bounds and overflow (finding 4): - MAX_JWKS_TIMING_SECONDS constant bounds both refresh and hard-deadline fields - i64::try_from() + Duration::try_seconds() eliminates u64->i64 cast panic - Validated at both ProductionJwksSource::new() and validate_nip_fi_config() - Test: new_rejects_timing_above_maximum() Generation monotonicity (finding 5): - Replace wall-clock millis with SHA-256 content digest per issuer - Generation counter advances (saturating_add) only when digest changes; identical documents preserve the prior generation - Regressions: generation_stable_for_identical_document(), generation_advances_for_changed_document() Clippy (finding 6): - manual_async_fn: replaced RPITIT form with native 'async fn' in impl block - single_match (startup): replaced match { None => .., Some(_) => {} } with if let / !contains_key - unnecessary_get_then_check: replaced .get().is_none() with !contains_key() Comment quality (all files): - Remove module-to-PR table from nip_fi/mod.rs - Remove all 'Phase A', 'PR 1/3', 'PRs 4-5' references from every doc comment - Remove WHAT comments (field-name paraphrases, narrated steps, section banners with no contract content, 'Construct with a default reqwest client') - Retain WHY: security invariants, exact NIP-FI spec refs, fail-closed choices, FI-TRACE/FI-INV stable identifiers Co-authored-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com> Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- Cargo.lock | 1 + Cargo.toml | 2 +- crates/buzz-auth/Cargo.toml | 1 + crates/buzz-auth/src/nip_fi/discovery.rs | 53 +-- crates/buzz-auth/src/nip_fi/jwks/mod.rs | 333 +++++++++++-------- crates/buzz-auth/src/nip_fi/jwks/tests.rs | 305 +++++++++++++++-- crates/buzz-auth/src/nip_fi/mod.rs | 25 +- crates/buzz-auth/src/nip_fi/startup/mod.rs | 148 ++++----- crates/buzz-auth/src/nip_fi/startup/tests.rs | 109 +++--- 9 files changed, 636 insertions(+), 341 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cc28cbf6263..552a12ca155 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -939,6 +939,7 @@ dependencies = [ "base64 0.22.1", "buzz-core", "chrono", + "futures-util", "hex", "jsonwebtoken", "nostr 0.44.7", diff --git a/Cargo.toml b/Cargo.toml index d6ee839f1b0..0af365f52fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,7 +104,7 @@ chrono = { version = "0.4", features = ["serde"] } jsonwebtoken = { version = "10.4.0", default-features = false, features = ["aws_lc_rs"] } # HTTP client (webhook delivery) -reqwest = { version = "0.13", features = ["json", "rustls"], default-features = false } +reqwest = { version = "0.13", features = ["json", "rustls", "stream"], default-features = false } # Cryptography sha2 = "0.11" diff --git a/crates/buzz-auth/Cargo.toml b/crates/buzz-auth/Cargo.toml index 13dbdd88564..158d282cd61 100644 --- a/crates/buzz-auth/Cargo.toml +++ b/crates/buzz-auth/Cargo.toml @@ -21,6 +21,7 @@ base64 = { workspace = true } chrono = { workspace = true } jsonwebtoken = { workspace = true } nostr = { workspace = true } +futures-util = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/buzz-auth/src/nip_fi/discovery.rs b/crates/buzz-auth/src/nip_fi/discovery.rs index 359844342dc..8d1b1500b12 100644 --- a/crates/buzz-auth/src/nip_fi/discovery.rs +++ b/crates/buzz-auth/src/nip_fi/discovery.rs @@ -1,7 +1,8 @@ -//! NIP-11 federated-identity discovery output (NIP-FI Phase A, PR 3). +//! NIP-11 federated-identity discovery output. //! //! [`FederatedIdentityDiscovery`] serializes to the `federated_identity` -//! object NIP-FI.md "Discovery" requires in NIP-11 relay information. +//! object required by the NIP-FI.md "Discovery" section of the NIP-11 relay +//! information document. //! //! ## Privacy invariants //! @@ -19,42 +20,40 @@ use serde::{Deserialize, Serialize}; -/// The `assertion_freshness` sub-object inside `federated_identity`. +/// The `assertion_freshness` sub-object in the `federated_identity` discovery +/// document. Describes the claimed freshness posture without exposing any +/// issuer or deployment-private state. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AssertionFreshnessDiscovery { - /// `"offline-jwt"` or `"current-status"`. + /// The wire string identifying the freshness class. pub class: FreshnessClassDiscovery, - /// `null` for `offline-jwt`; a tested positive integer for `current-status`. + /// `null` for `offline-jwt`; advertising a finite bound here requires a + /// live status witness that is not yet implemented. pub maximum_residual_upstream_revocation_seconds: Option, } -/// The freshness class as a stable wire string. +/// The freshness class as a stable NIP-FI wire string. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum FreshnessClassDiscovery { - /// Validates the JWT and JWKS snapshot only. + /// No revocation bound is claimed; JWKS snapshot validation only. OfflineJwt, - /// Additionally requires a current-status witness. - CurrentStatus, } -/// The `federated_identity` NIP-11 discovery object. -/// -/// Placed under `limitation.federated_identity = true` and the top-level -/// `federated_identity` key in the NIP-11 relay information document. -/// Fields never expose enrollment mode, issuer, audience, or private state. +/// The `federated_identity` NIP-11 discovery object. Fields never expose +/// enrollment mode, issuer, audience, or private state. /// [FI-TRACE-DISCOVERY-PRIVATE] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FederatedIdentityDiscovery { - /// Always `"client-attached"` for core. + /// Fixed value `"client-attached"` for the core NIP-FI transport mode. pub core: String, - /// The assertion freshness contract claimed by this deployment. + /// The freshness contract claimed by this deployment. pub assertion_freshness: AssertionFreshnessDiscovery, } impl FederatedIdentityDiscovery { - /// Construct an offline-jwt discovery object. This is the minimal core - /// claim that carries no residual revocation bound. + /// The only supported posture: claims no residual revocation bound, which + /// is the honest description of JWKS-only assertion verification. pub fn offline_jwt() -> Self { Self { core: "client-attached".to_owned(), @@ -64,22 +63,4 @@ impl FederatedIdentityDiscovery { }, } } - - /// Construct a current-status discovery object with a tested positive - /// revocation bound (in seconds). The caller is responsible for ensuring - /// `revocation_bound_seconds` has been empirically verified. - /// - /// Returns `None` when `revocation_bound_seconds` is zero. - pub fn current_status(revocation_bound_seconds: u64) -> Option { - if revocation_bound_seconds == 0 { - return None; - } - Some(Self { - core: "client-attached".to_owned(), - assertion_freshness: AssertionFreshnessDiscovery { - class: FreshnessClassDiscovery::CurrentStatus, - maximum_residual_upstream_revocation_seconds: Some(revocation_bound_seconds), - }, - }) - } } diff --git a/crates/buzz-auth/src/nip_fi/jwks/mod.rs b/crates/buzz-auth/src/nip_fi/jwks/mod.rs index dd30e1dfb4b..dc215434733 100644 --- a/crates/buzz-auth/src/nip_fi/jwks/mod.rs +++ b/crates/buzz-auth/src/nip_fi/jwks/mod.rs @@ -1,5 +1,5 @@ //! JWKS discovery, snapshot caching, and the production [`IssuerKeySource`] -//! implementation (NIP-FI Phase A, PR 3). +//! implementation for federated-assertion verification. //! //! ## Design invariants //! @@ -13,9 +13,10 @@ //! snapshot if it is within its hard deadline, or `None`. It never serves //! an expired snapshot. [FI-TRACE-JWKS-REMOVE] //! -//! - **Bounded resource acquisition.** The HTTP response is capped at -//! [`MAX_JWKS_RESPONSE_BYTES`] before parsing. Key count is bounded by -//! [`super::config::MAX_JWKS_KEYS`] inside [`AssertionKeySet::new`]. +//! - **Bounded resource acquisition.** HTTP response streaming stops at +//! [`MAX_JWKS_RESPONSE_BYTES`] + 1 byte before any allocation for parsing. +//! Key count is bounded by [`super::config::MAX_JWKS_KEYS`] inside +//! [`AssertionKeySet::new`]. //! //! - **Coalesced refresh.** A single in-flight refresh per issuer prevents //! thundering-herd. Concurrent callers observe the snapshot just after the @@ -26,30 +27,79 @@ use super::config::MAX_JWKS_KEYS; use super::verifier::{AssertionKeySet, IssuerKeySource}; +use buzz_core::network::is_private_ip; use chrono::{DateTime, Duration, Utc}; +use futures_util::StreamExt as _; use jsonwebtoken::jwk::JwkSet; +use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::{Mutex, RwLock}; use tracing::warn; +use url::Url; -/// Maximum HTTP response size for a JWKS endpoint, in bytes. Bounded before -/// parsing to prevent a large or malicious response from consuming unbounded -/// memory during deserialization. +/// Maximum HTTP response body for a JWKS endpoint. Streaming stops at this +/// limit before any deserialization, preventing OOM from a malicious server. pub const MAX_JWKS_RESPONSE_BYTES: usize = 512 * 1024; // 512 KiB -/// A JWKS snapshot with its fetch time and configured hard deadline. +/// Hard upper bound on JWKS timing fields. Values above this are rejected at +/// config construction to prevent `u64`→`i64` conversion overflow and Chrono +/// range panics when computing snapshot deadlines. +pub const MAX_JWKS_TIMING_SECONDS: u64 = 365 * 24 * 3600; // 1 year + +/// Per-request deadline for the complete JWKS fetch (connect + headers + body). +/// This constant documents the timeout set on the default `HttpJwksFetcher::new()` +/// client; it cannot be removed via `with_client`. +pub const JWKS_REQUEST_TIMEOUT_SECS: u64 = 10; + +/// Validate that a JWKS URI is safe to fetch: HTTPS scheme, no credentials, +/// no fragment, and the host (if a bare IP) is not private/reserved. Hostnames +/// are not resolved here — runtime SSRF for hostname targets is limited by +/// redirect denial and the intrinsic request deadline. +pub fn validate_jwks_uri(uri: &str) -> Result<(), JwksFetchError> { + let parsed = Url::parse(uri).map_err(|_| JwksFetchError::InvalidUri)?; + if parsed.scheme() != "https" { + return Err(JwksFetchError::InvalidUri); + } + // Credentials in the URI are never legitimate for a public JWKS endpoint + // and would be forwarded to the server, leaking material in logs. + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(JwksFetchError::InvalidUri); + } + // Fragments are client-side only; their presence indicates a misconfigured URI. + if parsed.fragment().is_some() { + return Err(JwksFetchError::InvalidUri); + } + // Reject bare private/reserved IP targets at construction time. Hostname + // targets are additionally constrained at runtime by redirect denial. + if let Some(url::Host::Ipv4(addr)) = parsed.host() { + if is_private_ip(&std::net::IpAddr::V4(addr)) { + return Err(JwksFetchError::InvalidUri); + } + } + if let Some(url::Host::Ipv6(addr)) = parsed.host() { + if is_private_ip(&std::net::IpAddr::V6(addr)) { + return Err(JwksFetchError::InvalidUri); + } + } + Ok(()) +} + #[derive(Clone)] struct CachedSnapshot { key_set: AssertionKeySet, fetched_at: DateTime, hard_deadline: DateTime, + /// SHA-256 of the raw JWKS bytes. Suppresses generation advances when the + /// document is unchanged between refreshes. [FI-TRACE-JWKS-ADD/REMOVE] + content_digest: [u8; 32], } -/// Per-issuer runtime state: the current snapshot and in-flight flag. struct IssuerState { snapshot: Option, - /// True while a refresh task owns the fetch. Prevents concurrent fetches. + /// Advances only when `content_digest` changes; never wraps (saturating). + generation_counter: u64, + /// True while a refresh task owns the fetch lock. Prevents thundering-herd. refresh_in_flight: bool, } @@ -57,82 +107,96 @@ impl IssuerState { fn new() -> Self { Self { snapshot: None, + generation_counter: 0, refresh_in_flight: false, } } } -/// Configuration for one issuer's JWKS endpoint. +/// Per-issuer JWKS endpoint configuration. All fields are validated by +/// [`validate_jwks_uri`] and timing bounds at [`ProductionJwksSource::new`]. #[derive(Debug, Clone)] pub struct IssuerJwksConfig { /// The exact `iss` value this config authenticates. Must match the - /// configured [`IssuerPolicy`][super::config::IssuerPolicy] exactly. + /// corresponding [`IssuerPolicy`][super::config::IssuerPolicy] exactly. pub issuer: String, - /// The HTTPS JWKS endpoint URI. + /// Must pass [`validate_jwks_uri`]: HTTPS, no credentials/fragment, no + /// bare private-IP host. pub jwks_uri: String, - /// How long a cached snapshot remains fresh before re-fetching is - /// triggered, in seconds. Must be positive and less than - /// `key_snapshot_hard_deadline_seconds`. + /// Seconds until a cached snapshot is considered stale and re-fetching is + /// triggered. Must be positive, strictly less than + /// `key_snapshot_hard_deadline_seconds`, and ≤ [`MAX_JWKS_TIMING_SECONDS`]. pub refresh_interval_seconds: u64, /// Hard upper bound from fetch time on how long a snapshot may be served. - /// A snapshot whose deadline has passed is never returned, even on error. - /// Folds into every `AssertionKeySet` hard deadline and therefore into - /// every `VerifiedAssertion.revalidation_dependencies`. + /// Expired snapshots are never returned, even on fetch error — no stale + /// fallback. Folds into every `AssertionKeySet` hard deadline and therefore + /// into every `VerifiedAssertion.revalidation_dependencies`. + /// Must be ≤ [`MAX_JWKS_TIMING_SECONDS`]. pub key_snapshot_hard_deadline_seconds: u64, } -/// Why a JWKS fetch or parse operation failed. No key material, issuer URLs, -/// or raw response content appear in these variants. +/// Reason a JWKS fetch or parse operation failed. No key material, issuer +/// URLs, or raw response content appear in these variants. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum JwksFetchError { - /// The HTTP response exceeded [`MAX_JWKS_RESPONSE_BYTES`]. + /// Non-HTTPS scheme, embedded credentials, fragment, or bare + /// private/reserved IP host. + #[error("JWKS URI failed safety validation")] + InvalidUri, + /// Response body exceeded [`MAX_JWKS_RESPONSE_BYTES`]. #[error("JWKS response exceeded size limit")] ResponseTooLarge, - /// The HTTP request failed (network, TLS, timeout). + /// Network failure, TLS error, request timeout, or non-2xx status. #[error("JWKS HTTP request failed")] NetworkError, - /// The response body was not parseable as a JWK Set. + /// Response body was not parseable as a JWK Set. #[error("JWKS response was not parseable")] ParseError, - /// The parsed key set was empty or exceeded the key-count bound. + /// Parsed key set was empty or exceeded [`super::config::MAX_JWKS_KEYS`]. #[error("JWKS key set bounds violation")] KeyCountBoundsViolation, } -/// Async HTTP fetch of a JWKS endpoint. -/// -/// This is a sealed injection seam: only types inside `buzz_auth` may -/// implement it (the private supertrait `sealed` prevents external impls). -/// The production implementation uses `reqwest`; the test implementation -/// returns hard-coded bodies without network calls. +/// Sealed injection seam for JWKS HTTP fetching. Only types inside `buzz_auth` +/// may implement it — external types cannot name the private supertrait. /// -/// Implementations MUST enforce [`MAX_JWKS_RESPONSE_BYTES`]. +/// Implementations MUST enforce [`MAX_JWKS_RESPONSE_BYTES`] and MUST reject +/// non-2xx responses. pub trait JwksFetcher: super::verifier::sealed::Sealed + Send + Sync + 'static { - /// Fetch the JWK Set from the given URI, returning the raw JSON body. + /// Fetch and return the raw JSON body from the given JWKS URI. fn fetch_jwks<'a>( &'a self, uri: &'a str, ) -> impl std::future::Future> + Send + 'a; } -/// Production [`JwksFetcher`] backed by `reqwest`. +/// Production [`JwksFetcher`] backed by `reqwest`. The default client enforces: +/// - no redirects (`Policy::none()`) — a redirect to an internal host would +/// bypass the URI safety check performed at startup; +/// - a finite per-request deadline ([`JWKS_REQUEST_TIMEOUT_SECS`]). /// -/// Enforces [`MAX_JWKS_RESPONSE_BYTES`] before reading the full body. +/// `with_client` accepts a caller-supplied client; the caller must preserve +/// the no-redirect and finite-timeout invariants. The JWKS URI safety check +/// is still enforced by [`ProductionJwksSource::new`] regardless. #[derive(Clone)] pub struct HttpJwksFetcher { client: reqwest::Client, } impl HttpJwksFetcher { - /// Construct with a default `reqwest` client. + /// Builds a hardened client: no redirects (`Policy::none()`), finite + /// request deadline ([`JWKS_REQUEST_TIMEOUT_SECS`]). pub fn new() -> Self { - Self { - client: reqwest::Client::new(), - } + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(JWKS_REQUEST_TIMEOUT_SECS)) + .build() + .expect("HttpJwksFetcher default client build failed"); + Self { client } } - /// Construct with an explicit `reqwest::Client` (e.g., with custom TLS - /// certificates or timeout configuration). + /// The caller is responsible for preserving the no-redirect and + /// finite-timeout invariants documented on this type. pub fn with_client(client: reqwest::Client) -> Self { Self { client } } @@ -150,63 +214,62 @@ impl std::fmt::Debug for HttpJwksFetcher { } } -// Sealed so only in-crate types implement `JwksFetcher`. impl super::verifier::sealed::Sealed for HttpJwksFetcher {} impl JwksFetcher for HttpJwksFetcher { - fn fetch_jwks<'a>( - &'a self, - uri: &'a str, - ) -> impl std::future::Future> + Send + 'a { - async move { - let response = self - .client - .get(uri) - .send() - .await - .map_err(|_| JwksFetchError::NetworkError)?; - - // Reject based on Content-Length before reading body. - if let Some(content_length) = response.content_length() { - if content_length as usize > MAX_JWKS_RESPONSE_BYTES { - return Err(JwksFetchError::ResponseTooLarge); - } - } - - let bytes = response - .bytes() - .await - .map_err(|_| JwksFetchError::NetworkError)?; + async fn fetch_jwks<'a>(&'a self, uri: &'a str) -> Result { + let response = self + .client + .get(uri) + .send() + .await + .map_err(|_| JwksFetchError::NetworkError)?; + + // Non-2xx rejected before reading the body. A 3xx here means the + // client followed a redirect (default client disallows this); 4xx/5xx + // means the endpoint is not serving JWKS. + if !response.status().is_success() { + return Err(JwksFetchError::NetworkError); + } - if bytes.len() > MAX_JWKS_RESPONSE_BYTES { + // Early-exit on Content-Length before streaming. A lying or absent + // Content-Length is caught by the incremental counter below. + if let Some(content_length) = response.content_length() { + if content_length as usize > MAX_JWKS_RESPONSE_BYTES { return Err(JwksFetchError::ResponseTooLarge); } + } - String::from_utf8(bytes.to_vec()).map_err(|_| JwksFetchError::ParseError) + // Stream incrementally; stop at MAX_JWKS_RESPONSE_BYTES + 1 so we + // never buffer more than the limit before rejecting. + let mut body = Vec::with_capacity(MAX_JWKS_RESPONSE_BYTES.min(64 * 1024)); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| JwksFetchError::NetworkError)?; + if body.len().saturating_add(chunk.len()) > MAX_JWKS_RESPONSE_BYTES { + return Err(JwksFetchError::ResponseTooLarge); + } + body.extend_from_slice(&chunk); } + + String::from_utf8(body).map_err(|_| JwksFetchError::ParseError) } } -/// Parse a raw JWKS JSON body into a bounded, validated [`JwkSet`]. -/// -/// Rejects parse errors and key-count bound violations before any per-key -/// lookup or allocation. fn parse_and_bound_jwks(body: &str) -> Result { let key_set: JwkSet = serde_json::from_str(body).map_err(|_| JwksFetchError::ParseError)?; - if key_set.keys.is_empty() || key_set.keys.len() > MAX_JWKS_KEYS { return Err(JwksFetchError::KeyCountBoundsViolation); } - Ok(key_set) } -/// The production [`IssuerKeySource`]: a multi-issuer JWKS cache that performs -/// bounded periodic refresh and never serves snapshots past their hard deadline. +/// Multi-issuer JWKS cache that performs bounded periodic refresh and never +/// serves snapshots past their hard deadline. /// -/// One `ProductionJwksSource` is constructed at startup after -/// [`super::startup::validate_nip_fi_config`] passes. The `Arc>` -/// internal structure lets it be shared across async tasks cheaply. +/// Must be constructed at startup after +/// [`super::startup::validate_nip_fi_config`] passes. Shared across async +/// tasks via the inner `Arc>`. /// /// ## Security /// @@ -215,17 +278,14 @@ fn parse_and_bound_jwks(body: &str) -> Result { /// - Errors are logged with a stable code; no key material appears in logs. pub struct ProductionJwksSource { configs: HashMap, - /// Keyed by exact issuer string. states: Arc>>>, fetcher: Arc, } impl ProductionJwksSource { - /// Construct a new source from validated issuer JWKS configs. - /// - /// Returns `None` when `configs` is empty (startup validation rejects this - /// before the source is ever built) or when any config has invalid timing - /// bounds. + /// Returns `None` when `configs` is empty, any config has invalid timing + /// bounds or fails URI validation, or any two configs share the same + /// `issuer` (duplicate issuers make trust configuration ambiguous). pub fn new(configs: Vec, fetcher: F) -> Option { if configs.is_empty() { return None; @@ -238,9 +298,17 @@ impl ProductionJwksSource { if c.refresh_interval_seconds == 0 || c.key_snapshot_hard_deadline_seconds == 0 || c.key_snapshot_hard_deadline_seconds <= c.refresh_interval_seconds + || c.refresh_interval_seconds > MAX_JWKS_TIMING_SECONDS + || c.key_snapshot_hard_deadline_seconds > MAX_JWKS_TIMING_SECONDS { return None; } + if validate_jwks_uri(&c.jwks_uri).is_err() { + return None; + } + if config_map.contains_key(&c.issuer) { + return None; + } let issuer = c.issuer.clone(); state_map.insert(issuer.clone(), Mutex::new(IssuerState::new())); config_map.insert(issuer, c); @@ -252,17 +320,17 @@ impl ProductionJwksSource { }) } - /// Fetch and seal a fresh snapshot for one issuer, without updating the - /// cache. Returns `None` when the fetch or parse fails (already logged). - async fn fetch_fresh(&self, issuer: &str) -> Option { + async fn fetch_fresh( + &self, + issuer: &str, + prev_digest: Option<[u8; 32]>, + prev_generation: u64, + ) -> Option<(CachedSnapshot, u64)> { let config = self.configs.get(issuer)?; let body = match self.fetcher.fetch_jwks(&config.jwks_uri).await { Ok(b) => b, Err(err) => { - warn!( - error = %err, - "nip-fi jwks fetch failed; will use cached snapshot if live" - ); + warn!(error = %err, "nip-fi jwks fetch failed; will use cached snapshot if live"); return None; } }; @@ -270,41 +338,50 @@ impl ProductionJwksSource { let jwks = match parse_and_bound_jwks(&body) { Ok(k) => k, Err(err) => { - warn!( - error = %err, - "nip-fi jwks parse failed; will use cached snapshot if live" - ); + warn!(error = %err, "nip-fi jwks parse failed; will use cached snapshot if live"); return None; } }; - let now = Utc::now(); - let hard_deadline = - now + Duration::seconds(config.key_snapshot_hard_deadline_seconds as i64); + let content_digest: [u8; 32] = Sha256::digest(body.as_bytes()).into(); - // Generation: milliseconds since epoch, floored to 1 to satisfy the - // non-zero invariant. Monotone unless the system clock goes backwards. - let generation = u64::try_from(now.timestamp_millis()).unwrap_or(1).max(1); + // Advance only when the document changed so key-rotation events are + // visible [FI-TRACE-JWKS-ADD/REMOVE] while identical refetches are + // stable. Saturating add prevents wrap on the (unreachable) u64 ceiling. + let generation = if Some(content_digest) == prev_digest { + prev_generation + } else { + prev_generation.saturating_add(1).max(1) + }; + + let now = Utc::now(); + // MAX_JWKS_TIMING_SECONDS ≤ ~31.5M < i64::MAX, so this conversion is + // always safe for values that passed the bounds check in new(). + let deadline_secs = + i64::try_from(config.key_snapshot_hard_deadline_seconds).unwrap_or(i64::MAX / 2); + let hard_deadline = now + + Duration::try_seconds(deadline_secs) + .unwrap_or_else(|| Duration::seconds(i64::MAX / 2)); let key_set = AssertionKeySet::new(issuer.to_owned(), generation, jwks, hard_deadline)?; - Some(CachedSnapshot { - key_set, - fetched_at: now, - hard_deadline, - }) + Some(( + CachedSnapshot { + key_set, + fetched_at: now, + hard_deadline, + content_digest, + }, + generation, + )) } - /// Return the current snapshot for `issuer`, refreshing if stale. - /// + /// Returns the cached snapshot for `issuer`, refreshing inline if stale. /// Returns `None` when no live snapshot is available and the fetch fails. /// - /// ## Refresh logic - /// - /// - If the cached snapshot is past its hard deadline, it is cleared. - /// - If there is no snapshot, or the snapshot is past its refresh - /// interval, a refresh runs inline (holding the issuer's mutex). - /// - Concurrent calls share the inline refresh via the per-issuer mutex. + /// If a refresh is already in flight for this issuer, returns the current + /// snapshot rather than blocking — coalesces concurrent callers. Drops + /// both locks before the async fetch so other issuers are not blocked. pub async fn get_snapshot(&self, issuer: &str) -> Option { let states = self.states.read().await; let state_mutex = states.get(issuer)?; @@ -313,7 +390,6 @@ impl ProductionJwksSource { let now = Utc::now(); let config = self.configs.get(issuer)?; - // Evict expired snapshot. if let Some(ref cached) = state.snapshot { if now >= cached.hard_deadline { state.snapshot = None; @@ -333,25 +409,23 @@ impl ProductionJwksSource { } if state.refresh_in_flight { - // Another task is already refreshing; return the current snapshot - // (may be None if no snapshot is available yet). return state.snapshot.as_ref().map(|c| c.key_set.clone()); } state.refresh_in_flight = true; - // Drop mutex and read lock while doing async I/O so other issuers - // are not blocked. + let prev_digest = state.snapshot.as_ref().map(|c| c.content_digest); + let prev_generation = state.generation_counter; drop(state); drop(states); - let fresh = self.fetch_fresh(issuer).await; + let fresh = self.fetch_fresh(issuer, prev_digest, prev_generation).await; - // Re-acquire to commit the result and clear the in-flight flag. let states = self.states.read().await; if let Some(state_mutex) = states.get(issuer) { let mut st = state_mutex.lock().await; st.refresh_in_flight = false; - if let Some(ref cached) = fresh { + if let Some((ref cached, new_generation)) = fresh { + st.generation_counter = new_generation; st.snapshot = Some(cached.clone()); } let now2 = Utc::now(); @@ -366,20 +440,15 @@ impl ProductionJwksSource { } } -// Sealed so only in-crate types implement `IssuerKeySource`. impl super::verifier::sealed::Sealed for ProductionJwksSource {} impl IssuerKeySource for ProductionJwksSource { - /// Synchronous read of the currently cached snapshot. - /// - /// The verifier calls this per-request after the runtime has ensured the - /// cache is warm via [`get_snapshot`][Self::get_snapshot]. Returns `None` - /// if no snapshot is available or the snapshot is past its hard deadline. + /// Called per-request by the verifier after the cache has been warmed via + /// [`get_snapshot`][Self::get_snapshot]. /// - /// Uses `try_read`/`try_lock` so it is safe to call from any context — - /// including inside an async runtime. If the lock is momentarily held - /// (in-flight refresh), fails closed by returning `None` rather than - /// blocking or panicking. [FI-INV-14] + /// Uses `try_read`/`try_lock` — safe to call from any async context. + /// Fails closed (returns `None`) when the lock is momentarily held by an + /// in-flight refresh, rather than blocking or panicking. [FI-INV-14] fn key_set(&self, issuer: &str) -> Option { let states = self.states.try_read().ok()?; let state_mutex = states.get(issuer)?; diff --git a/crates/buzz-auth/src/nip_fi/jwks/tests.rs b/crates/buzz-auth/src/nip_fi/jwks/tests.rs index 2d4246bd823..0542d46ae16 100644 --- a/crates/buzz-auth/src/nip_fi/jwks/tests.rs +++ b/crates/buzz-auth/src/nip_fi/jwks/tests.rs @@ -1,14 +1,7 @@ -//! Unit tests for the NIP-FI JWKS source (Phase A, PR 3). -//! -//! These tests drive [`ProductionJwksSource`] through a fake [`JwksFetcher`] -//! to avoid live network calls. - use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; -// ── Fake fetcher ────────────────────────────────────────────────────────────── - struct FakeJwksFetcher { body: Result, call_count: Arc, @@ -27,7 +20,6 @@ impl JwksFetcher for FakeJwksFetcher { } } -/// Build a minimal valid ES256 JWK Set JSON with one key. fn minimal_jwks_json(kid: &str) -> String { format!( r#"{{"keys":[{{"kty":"EC","crv":"P-256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0","use":"sig","alg":"ES256","kid":"{kid}"}}]}}"# @@ -43,7 +35,14 @@ fn make_config(issuer: &str) -> IssuerJwksConfig { } } -// ── Tests ───────────────────────────────────────────────────────────────────── +fn make_config_with_uri(issuer: &str, jwks_uri: &str) -> IssuerJwksConfig { + IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: jwks_uri.to_owned(), + refresh_interval_seconds: 300, + key_snapshot_hard_deadline_seconds: 3600, + } +} #[tokio::test] async fn get_snapshot_returns_sealed_key_set_on_success() { @@ -54,9 +53,7 @@ async fn get_snapshot_returns_sealed_key_set_on_success() { }; let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); - let snapshot = source.get_snapshot(issuer).await; - assert!(snapshot.is_some(), "snapshot should be present on success"); - let ks = snapshot.unwrap(); + let ks = source.get_snapshot(issuer).await.unwrap(); assert_eq!(ks.issuer(), issuer); } @@ -69,8 +66,7 @@ async fn get_snapshot_returns_none_for_unknown_issuer() { let source = ProductionJwksSource::new(vec![make_config("https://id.example")], fetcher).unwrap(); - let snapshot = source.get_snapshot("https://other.example").await; - assert!(snapshot.is_none(), "unknown issuer must return None"); + assert!(source.get_snapshot("https://other.example").await.is_none()); } #[tokio::test] @@ -82,8 +78,7 @@ async fn get_snapshot_returns_none_on_network_error_with_no_cache() { let issuer = "https://id.example"; let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); - let snapshot = source.get_snapshot(issuer).await; - assert!(snapshot.is_none(), "no cache + network error = None"); + assert!(source.get_snapshot(issuer).await.is_none()); } #[tokio::test] @@ -112,22 +107,22 @@ async fn get_snapshot_returns_none_on_parse_error() { #[tokio::test] async fn parse_and_bound_rejects_empty_key_set() { - let empty_jwks = r#"{"keys":[]}"#; - let err = parse_and_bound_jwks(empty_jwks).unwrap_err(); + let err = parse_and_bound_jwks(r#"{"keys":[]}"#).unwrap_err(); assert_eq!(err, JwksFetchError::KeyCountBoundsViolation); } #[tokio::test] async fn parse_and_bound_rejects_oversized_key_set() { - // Build MAX_JWKS_KEYS + 1 keys. let keys: Vec = (0..=MAX_JWKS_KEYS) .map(|i| format!( r#"{{"kty":"EC","crv":"P-256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0","kid":"k{i}"}}"# )) .collect(); let body = format!(r#"{{"keys":[{}]}}"#, keys.join(",")); - let err = parse_and_bound_jwks(&body).unwrap_err(); - assert_eq!(err, JwksFetchError::KeyCountBoundsViolation); + assert_eq!( + parse_and_bound_jwks(&body).unwrap_err(), + JwksFetchError::KeyCountBoundsViolation + ); } #[tokio::test] @@ -148,7 +143,7 @@ async fn new_rejects_refresh_ge_hard_deadline() { let bad_config = IssuerJwksConfig { issuer: "https://id.example".to_owned(), jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(), - refresh_interval_seconds: 3600, // equal to hard deadline + refresh_interval_seconds: 3600, key_snapshot_hard_deadline_seconds: 3600, }; assert!(ProductionJwksSource::new(vec![bad_config], fetcher).is_none()); @@ -169,8 +164,125 @@ async fn new_rejects_zero_refresh_interval() { assert!(ProductionJwksSource::new(vec![bad_config], fetcher).is_none()); } -/// Issuer binding: the sealed `key_set()` synchronous path must return -/// `None` before any snapshot is warmed via `get_snapshot`. +#[tokio::test] +async fn new_rejects_timing_above_maximum() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let bad_config = IssuerJwksConfig { + issuer: "https://id.example".to_owned(), + jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: MAX_JWKS_TIMING_SECONDS + 1, + key_snapshot_hard_deadline_seconds: MAX_JWKS_TIMING_SECONDS + 2, + }; + assert!(ProductionJwksSource::new(vec![bad_config], fetcher).is_none()); +} + +#[tokio::test] +async fn new_rejects_duplicate_issuer() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let config_a = IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 300, + key_snapshot_hard_deadline_seconds: 3600, + }; + let config_b = IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: "https://id.example/.well-known/jwks-alt.json".to_owned(), + refresh_interval_seconds: 600, + key_snapshot_hard_deadline_seconds: 7200, + }; + assert!(ProductionJwksSource::new(vec![config_a, config_b], fetcher).is_none()); +} + +#[tokio::test] +async fn new_rejects_non_https_jwks_uri() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + assert!(ProductionJwksSource::new( + vec![make_config_with_uri( + "https://id.example", + "http://id.example/.well-known/jwks.json" + )], + fetcher + ) + .is_none()); +} + +#[tokio::test] +async fn new_rejects_loopback_jwks_uri() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + assert!(ProductionJwksSource::new( + vec![make_config_with_uri( + "https://id.example", + "https://127.0.0.1/.well-known/jwks.json" + )], + fetcher + ) + .is_none()); +} + +#[tokio::test] +async fn new_rejects_private_ip_jwks_uri() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + assert!(ProductionJwksSource::new( + vec![make_config_with_uri( + "https://id.example", + "https://10.0.0.1/.well-known/jwks.json" + )], + fetcher + ) + .is_none()); +} + +#[tokio::test] +async fn new_rejects_jwks_uri_with_credentials() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + assert!(ProductionJwksSource::new( + vec![make_config_with_uri( + "https://id.example", + "https://user:pass@id.example/.well-known/jwks.json" + )], + fetcher + ) + .is_none()); +} + +#[tokio::test] +async fn new_rejects_jwks_uri_with_fragment() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + assert!(ProductionJwksSource::new( + vec![make_config_with_uri( + "https://id.example", + "https://id.example/.well-known/jwks.json#keys" + )], + fetcher + ) + .is_none()); +} + +/// `key_set()` fails closed (returns `None`) before any snapshot is warmed via +/// `get_snapshot` — the synchronous path never fetches. #[tokio::test] async fn sync_key_set_returns_none_before_warmup() { let fetcher = FakeJwksFetcher { @@ -181,14 +293,9 @@ async fn sync_key_set_returns_none_before_warmup() { let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); use crate::nip_fi::verifier::IssuerKeySource; - assert!( - source.key_set(issuer).is_none(), - "cache is cold before get_snapshot" - ); + assert!(source.key_set(issuer).is_none()); } -/// After a successful `get_snapshot`, the synchronous `key_set()` path must -/// return the same issuer's snapshot without re-fetching. #[tokio::test] async fn sync_key_set_returns_snapshot_after_warmup() { let fetcher = FakeJwksFetcher { @@ -204,3 +311,141 @@ async fn sync_key_set_returns_snapshot_after_warmup() { let ks = source.key_set(issuer).unwrap(); assert_eq!(ks.issuer(), issuer); } + +/// Identical document fetched twice must not advance the generation counter +/// — stable generation for unchanged JWKS prevents spurious revalidation. +#[tokio::test] +async fn generation_stable_for_identical_document() { + let issuer = "https://id.example"; + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: format!("https://{issuer}/.well-known/jwks.json"), + refresh_interval_seconds: 1, + key_snapshot_hard_deadline_seconds: 3600, + }; + let source = ProductionJwksSource::new(vec![config], fetcher).unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + source.get_snapshot(issuer).await.unwrap(); + let gen1 = source.key_set(issuer).unwrap().generation(); + + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + source.get_snapshot(issuer).await.unwrap(); + let gen2 = source.key_set(issuer).unwrap().generation(); + + assert_eq!(gen1, gen2); +} + +/// Changed document must advance the generation so key-rotation events are +/// visible [FI-TRACE-JWKS-ADD/REMOVE]. +#[tokio::test] +async fn generation_advances_for_changed_document() { + let issuer = "https://id.example"; + + let bodies = Arc::new(std::sync::Mutex::new(vec![ + Ok::(minimal_jwks_json("k2")), + Ok(minimal_jwks_json("k1")), + ])); + + struct MultiBodyFetcher { + bodies: Arc>>>, + } + impl super::super::verifier::sealed::Sealed for MultiBodyFetcher {} + impl JwksFetcher for MultiBodyFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self + .bodies + .lock() + .unwrap() + .pop() + .unwrap_or(Err(JwksFetchError::NetworkError)); + async move { result } + } + } + + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: format!("https://{issuer}/.well-known/jwks.json"), + refresh_interval_seconds: 1, + key_snapshot_hard_deadline_seconds: 3600, + }; + let source = ProductionJwksSource::new(vec![config], MultiBodyFetcher { bodies }).unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + source.get_snapshot(issuer).await.unwrap(); + let gen1 = source.key_set(issuer).unwrap().generation(); + + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + source.get_snapshot(issuer).await.unwrap(); + let gen2 = source.key_set(issuer).unwrap().generation(); + + assert!(gen2 > gen1, "gen1={gen1}, gen2={gen2}"); +} + +#[test] +fn validate_uri_accepts_valid_https() { + assert!(validate_jwks_uri("https://id.example/.well-known/jwks.json").is_ok()); +} + +#[test] +fn validate_uri_rejects_http() { + assert_eq!( + validate_jwks_uri("http://id.example/.well-known/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_loopback_ip() { + assert_eq!( + validate_jwks_uri("https://127.0.0.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_private_ip() { + assert_eq!( + validate_jwks_uri("https://192.168.1.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_link_local_ip() { + assert_eq!( + validate_jwks_uri("https://169.254.169.254/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_credentials() { + assert_eq!( + validate_jwks_uri("https://user:pass@id.example/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_fragment() { + assert_eq!( + validate_jwks_uri("https://id.example/jwks.json#section").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_unparseable() { + assert_eq!( + validate_jwks_uri("not a url").unwrap_err(), + JwksFetchError::InvalidUri + ); +} diff --git a/crates/buzz-auth/src/nip_fi/mod.rs b/crates/buzz-auth/src/nip_fi/mod.rs index 0641481362a..2f649f95a61 100644 --- a/crates/buzz-auth/src/nip_fi/mod.rs +++ b/crates/buzz-auth/src/nip_fi/mod.rs @@ -1,24 +1,11 @@ //! NIP-FI federated-identity authorization — assertion verifier, JWKS runtime, -//! startup validation, and discovery (Phase A, PRs 1–3). -//! -//! ## Module layout -//! -//! | Module | Introduced | Responsibility | -//! |--------|-----------|----------------| -//! | [`assertion`] | PR 1 | Sealed [`VerifiedAssertion`] result and its fields | -//! | [`config`] | PR 1 | Multi-issuer policy, contract IDs, size/time bounds | -//! | [`denial`] | PR 1 | Privacy-preserving four-class denial wire contract | -//! | [`verifier`] | PR 1 | Single canonical [`FederatedAssertionVerifier`] | -//! | [`jwks`] | PR 3 | JWKS fetch, cache, and [`ProductionJwksSource`] | -//! | [`startup`] | PR 3 | Startup validation gate ([`validate_nip_fi_config`]) | -//! | [`discovery`] | PR 3 | NIP-11 [`FederatedIdentityDiscovery`] object | -//! -//! Identity is issuer-qualified `(iss, sub)` throughout. No database schema, -//! binding resolution, or request/proof binding is defined here — those belong -//! to PRs 4–5. +//! startup validation, and discovery. -/// The exact client-attached header field ([NIP-FI.md](../../../docs/nips/NIP-FI.md), -/// "Client-attached transport"). `Authorization` remains reserved for NIP-98. +/// The client-attached transport header for federated-identity assertions. +/// +/// `Authorization` remains reserved for NIP-98; this separate header avoids +/// conflating authentication schemes at the relay ingress. +/// ([NIP-FI.md](../../../docs/nips/NIP-FI.md), "Client-attached transport") pub const CLIENT_ATTACHED_HEADER: &str = "Nostr-Federated-Identity"; pub mod assertion; diff --git a/crates/buzz-auth/src/nip_fi/startup/mod.rs b/crates/buzz-auth/src/nip_fi/startup/mod.rs index 770fbe6cd0f..15af19eb00a 100644 --- a/crates/buzz-auth/src/nip_fi/startup/mod.rs +++ b/crates/buzz-auth/src/nip_fi/startup/mod.rs @@ -1,30 +1,15 @@ -//! Startup validation for the NIP-FI assertion runtime (Phase A, PR 3). +//! Startup validation for the NIP-FI assertion runtime. //! //! [`validate_nip_fi_config`] is the production entry point. It rejects any //! configuration that would make the runtime unsafe, incomplete, or ambiguous //! before the relay accepts any protected traffic. The relay MUST call this and -//! refuse to start on error in [`Enforce`][crate::nip_fi::NipFiMode::Enforce] -//! mode (`FI-INV-14`, `FI-INV-15`). -//! -//! ## What it checks -//! -//! | Check | Why | -//! |-------|-----| -//! | Registry non-empty | An enforce-mode deployment with no issuer policy admits nothing and the gap is undetectable at request time | -//! | Each issuer non-empty `iss` and `aud` | `IssuerPolicy` validates these, but startup re-asserts the invariant at the registry level | -//! | No duplicate `iss` | A duplicate would silently pick one policy; enforce uniqueness | -//! | `current-status` requires `maximum_status_age_seconds` | Already enforced in `IssuerPolicy::new`; startup confirms no offline-mode policy sneaked through with a status-age | -//! | Offline-only deployments: `FreshnessClass::OfflineJwt` is safe | No residual bound claim (per NIP-FI.md:259-266) | -//! | JWKS config present for every issuer in enforce mode | Every issuer needs a reachable key source | -//! | JWKS config issuer match | The JWKS config `issuer` must equal the policy `issuer` | -//! | `refresh_interval` < `hard_deadline` | Prevents an always-stale cache | +//! refuse to start on error in [`Enforce`][NipFiMode::Enforce] mode +//! (`FI-INV-14`, `FI-INV-15`). use super::config::{FreshnessClass, IssuerRegistry}; -use super::jwks::IssuerJwksConfig; +use super::jwks::{validate_jwks_uri, IssuerJwksConfig, MAX_JWKS_TIMING_SECONDS}; -/// Operating mode for the NIP-FI assertion runtime. -/// -/// The variant names are stable contract values; do not rename without a +/// Variant names are stable contract values; do not rename without a /// `VERIFIER_CONTRACT_VERSION` bump. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NipFiMode { @@ -34,124 +19,117 @@ pub enum NipFiMode { /// federated assertion evidence. The relay MUST call /// [`validate_nip_fi_config`] before accepting traffic in this mode. Enforce, - /// Emergency mode: all protected routes deny before any verifier is - /// configured. Used during startup if a previous enforce-mode deployment - /// was misconfigured and must fail closed while the operator repairs - /// configuration. [FI-INV-14] + /// All protected routes deny unconditionally. Used when a prior + /// enforce-mode deployment was misconfigured and must fail closed while + /// the operator repairs configuration. [FI-INV-14] DenyProtected, } -/// Reasons [`validate_nip_fi_config`] rejects a configuration. -/// /// Every variant corresponds to a concrete, operator-actionable defect. /// No key material, token bytes, or raw claim values appear. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum NipFiStartupError { - /// Enforce mode requires at least one issuer policy; the registry is empty. + /// Registry has no entries; enforce mode requires at least one issuer. #[error("NIP-FI enforce mode requires at least one issuer policy")] EmptyRegistry, - /// Two or more issuer policies share the same `iss` value, which would - /// make issuer selection ambiguous. - #[error("NIP-FI issuer registry contains duplicate issuer: {0}")] - DuplicateIssuer(String), + /// The duplicate `iss` is omitted to avoid leaking configuration into + /// operational logs. + #[error("NIP-FI issuer registry contains a duplicate issuer")] + DuplicateIssuer, - /// Enforce mode requires a JWKS config for every registered issuer, but - /// the given issuer has no JWKS configuration. - #[error("NIP-FI issuer has no JWKS configuration: (issuer redacted)")] + /// Every registered issuer requires a JWKS endpoint in enforce mode. + #[error("NIP-FI issuer has no JWKS configuration")] MissingJwksConfig, - /// A JWKS config's `issuer` field does not match any registered issuer - /// policy. Mismatched configs are rejected to prevent silent key-source - /// confusion. + /// Mismatched configs are rejected to prevent silent key-source confusion. #[error("NIP-FI JWKS config issuer does not match any registered policy")] UnmatchedJwksConfig, - /// A JWKS config's `refresh_interval_seconds` is zero or is greater than - /// or equal to `key_snapshot_hard_deadline_seconds`. - #[error("NIP-FI JWKS config has invalid timing bounds: refresh >= hard deadline")] + /// `refresh_interval_seconds` is zero, exceeds [`MAX_JWKS_TIMING_SECONDS`], + /// or is ≥ `key_snapshot_hard_deadline_seconds`. + #[error("NIP-FI JWKS config has invalid timing bounds")] InvalidJwksTiming, - /// A `current-status` issuer policy is present but the JWKS URI is - /// absent; current-status requires a reachable JWKS to validate assertion - /// signatures. - #[error("NIP-FI current-status issuer requires a JWKS configuration")] - CurrentStatusRequiresJwks, + /// Non-HTTPS scheme, embedded credentials, fragment, or bare + /// private/reserved IP host. See [`validate_jwks_uri`]. + #[error("NIP-FI JWKS URI failed safety validation")] + InvalidJwksUri, + + /// `current-status` requires an authenticated status witness that is not + /// yet implemented. Use `FreshnessClass::OfflineJwt` instead. + #[error( + "NIP-FI current-status freshness is not yet supported; \ + use offline-jwt posture" + )] + UnsupportedPosture, } -/// Validate the complete NIP-FI runtime configuration before the relay -/// accepts any protected traffic. -/// -/// `registry` is the set of issuer policies. `jwks_configs` is the set of -/// JWKS endpoint configurations (one per issuer in enforce mode). -/// `mode` is the intended operating mode. -/// -/// Returns `Ok(())` when the configuration is valid and complete for `mode`. -/// Returns `Err(NipFiStartupError)` when any invariant is violated; the relay -/// MUST refuse to start or must fall back to [`NipFiMode::DenyProtected`]. +/// Validates the complete NIP-FI runtime configuration. On error the relay +/// MUST refuse to start or fall back to [`NipFiMode::DenyProtected`]. pub fn validate_nip_fi_config( mode: NipFiMode, registry: &IssuerRegistry, jwks_configs: &[IssuerJwksConfig], ) -> Result<(), NipFiStartupError> { - match mode { - NipFiMode::Off | NipFiMode::DenyProtected => { - // Off and emergency-denial modes impose no assertion config - // requirements — they admit nothing. - return Ok(()); - } - NipFiMode::Enforce => {} + if let NipFiMode::Off | NipFiMode::DenyProtected = mode { + return Ok(()); } - // Enforce mode: validate the registry and JWKS configs. - if registry.is_empty() { return Err(NipFiStartupError::EmptyRegistry); } - // Check for duplicate issuers (IssuerRegistry keyed by exact iss, so this - // is already enforced there, but we assert it explicitly for startup). + // IssuerRegistry overwrites duplicates silently; assert uniqueness here so + // a misconfigured multi-issuer call-site is caught before traffic is served. { let mut seen = std::collections::HashSet::new(); for policy in registry.all_policies() { if !seen.insert(policy.issuer()) { - return Err(NipFiStartupError::DuplicateIssuer( - policy.issuer().to_owned(), - )); + return Err(NipFiStartupError::DuplicateIssuer); } } } - // Build a map from issuer → JWKS config for O(1) lookup. - let jwks_map: std::collections::HashMap<&str, &IssuerJwksConfig> = jwks_configs - .iter() - .map(|c| (c.issuer.as_str(), c)) - .collect(); + // Reject current-status policies: the status witness is not yet + // implemented. Fail closed rather than advertise a freshness guarantee the + // verifier cannot satisfy. + for policy in registry.all_policies() { + if policy.freshness() == FreshnessClass::CurrentStatus { + return Err(NipFiStartupError::UnsupportedPosture); + } + } + + // Build JWKS map, rejecting duplicates. Two configs for the same issuer + // would make the effective endpoint selection order-dependent. + let mut jwks_map: std::collections::HashMap<&str, &IssuerJwksConfig> = + std::collections::HashMap::with_capacity(jwks_configs.len()); + for config in jwks_configs { + if jwks_map.insert(config.issuer.as_str(), config).is_some() { + return Err(NipFiStartupError::DuplicateIssuer); + } + } - // Verify every JWKS config references a known issuer. for config in jwks_configs { if registry.policy_for_issuer(&config.issuer).is_none() { return Err(NipFiStartupError::UnmatchedJwksConfig); } - // Validate timing bounds. if config.refresh_interval_seconds == 0 || config.key_snapshot_hard_deadline_seconds == 0 || config.key_snapshot_hard_deadline_seconds <= config.refresh_interval_seconds + || config.refresh_interval_seconds > MAX_JWKS_TIMING_SECONDS + || config.key_snapshot_hard_deadline_seconds > MAX_JWKS_TIMING_SECONDS { return Err(NipFiStartupError::InvalidJwksTiming); } + if validate_jwks_uri(&config.jwks_uri).is_err() { + return Err(NipFiStartupError::InvalidJwksUri); + } } - // Every issuer policy must have a JWKS config in enforce mode. for policy in registry.all_policies() { - match jwks_map.get(policy.issuer()) { - None => { - if policy.freshness() == FreshnessClass::CurrentStatus { - return Err(NipFiStartupError::CurrentStatusRequiresJwks); - } - return Err(NipFiStartupError::MissingJwksConfig); - } - Some(_) => {} + if !jwks_map.contains_key(policy.issuer()) { + return Err(NipFiStartupError::MissingJwksConfig); } } diff --git a/crates/buzz-auth/src/nip_fi/startup/tests.rs b/crates/buzz-auth/src/nip_fi/startup/tests.rs index 14924d5c9c4..12455f98b0e 100644 --- a/crates/buzz-auth/src/nip_fi/startup/tests.rs +++ b/crates/buzz-auth/src/nip_fi/startup/tests.rs @@ -1,11 +1,8 @@ -//! Unit tests for NIP-FI startup validation (Phase A, PR 3). - use super::*; use crate::nip_fi::config::{FreshnessClass, IssuerPolicy, IssuerRegistry, TokenClass}; use crate::nip_fi::jwks::IssuerJwksConfig; use jsonwebtoken::Algorithm as JwtAlgorithm; -/// Build a minimal valid offline-jwt `IssuerPolicy`. fn make_offline_policy(issuer: &str) -> IssuerPolicy { IssuerPolicy::new( issuer.to_owned(), @@ -21,7 +18,6 @@ fn make_offline_policy(issuer: &str) -> IssuerPolicy { .unwrap() } -/// Build a minimal valid current-status `IssuerPolicy`. fn make_status_policy(issuer: &str) -> IssuerPolicy { IssuerPolicy::new( issuer.to_owned(), @@ -46,8 +42,6 @@ fn make_jwks_config(issuer: &str) -> IssuerJwksConfig { } } -// ── Off / DenyProtected accept anything ─────────────────────────────────────── - #[test] fn off_mode_accepts_empty_registry() { let registry = IssuerRegistry::new(); @@ -60,16 +54,15 @@ fn deny_protected_mode_accepts_empty_registry() { assert!(validate_nip_fi_config(NipFiMode::DenyProtected, ®istry, &[]).is_ok()); } -// ── Enforce: basic happy path ───────────────────────────────────────────────── - #[test] fn enforce_valid_config_passes() { let issuer = "https://id.example"; let mut registry = IssuerRegistry::new(); registry.insert(make_offline_policy(issuer)); - let jwks = vec![make_jwks_config(issuer)]; - assert!(validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).is_ok()); + assert!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[make_jwks_config(issuer)]).is_ok() + ); } #[test] @@ -87,8 +80,6 @@ fn enforce_multiple_issuers_passes() { assert!(validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).is_ok()); } -// ── Enforce: empty registry ─────────────────────────────────────────────────── - #[test] fn enforce_empty_registry_rejects() { let registry = IssuerRegistry::new(); @@ -96,8 +87,6 @@ fn enforce_empty_registry_rejects() { assert_eq!(err, NipFiStartupError::EmptyRegistry); } -// ── Enforce: missing JWKS config ───────────────────────────────────────────── - #[test] fn enforce_issuer_without_jwks_rejects() { let issuer = "https://id.example"; @@ -108,22 +97,21 @@ fn enforce_issuer_without_jwks_rejects() { assert_eq!(err, NipFiStartupError::MissingJwksConfig); } -// ── Enforce: unmatched JWKS config ─────────────────────────────────────────── - #[test] fn enforce_unmatched_jwks_config_rejects() { let issuer = "https://id.example"; let mut registry = IssuerRegistry::new(); registry.insert(make_offline_policy(issuer)); - // JWKS config for a different issuer. - let jwks = vec![make_jwks_config("https://other.example")]; - let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err(); + let err = validate_nip_fi_config( + NipFiMode::Enforce, + ®istry, + &[make_jwks_config("https://other.example")], + ) + .unwrap_err(); assert_eq!(err, NipFiStartupError::UnmatchedJwksConfig); } -// ── Enforce: invalid JWKS timing ───────────────────────────────────────────── - #[test] fn enforce_refresh_equals_hard_deadline_rejects() { let issuer = "https://id.example"; @@ -136,8 +124,10 @@ fn enforce_refresh_equals_hard_deadline_rejects() { refresh_interval_seconds: 3600, key_snapshot_hard_deadline_seconds: 3600, }]; - let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err(); - assert_eq!(err, NipFiStartupError::InvalidJwksTiming); + assert_eq!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err(), + NipFiStartupError::InvalidJwksTiming + ); } #[test] @@ -152,34 +142,77 @@ fn enforce_zero_refresh_interval_rejects() { refresh_interval_seconds: 0, key_snapshot_hard_deadline_seconds: 3600, }]; - let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err(); - assert_eq!(err, NipFiStartupError::InvalidJwksTiming); + assert_eq!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err(), + NipFiStartupError::InvalidJwksTiming + ); } -// ── current-status requires JWKS ───────────────────────────────────────────── - +/// Rejected regardless of whether a JWKS config is present — the verifier +/// has no status witness to satisfy the freshness guarantee. #[test] -fn enforce_current_status_without_jwks_rejects() { +fn enforce_current_status_policy_always_rejects() { let issuer = "https://id.example"; let mut registry = IssuerRegistry::new(); registry.insert(make_status_policy(issuer)); - let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[]).unwrap_err(); - // Either CurrentStatusRequiresJwks or MissingJwksConfig is correct here; - // the current implementation returns CurrentStatusRequiresJwks. + assert_eq!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[]).unwrap_err(), + NipFiStartupError::UnsupportedPosture + ); + assert_eq!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[make_jwks_config(issuer)]) + .unwrap_err(), + NipFiStartupError::UnsupportedPosture + ); +} + +/// Duplicate JWKS configs for the same issuer must not silently succeed. +#[test] +fn enforce_duplicate_jwks_issuer_in_configs_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let jwks = vec![make_jwks_config(issuer), make_jwks_config(issuer)]; assert!( - err == NipFiStartupError::CurrentStatusRequiresJwks - || err == NipFiStartupError::MissingJwksConfig, - "expected a JWKS-missing error, got {err:?}" + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).is_err(), + "duplicate JWKS configs must not pass" ); } #[test] -fn enforce_current_status_with_jwks_passes() { +fn enforce_non_https_jwks_uri_rejects() { let issuer = "https://id.example"; let mut registry = IssuerRegistry::new(); - registry.insert(make_status_policy(issuer)); + registry.insert(make_offline_policy(issuer)); - let jwks = vec![make_jwks_config(issuer)]; - assert!(validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).is_ok()); + let jwks = vec![IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: "http://id.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 300, + key_snapshot_hard_deadline_seconds: 3600, + }]; + assert_eq!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err(), + NipFiStartupError::InvalidJwksUri + ); +} + +#[test] +fn enforce_loopback_jwks_uri_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let jwks = vec![IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: "https://127.0.0.1/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 300, + key_snapshot_hard_deadline_seconds: 3600, + }]; + assert_eq!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err(), + NipFiStartupError::InvalidJwksUri + ); } From 673adbcd8b6d5268fcac6ca09123cf047f54626b Mon Sep 17 00:00:00 2001 From: Duncan Date: Mon, 31 Aug 2026 12:13:03 -0400 Subject: [PATCH 12/15] fix(buzz-auth): close SSRF/redirect bypass in HttpJwksFetcher; trim internal markers - Remove with_client() bypass: HttpJwksFetcher is now a unit struct; each fetch_jwks call builds a dedicated per-request pinned client. - Add resolve_and_check_ssrf: DNS-resolves host:port via spawn_blocking, rejects any resolved private/reserved IP (closes DNS-rebinding TOCTOU). - Per-request client enforces: redirect(Policy::none()), no_proxy(), .resolve(host, pinned_ip), and timeout(JWKS_REQUEST_TIMEOUT_SECS). - Drop unused client field (dead_code warning) now that no shared pool is needed. - Remove pure-paraphrase doc on IssuerRegistry::all_policies(); replace with doc stating constraint (unspecified order, startup use). - Remove all 'PR N' internal markers from doc comments; replace with production-stable references to the jwks runtime. Co-authored-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com> Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-auth/src/nip_fi/config.rs | 3 +- crates/buzz-auth/src/nip_fi/jwks/mod.rs | 136 +++++++++++++++--------- crates/buzz-auth/src/nip_fi/verifier.rs | 14 +-- 3 files changed, 97 insertions(+), 56 deletions(-) diff --git a/crates/buzz-auth/src/nip_fi/config.rs b/crates/buzz-auth/src/nip_fi/config.rs index 83866df247e..227e9e0dfde 100644 --- a/crates/buzz-auth/src/nip_fi/config.rs +++ b/crates/buzz-auth/src/nip_fi/config.rs @@ -561,7 +561,8 @@ impl IssuerRegistry { self.policies.is_empty() } - /// Iterate over all registered policies. + /// All registered issuer policies, in unspecified order. Useful for + /// iterating over every configured issuer during startup validation. pub fn all_policies(&self) -> impl Iterator { self.policies.values() } diff --git a/crates/buzz-auth/src/nip_fi/jwks/mod.rs b/crates/buzz-auth/src/nip_fi/jwks/mod.rs index dc215434733..e90dd378670 100644 --- a/crates/buzz-auth/src/nip_fi/jwks/mod.rs +++ b/crates/buzz-auth/src/nip_fi/jwks/mod.rs @@ -47,15 +47,15 @@ pub const MAX_JWKS_RESPONSE_BYTES: usize = 512 * 1024; // 512 KiB /// range panics when computing snapshot deadlines. pub const MAX_JWKS_TIMING_SECONDS: u64 = 365 * 24 * 3600; // 1 year -/// Per-request deadline for the complete JWKS fetch (connect + headers + body). -/// This constant documents the timeout set on the default `HttpJwksFetcher::new()` -/// client; it cannot be removed via `with_client`. +/// Per-request deadline for the complete JWKS fetch (connect + headers + body), +/// enforced inside `fetch_jwks` independently of any client-level timeout. pub const JWKS_REQUEST_TIMEOUT_SECS: u64 = 10; /// Validate that a JWKS URI is safe to fetch: HTTPS scheme, no credentials, -/// no fragment, and the host (if a bare IP) is not private/reserved. Hostnames -/// are not resolved here — runtime SSRF for hostname targets is limited by -/// redirect denial and the intrinsic request deadline. +/// no fragment, and the host (if a bare IP) is not private/reserved. +/// Hostname targets are resolved and checked at every fetch in `fetch_jwks` +/// to prevent DNS rebinding — this check catches the most common +/// misconfiguration at construction time. pub fn validate_jwks_uri(uri: &str) -> Result<(), JwksFetchError> { let parsed = Url::parse(uri).map_err(|_| JwksFetchError::InvalidUri)?; if parsed.scheme() != "https" { @@ -70,8 +70,7 @@ pub fn validate_jwks_uri(uri: &str) -> Result<(), JwksFetchError> { if parsed.fragment().is_some() { return Err(JwksFetchError::InvalidUri); } - // Reject bare private/reserved IP targets at construction time. Hostname - // targets are additionally constrained at runtime by redirect denial. + // Reject bare private/reserved IP targets at construction time. if let Some(url::Host::Ipv4(addr)) = parsed.host() { if is_private_ip(&std::net::IpAddr::V4(addr)) { return Err(JwksFetchError::InvalidUri); @@ -85,6 +84,37 @@ pub fn validate_jwks_uri(uri: &str) -> Result<(), JwksFetchError> { Ok(()) } +/// Resolve `host:port` to IP addresses and reject if any are private/reserved. +/// +/// Returns the first safe address for DNS pinning. Blocks on the OS resolver +/// via `spawn_blocking` to avoid blocking the async runtime. +/// +/// Rejecting *any* resolved address (not just the first) closes split-horizon +/// DNS attacks: if an attacker can cause one DNS record to resolve to a private +/// address, the entire request is blocked even when other records are public. +async fn resolve_and_check_ssrf(host: &str, port: u16) -> Result { + let addr_str = format!("{host}:{port}"); + let addrs: Vec = tokio::task::spawn_blocking(move || { + use std::net::ToSocketAddrs; + addr_str + .to_socket_addrs() + .map(|iter| iter.map(|sa| sa.ip()).collect::>()) + }) + .await + .map_err(|_| JwksFetchError::NetworkError)? + .map_err(|_| JwksFetchError::NetworkError)?; + + if addrs.is_empty() { + return Err(JwksFetchError::NetworkError); + } + for ip in &addrs { + if is_private_ip(ip) { + return Err(JwksFetchError::InvalidUri); + } + } + Ok(addrs[0]) +} + #[derive(Clone)] struct CachedSnapshot { key_set: AssertionKeySet, @@ -139,8 +169,8 @@ pub struct IssuerJwksConfig { /// URLs, or raw response content appear in these variants. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum JwksFetchError { - /// Non-HTTPS scheme, embedded credentials, fragment, or bare - /// private/reserved IP host. + /// Non-HTTPS scheme, embedded credentials, fragment, bare + /// private/reserved IP host, or DNS resolved to a private/reserved address. #[error("JWKS URI failed safety validation")] InvalidUri, /// Response body exceeded [`MAX_JWKS_RESPONSE_BYTES`]. @@ -160,8 +190,12 @@ pub enum JwksFetchError { /// Sealed injection seam for JWKS HTTP fetching. Only types inside `buzz_auth` /// may implement it — external types cannot name the private supertrait. /// -/// Implementations MUST enforce [`MAX_JWKS_RESPONSE_BYTES`] and MUST reject -/// non-2xx responses. +/// Implementations MUST: +/// - resolve hostname targets and reject any private/reserved resolved address; +/// - deny redirects (3xx responses rejected as `NetworkError`); +/// - enforce a finite per-fetch deadline ([`JWKS_REQUEST_TIMEOUT_SECS`]); +/// - enforce [`MAX_JWKS_RESPONSE_BYTES`] via incremental streaming; +/// - reject non-2xx responses. pub trait JwksFetcher: super::verifier::sealed::Sealed + Send + Sync + 'static { /// Fetch and return the raw JSON body from the given JWKS URI. fn fetch_jwks<'a>( @@ -170,35 +204,27 @@ pub trait JwksFetcher: super::verifier::sealed::Sealed + Send + Sync + 'static { ) -> impl std::future::Future> + Send + 'a; } -/// Production [`JwksFetcher`] backed by `reqwest`. The default client enforces: -/// - no redirects (`Policy::none()`) — a redirect to an internal host would -/// bypass the URI safety check performed at startup; -/// - a finite per-request deadline ([`JWKS_REQUEST_TIMEOUT_SECS`]). +/// Production [`JwksFetcher`] backed by `reqwest`. Each call to `fetch_jwks` +/// builds a dedicated pinned client — no shared connection state between fetches. /// -/// `with_client` accepts a caller-supplied client; the caller must preserve -/// the no-redirect and finite-timeout invariants. The JWKS URI safety check -/// is still enforced by [`ProductionJwksSource::new`] regardless. -#[derive(Clone)] -pub struct HttpJwksFetcher { - client: reqwest::Client, -} +/// Per-fetch boundary enforcement: +/// - hostname DNS is resolved and every address checked against +/// `buzz_core::network::is_private_ip` before the request is sent; +/// - the request is pinned to the validated address to prevent DNS rebinding +/// TOCTOU (the OS resolver is called once per fetch, not once per URL); +/// - a per-request timeout of [`JWKS_REQUEST_TIMEOUT_SECS`] is applied via +/// `RequestBuilder::timeout`; +/// - 3xx responses are rejected as `NetworkError` — redirects are never followed; +/// - the body is streamed incrementally and stopped at +/// [`MAX_JWKS_RESPONSE_BYTES`] + 1. +#[derive(Clone, Debug)] +pub struct HttpJwksFetcher; impl HttpJwksFetcher { - /// Builds a hardened client: no redirects (`Policy::none()`), finite - /// request deadline ([`JWKS_REQUEST_TIMEOUT_SECS`]). + /// Builds a new fetcher. Security invariants are enforced per-request in + /// `fetch_jwks` — each call constructs a dedicated pinned client. pub fn new() -> Self { - let client = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .timeout(std::time::Duration::from_secs(JWKS_REQUEST_TIMEOUT_SECS)) - .build() - .expect("HttpJwksFetcher default client build failed"); - Self { client } - } - - /// The caller is responsible for preserving the no-redirect and - /// finite-timeout invariants documented on this type. - pub fn with_client(client: reqwest::Client) -> Self { - Self { client } + Self } } @@ -208,26 +234,40 @@ impl Default for HttpJwksFetcher { } } -impl std::fmt::Debug for HttpJwksFetcher { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("HttpJwksFetcher") - } -} - impl super::verifier::sealed::Sealed for HttpJwksFetcher {} impl JwksFetcher for HttpJwksFetcher { async fn fetch_jwks<'a>(&'a self, uri: &'a str) -> Result { - let response = self - .client + let parsed = Url::parse(uri).map_err(|_| JwksFetchError::InvalidUri)?; + let host = parsed.host_str().ok_or(JwksFetchError::InvalidUri)?; + let port = parsed.port_or_known_default().unwrap_or(443); + + // Resolve and check every IP before sending. Pins DNS to the validated + // address to prevent rebinding TOCTOU between check and connect. + let safe_ip = resolve_and_check_ssrf(host, port).await?; + + // Build a per-request client that: + // - denies redirects (a 3xx to an internal host bypasses the URI check); + // - has no system proxy (proxy would resolve the original hostname itself); + // - pins this request to the validated IP. + // The connection pool from self.client is not reused here by design — + // DNS pinning requires a fresh client for each pinned address. + let pinned_client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .resolve(host, std::net::SocketAddr::new(safe_ip, port)) + .build() + .map_err(|_| JwksFetchError::NetworkError)?; + + let response = pinned_client .get(uri) + .timeout(std::time::Duration::from_secs(JWKS_REQUEST_TIMEOUT_SECS)) .send() .await .map_err(|_| JwksFetchError::NetworkError)?; - // Non-2xx rejected before reading the body. A 3xx here means the - // client followed a redirect (default client disallows this); 4xx/5xx - // means the endpoint is not serving JWKS. + // Reject non-2xx. A 3xx here means our no-redirect policy was somehow + // bypassed — treat as a network error. if !response.status().is_success() { return Err(JwksFetchError::NetworkError); } diff --git a/crates/buzz-auth/src/nip_fi/verifier.rs b/crates/buzz-auth/src/nip_fi/verifier.rs index 19a4824377e..167dd4f7d86 100644 --- a/crates/buzz-auth/src/nip_fi/verifier.rs +++ b/crates/buzz-auth/src/nip_fi/verifier.rs @@ -64,7 +64,7 @@ pub(crate) mod sealed { /// construction seam: [`verify`] takes no snapshot argument, and this type has /// no public constructor, so an external consumer cannot build a snapshot that /// labels issuer B's JWKS as issuer A. Building a snapshot (and the source that -/// serves it) is the trusted configuration act PR 3's JWKS runtime performs at +/// serves it) is the trusted configuration act the `jwks` runtime performs at /// startup, not a per-request or external input. /// /// The crate-private constructor is a live regression: an external crate that @@ -90,7 +90,7 @@ impl AssertionKeySet { /// generation and a required key-snapshot hard deadline. Rejects a zero /// generation, an empty issuer, an empty or oversized key set /// ([`MAX_JWKS_KEYS`]), or a non-positive deadline. Crate-private: only the - /// trusted in-crate configuration path (PR 3's JWKS runtime) may bind key + /// trusted in-crate configuration path (the `jwks` runtime) may bind key /// material to an issuer. /// /// Bounding the key count here is the pre-lookup control (NIP-FI.md:166-171): @@ -146,7 +146,7 @@ impl fmt::Debug for AssertionKeySet { /// instead asks this source for the snapshot bound to the token's /// signature-authenticated `iss`. A request-path caller therefore cannot /// relabel one issuer's JWKS as another's — the cross-issuer bypass at the old -/// `verify(token, key_set)` seam. Configuring the source (PR 3's JWKS runtime) +/// `verify(token, key_set)` seam. Configuring the source (the `jwks` runtime) /// is a trusted startup act, not per-request input. /// /// This trait is sealed via a private supertrait, so it cannot be implemented @@ -174,7 +174,7 @@ pub trait IssuerKeySource: sealed::Sealed { } /// A fixed issuer→snapshot key source for the in-crate verifier tests, -/// standing in for PR 3's JWKS runtime. It is `cfg(test)`-only — not behind a +/// standing in for the `jwks` runtime. It is `cfg(test)`-only — not behind a /// downstream-selectable Cargo feature — so no dependent crate can enable it to /// reconstruct the authority. An honest source returns only the snapshot bound /// to the exact issuer requested, the invariant the real runtime source @@ -345,7 +345,7 @@ impl FederatedAssertionVerifier { // is `evidence_rejected` (403), and this defers a valid one as // `authorization_unavailable` (503) so a missing witness never // masquerades as rejected evidence, nor invalid input as unavailable - // (NIP-FI.md:459-476). PR 3 adds the witness path additively. + // (NIP-FI.md:459-476). if policy.freshness() == FreshnessClass::CurrentStatus { return Err(VerifierError::StatusWitnessUnavailable); } @@ -719,8 +719,8 @@ fn parse_nostr_pubkey_claim( } } -/// Capture only the claim names the policy reads into a canonical set. For PR 1 -/// the closed set is the `scope` claim, split on ASCII space; unchecked claims +/// Capture only the claim names the policy reads into a canonical set. The +/// closed set is the `scope` claim, split on ASCII space; unchecked claims /// never enter the result. fn capture_capabilities( _policy: &IssuerPolicy, From 445302eead2404f0c2c7d82645721bbca9c896bb Mon Sep 17 00:00:00 2001 From: Duncan Date: Mon, 31 Aug 2026 12:29:10 -0400 Subject: [PATCH 13/15] fix(buzz-auth): validate URI + full deadline + IPv6 safe path in fetch_jwks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Call validate_jwks_uri at entry of fetch_jwks_inner: direct callers of HttpJwksFetcher are protected regardless of ProductionJwksSource pre-validation. HTTP/credentials/fragment URIs rejected before any DNS resolution or connection attempt. - Introduce with_deadline(fut, duration): private generic helper that wraps any future in tokio::time::timeout. HttpJwksFetcher::fetch_jwks passes fetch_jwks_inner(uri) through it with the fixed 10-second constant. Remove the RequestBuilder::timeout — the outer deadline covers the whole operation including a stalled OS resolver. - Add with_deadline_fires_before_outer_guard: tokio::test(start_paused) passes std::future::pending() to with_deadline with Duration::ZERO. The inner timeout fires immediately; removing it leaves the future permanently pending and the outer test guard fires — seam verified. - Fix IPv6-literal handling in resolve_and_check_ssrf: use (host, port) tuple form of ToSocketAddrs, not format!("{host}:{port}"), which is ambiguous for IPv6 addresses returned without brackets by host_str(). Add IP-literal fast path that skips the OS resolver for bare IP hosts. - Add production-boundary tests: four HttpJwksFetcher direct-call regressions (http/credentials/fragment/private-IP) and two IPv6 SSRF fast-path tests (loopback rejected, public accepted). - Add tokio test-util dev-dependency to buzz-auth for start_paused. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-auth/Cargo.toml | 1 + crates/buzz-auth/src/nip_fi/config.rs | 4 +- crates/buzz-auth/src/nip_fi/jwks/mod.rs | 163 ++++++++++++++-------- crates/buzz-auth/src/nip_fi/jwks/tests.rs | 78 +++++++++++ 4 files changed, 185 insertions(+), 61 deletions(-) diff --git a/crates/buzz-auth/Cargo.toml b/crates/buzz-auth/Cargo.toml index 158d282cd61..6cbe491e2c8 100644 --- a/crates/buzz-auth/Cargo.toml +++ b/crates/buzz-auth/Cargo.toml @@ -14,6 +14,7 @@ dev = [] [dev-dependencies] # `use_pem` enables EncodingKey::from_ec_pem for minting ES256 test assertions. jsonwebtoken = { version = "10.4.0", default-features = false, features = ["aws_lc_rs", "use_pem"] } +tokio = { workspace = true, features = ["test-util"] } [dependencies] buzz-core = { workspace = true } diff --git a/crates/buzz-auth/src/nip_fi/config.rs b/crates/buzz-auth/src/nip_fi/config.rs index 227e9e0dfde..5c264b00ee4 100644 --- a/crates/buzz-auth/src/nip_fi/config.rs +++ b/crates/buzz-auth/src/nip_fi/config.rs @@ -561,8 +561,8 @@ impl IssuerRegistry { self.policies.is_empty() } - /// All registered issuer policies, in unspecified order. Useful for - /// iterating over every configured issuer during startup validation. + /// Iteration order is deliberately unspecified; callers must not depend on + /// registration order. pub fn all_policies(&self) -> impl Iterator { self.policies.values() } diff --git a/crates/buzz-auth/src/nip_fi/jwks/mod.rs b/crates/buzz-auth/src/nip_fi/jwks/mod.rs index e90dd378670..ef8b8297a7e 100644 --- a/crates/buzz-auth/src/nip_fi/jwks/mod.rs +++ b/crates/buzz-auth/src/nip_fi/jwks/mod.rs @@ -47,8 +47,9 @@ pub const MAX_JWKS_RESPONSE_BYTES: usize = 512 * 1024; // 512 KiB /// range panics when computing snapshot deadlines. pub const MAX_JWKS_TIMING_SECONDS: u64 = 365 * 24 * 3600; // 1 year -/// Per-request deadline for the complete JWKS fetch (connect + headers + body), -/// enforced inside `fetch_jwks` independently of any client-level timeout. +/// Hard deadline for the complete JWKS fetch: hostname resolution, connect, +/// headers, and body streaming combined. Applied via `tokio::time::timeout` +/// so a stalled resolver cannot keep `fetch_jwks` pending indefinitely. pub const JWKS_REQUEST_TIMEOUT_SECS: u64 = 10; /// Validate that a JWKS URI is safe to fetch: HTTPS scheme, no credentials, @@ -89,14 +90,31 @@ pub fn validate_jwks_uri(uri: &str) -> Result<(), JwksFetchError> { /// Returns the first safe address for DNS pinning. Blocks on the OS resolver /// via `spawn_blocking` to avoid blocking the async runtime. /// +/// Uses the `(host, port)` tuple form of `ToSocketAddrs` — not +/// `format!("{host}:{port}")` — so IPv6 literal hosts (returned without +/// brackets by `Url::host_str()`) are handled correctly without socket-address +/// ambiguity. +/// /// Rejecting *any* resolved address (not just the first) closes split-horizon /// DNS attacks: if an attacker can cause one DNS record to resolve to a private /// address, the entire request is blocked even when other records are public. -async fn resolve_and_check_ssrf(host: &str, port: u16) -> Result { - let addr_str = format!("{host}:{port}"); +pub(crate) async fn resolve_and_check_ssrf( + host: &str, + port: u16, +) -> Result { + // Fast path: if the host is already a parsed IP literal, skip the resolver. + if let Ok(ip) = host.parse::() { + if is_private_ip(&ip) { + return Err(JwksFetchError::InvalidUri); + } + return Ok(ip); + } + + // Hostname path: use the tuple form to avoid IPv6-bracket ambiguity. + let host_owned = host.to_owned(); let addrs: Vec = tokio::task::spawn_blocking(move || { use std::net::ToSocketAddrs; - addr_str + (host_owned.as_str(), port) .to_socket_addrs() .map(|iter| iter.map(|sa| sa.ip()).collect::>()) }) @@ -191,9 +209,12 @@ pub enum JwksFetchError { /// may implement it — external types cannot name the private supertrait. /// /// Implementations MUST: +/// - validate the URI (scheme, credentials, fragment, bare private-IP host) +/// before any I/O; /// - resolve hostname targets and reject any private/reserved resolved address; /// - deny redirects (3xx responses rejected as `NetworkError`); -/// - enforce a finite per-fetch deadline ([`JWKS_REQUEST_TIMEOUT_SECS`]); +/// - enforce a finite per-fetch deadline covering resolution, connect, headers, +/// and body streaming — the entire operation must be bounded; /// - enforce [`MAX_JWKS_RESPONSE_BYTES`] via incremental streaming; /// - reject non-2xx responses. pub trait JwksFetcher: super::verifier::sealed::Sealed + Send + Sync + 'static { @@ -212,8 +233,8 @@ pub trait JwksFetcher: super::verifier::sealed::Sealed + Send + Sync + 'static { /// `buzz_core::network::is_private_ip` before the request is sent; /// - the request is pinned to the validated address to prevent DNS rebinding /// TOCTOU (the OS resolver is called once per fetch, not once per URL); -/// - a per-request timeout of [`JWKS_REQUEST_TIMEOUT_SECS`] is applied via -/// `RequestBuilder::timeout`; +/// - the complete operation (resolution, connect, headers, body streaming) is +/// bounded by [`JWKS_REQUEST_TIMEOUT_SECS`] via `tokio::time::timeout`; /// - 3xx responses are rejected as `NetworkError` — redirects are never followed; /// - the body is streamed incrementally and stopped at /// [`MAX_JWKS_RESPONSE_BYTES`] + 1. @@ -238,62 +259,86 @@ impl super::verifier::sealed::Sealed for HttpJwksFetcher {} impl JwksFetcher for HttpJwksFetcher { async fn fetch_jwks<'a>(&'a self, uri: &'a str) -> Result { - let parsed = Url::parse(uri).map_err(|_| JwksFetchError::InvalidUri)?; - let host = parsed.host_str().ok_or(JwksFetchError::InvalidUri)?; - let port = parsed.port_or_known_default().unwrap_or(443); - - // Resolve and check every IP before sending. Pins DNS to the validated - // address to prevent rebinding TOCTOU between check and connect. - let safe_ip = resolve_and_check_ssrf(host, port).await?; - - // Build a per-request client that: - // - denies redirects (a 3xx to an internal host bypasses the URI check); - // - has no system proxy (proxy would resolve the original hostname itself); - // - pins this request to the validated IP. - // The connection pool from self.client is not reused here by design — - // DNS pinning requires a fresh client for each pinned address. - let pinned_client = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .no_proxy() - .resolve(host, std::net::SocketAddr::new(safe_ip, port)) - .build() - .map_err(|_| JwksFetchError::NetworkError)?; - - let response = pinned_client - .get(uri) - .timeout(std::time::Duration::from_secs(JWKS_REQUEST_TIMEOUT_SECS)) - .send() - .await - .map_err(|_| JwksFetchError::NetworkError)?; - - // Reject non-2xx. A 3xx here means our no-redirect policy was somehow - // bypassed — treat as a network error. - if !response.status().is_success() { - return Err(JwksFetchError::NetworkError); - } + with_deadline( + fetch_jwks_inner(uri), + std::time::Duration::from_secs(JWKS_REQUEST_TIMEOUT_SECS), + ) + .await + } +} - // Early-exit on Content-Length before streaming. A lying or absent - // Content-Length is caught by the incremental counter below. - if let Some(content_length) = response.content_length() { - if content_length as usize > MAX_JWKS_RESPONSE_BYTES { - return Err(JwksFetchError::ResponseTooLarge); - } - } +/// Bound `fut` with a hard `tokio::time::timeout`. Elapsed maps to +/// `NetworkError`. Production passes `fetch_jwks_inner(uri)`; tests pass +/// `std::future::pending()` to verify the seam deterministically. +async fn with_deadline(fut: F, timeout: std::time::Duration) -> Result +where + F: std::future::Future>, +{ + tokio::time::timeout(timeout, fut) + .await + .map_err(|_| JwksFetchError::NetworkError)? +} - // Stream incrementally; stop at MAX_JWKS_RESPONSE_BYTES + 1 so we - // never buffer more than the limit before rejecting. - let mut body = Vec::with_capacity(MAX_JWKS_RESPONSE_BYTES.min(64 * 1024)); - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|_| JwksFetchError::NetworkError)?; - if body.len().saturating_add(chunk.len()) > MAX_JWKS_RESPONSE_BYTES { - return Err(JwksFetchError::ResponseTooLarge); - } - body.extend_from_slice(&chunk); +/// Inner fetch logic. Called only by `HttpJwksFetcher::fetch_jwks` via `with_deadline`. +async fn fetch_jwks_inner(uri: &str) -> Result { + // Full URI validation first — scheme, credentials, fragment, bare + // private-IP host. This enforces the JwksFetcher contract for direct + // callers of HttpJwksFetcher regardless of whether ProductionJwksSource + // pre-validated the URI. + validate_jwks_uri(uri)?; + + let parsed = Url::parse(uri).map_err(|_| JwksFetchError::InvalidUri)?; + let host = parsed.host_str().ok_or(JwksFetchError::InvalidUri)?; + let port = parsed.port_or_known_default().unwrap_or(443); + + // Resolve and check every IP before sending. Pins DNS to the validated + // address to prevent rebinding TOCTOU between check and connect. + let safe_ip = resolve_and_check_ssrf(host, port).await?; + + // Build a per-request client that: + // - denies redirects (a 3xx to an internal host bypasses the URI check); + // - has no system proxy (proxy would resolve the original hostname itself); + // - pins this request to the validated IP. + let pinned_client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .resolve(host, std::net::SocketAddr::new(safe_ip, port)) + .build() + .map_err(|_| JwksFetchError::NetworkError)?; + + let response = pinned_client + .get(uri) + .send() + .await + .map_err(|_| JwksFetchError::NetworkError)?; + + // Reject non-2xx. A 3xx here means our no-redirect policy was somehow + // bypassed — treat as a network error. + if !response.status().is_success() { + return Err(JwksFetchError::NetworkError); + } + + // Early-exit on Content-Length before streaming. A lying or absent + // Content-Length is caught by the incremental counter below. + if let Some(content_length) = response.content_length() { + if content_length as usize > MAX_JWKS_RESPONSE_BYTES { + return Err(JwksFetchError::ResponseTooLarge); } + } - String::from_utf8(body).map_err(|_| JwksFetchError::ParseError) + // Stream incrementally; stop at MAX_JWKS_RESPONSE_BYTES + 1 so we + // never buffer more than the limit before rejecting. + let mut body = Vec::with_capacity(MAX_JWKS_RESPONSE_BYTES.min(64 * 1024)); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| JwksFetchError::NetworkError)?; + if body.len().saturating_add(chunk.len()) > MAX_JWKS_RESPONSE_BYTES { + return Err(JwksFetchError::ResponseTooLarge); + } + body.extend_from_slice(&chunk); } + + String::from_utf8(body).map_err(|_| JwksFetchError::ParseError) } fn parse_and_bound_jwks(body: &str) -> Result { diff --git a/crates/buzz-auth/src/nip_fi/jwks/tests.rs b/crates/buzz-auth/src/nip_fi/jwks/tests.rs index 0542d46ae16..90bc401436c 100644 --- a/crates/buzz-auth/src/nip_fi/jwks/tests.rs +++ b/crates/buzz-auth/src/nip_fi/jwks/tests.rs @@ -394,6 +394,11 @@ fn validate_uri_accepts_valid_https() { assert!(validate_jwks_uri("https://id.example/.well-known/jwks.json").is_ok()); } +#[test] +fn validate_uri_accepts_public_ipv6() { + assert!(validate_jwks_uri("https://[2606:4700::1]/.well-known/jwks.json").is_ok()); +} + #[test] fn validate_uri_rejects_http() { assert_eq!( @@ -449,3 +454,76 @@ fn validate_uri_rejects_unparseable() { JwksFetchError::InvalidUri ); } + +#[tokio::test] +async fn http_fetcher_rejects_http_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + // Would resolve DNS and return NetworkError if validation ran after I/O. + let err = fetcher + .fetch_jwks("http://id.example/.well-known/jwks.json") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn http_fetcher_rejects_credentials_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("https://user:pass@id.example/.well-known/jwks.json") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn http_fetcher_rejects_fragment_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("https://id.example/.well-known/jwks.json#section") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn http_fetcher_rejects_private_ip_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("https://10.0.0.1/.well-known/jwks.json") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn resolve_ssrf_rejects_ipv6_loopback_fast_path() { + let err = super::resolve_and_check_ssrf("::1", 443).await.unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn resolve_ssrf_accepts_public_ipv6_fast_path() { + let ip = super::resolve_and_check_ssrf("2606:4700::1", 443) + .await + .unwrap(); + assert_eq!(ip, "2606:4700::1".parse::().unwrap()); +} + +/// `with_deadline` must fire before the outer guard when the inner future +/// never resolves. Uses `std::future::pending()` so no DNS or I/O occurs. +/// Removing the `tokio::time::timeout` inside `with_deadline` leaves the +/// future permanently pending — the outer guard fires and the test fails. +#[tokio::test(start_paused = true)] +async fn with_deadline_fires_before_outer_guard() { + let inner = super::with_deadline( + std::future::pending::>(), + std::time::Duration::ZERO, + ); + // Independent 1-second outer guard. Must not be the one that fires. + let result = tokio::time::timeout(std::time::Duration::from_secs(1), inner).await; + assert_eq!( + result.expect("outer guard fired — with_deadline timeout seam missing"), + Err(JwksFetchError::NetworkError), + ); +} From 620dca3b43fc434687fb50a9e3a94e8fb97202ad Mon Sep 17 00:00:00 2001 From: Duncan Date: Mon, 31 Aug 2026 13:00:38 -0400 Subject: [PATCH 14/15] fix(buzz-auth): complete SSRF policy, cancellation-safe permit, and invariant tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Network policy (buzz-core): - Rename is_private_ip → is_not_global_unicast and add is_private_ip alias; the new name states the actual predicate. Update the owned JWKS caller to use the new name; unowned callers are covered by the alias. - Extend the predicate to cover every IANA non-globally-reachable IPv4 and IPv6 range (source: IANA Special-Purpose Address Registries, 2024-02): IPv4 — 192.0.0.0/24 IETF protocol assignments (exceptions: 192.0.0.9 PCP anycast RFC 7723 and 192.0.0.10 TURN anycast RFC 8155 are globally reachable), 192.88.99.0/24 deprecated 6to4 relay anycast (RFC 7526). IPv6 — 100::/64 discard-only (RFC 6666), 2001:2::/48 benchmarking (RFC 5180), 2001:20::/28 ORCHIDv2 (RFC 7343). - Audit: buzz-workflow::check_ssrf and desktop link_preview both use is_private_ip; the alias preserves their behavior while the stricter predicate closes the previously admitted ranges for all three callers. - Replace per-range test functions with grouped table-driven tests derived from IANA registry entries; public positive controls are explicit. Cancellation-safe refresh permit (buzz-auth): - Per-issuer IssuerState holds an Arc> refresh_permit; a second concurrent caller that loses try_lock_owned returns the current snapshot without a second fetch. The OwnedMutexGuard spans the complete fetch and state commit; if the caller future is cancelled the guard drops automatically, releasing the permit for the next caller. - concurrent_refresh_coalesces_without_second_fetch: BlockingFetcher fires an entered oneshot before yielding so the test waits for confirmed permit ownership before issuing the second call. Mutation: early permit drop → call_count 2, test fails. - aborted_first_caller_releases_permit_for_next_caller: uses the same source for both calls. SequencedFetcher hands out distinct enter/release channels per call. First call is aborted after the entered barrier; second call on the same source fetches and succeeds. Asserts call_count 2 and result is Some. Mutation: manual boolean cleared only on success → second call returns None, test fails. Central invariant regressions (buzz-auth): - expired_snapshot_never_served_after_hard_deadline: warms a 2 s hard-deadline snapshot, sleeps 3 s with a failing fetcher, verifies both get_snapshot and key_set return None. - two_issuer_keys_and_generations_are_isolated: warms two issuers with distinct bodies, advances only issuer A's document, verifies A's generation advances and B's is unchanged; asserts per-issuer key bindings before and after. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-auth/src/nip_fi/jwks/mod.rs | 44 +- crates/buzz-auth/src/nip_fi/jwks/tests.rs | 322 ++++++++++++- crates/buzz-core/src/network.rs | 542 ++++++++++++---------- 3 files changed, 651 insertions(+), 257 deletions(-) diff --git a/crates/buzz-auth/src/nip_fi/jwks/mod.rs b/crates/buzz-auth/src/nip_fi/jwks/mod.rs index ef8b8297a7e..cfa880a3c90 100644 --- a/crates/buzz-auth/src/nip_fi/jwks/mod.rs +++ b/crates/buzz-auth/src/nip_fi/jwks/mod.rs @@ -27,7 +27,7 @@ use super::config::MAX_JWKS_KEYS; use super::verifier::{AssertionKeySet, IssuerKeySource}; -use buzz_core::network::is_private_ip; +use buzz_core::network::is_not_global_unicast; use chrono::{DateTime, Duration, Utc}; use futures_util::StreamExt as _; use jsonwebtoken::jwk::JwkSet; @@ -73,12 +73,12 @@ pub fn validate_jwks_uri(uri: &str) -> Result<(), JwksFetchError> { } // Reject bare private/reserved IP targets at construction time. if let Some(url::Host::Ipv4(addr)) = parsed.host() { - if is_private_ip(&std::net::IpAddr::V4(addr)) { + if is_not_global_unicast(&std::net::IpAddr::V4(addr)) { return Err(JwksFetchError::InvalidUri); } } if let Some(url::Host::Ipv6(addr)) = parsed.host() { - if is_private_ip(&std::net::IpAddr::V6(addr)) { + if is_not_global_unicast(&std::net::IpAddr::V6(addr)) { return Err(JwksFetchError::InvalidUri); } } @@ -104,7 +104,7 @@ pub(crate) async fn resolve_and_check_ssrf( ) -> Result { // Fast path: if the host is already a parsed IP literal, skip the resolver. if let Ok(ip) = host.parse::() { - if is_private_ip(&ip) { + if is_not_global_unicast(&ip) { return Err(JwksFetchError::InvalidUri); } return Ok(ip); @@ -126,7 +126,7 @@ pub(crate) async fn resolve_and_check_ssrf( return Err(JwksFetchError::NetworkError); } for ip in &addrs { - if is_private_ip(ip) { + if is_not_global_unicast(ip) { return Err(JwksFetchError::InvalidUri); } } @@ -147,8 +147,10 @@ struct IssuerState { snapshot: Option, /// Advances only when `content_digest` changes; never wraps (saturating). generation_counter: u64, - /// True while a refresh task owns the fetch lock. Prevents thundering-herd. - refresh_in_flight: bool, + /// Owned permit for in-flight refresh. Held across the complete fetch + + /// state commit; dropped automatically if the caller future is cancelled. + /// `try_lock_owned()` succeeds iff no refresh is in progress. + refresh_permit: Arc>, } impl IssuerState { @@ -156,7 +158,7 @@ impl IssuerState { Self { snapshot: None, generation_counter: 0, - refresh_in_flight: false, + refresh_permit: Arc::new(tokio::sync::Mutex::new(())), } } } @@ -230,7 +232,7 @@ pub trait JwksFetcher: super::verifier::sealed::Sealed + Send + Sync + 'static { /// /// Per-fetch boundary enforcement: /// - hostname DNS is resolved and every address checked against -/// `buzz_core::network::is_private_ip` before the request is sent; +/// `buzz_core::network::is_not_global_unicast` before the request is sent; /// - the request is pinned to the validated address to prevent DNS rebinding /// TOCTOU (the OS resolver is called once per fetch, not once per URL); /// - the complete operation (resolution, connect, headers, body streaming) is @@ -464,9 +466,11 @@ impl ProductionJwksSource { /// Returns the cached snapshot for `issuer`, refreshing inline if stale. /// Returns `None` when no live snapshot is available and the fetch fails. /// - /// If a refresh is already in flight for this issuer, returns the current - /// snapshot rather than blocking — coalesces concurrent callers. Drops - /// both locks before the async fetch so other issuers are not blocked. + /// Coalesces concurrent callers: a second call while a refresh is in + /// flight returns the current snapshot immediately rather than starting a + /// second fetch. The refresh permit is an RAII guard — if this future is + /// cancelled while DNS, HTTP, or streaming is pending, the guard drops and + /// the permit is released, so the next caller can start a new fetch. pub async fn get_snapshot(&self, issuer: &str) -> Option { let states = self.states.read().await; let state_mutex = states.get(issuer)?; @@ -493,11 +497,14 @@ impl ProductionJwksSource { return state.snapshot.as_ref().map(|c| c.key_set.clone()); } - if state.refresh_in_flight { - return state.snapshot.as_ref().map(|c| c.key_set.clone()); - } + // Try to acquire the per-issuer refresh permit. Failure means another + // caller is already fetching; return the current snapshot rather than + // starting a second fetch. + let permit = match Arc::clone(&state.refresh_permit).try_lock_owned() { + Ok(g) => g, + Err(_) => return state.snapshot.as_ref().map(|c| c.key_set.clone()), + }; - state.refresh_in_flight = true; let prev_digest = state.snapshot.as_ref().map(|c| c.content_digest); let prev_generation = state.generation_counter; drop(state); @@ -505,14 +512,16 @@ impl ProductionJwksSource { let fresh = self.fetch_fresh(issuer, prev_digest, prev_generation).await; + // Re-acquire state to commit and release the permit atomically. let states = self.states.read().await; if let Some(state_mutex) = states.get(issuer) { let mut st = state_mutex.lock().await; - st.refresh_in_flight = false; if let Some((ref cached, new_generation)) = fresh { st.generation_counter = new_generation; st.snapshot = Some(cached.clone()); } + // Drop the permit only after the state commit is visible. + drop(permit); let now2 = Utc::now(); return st .snapshot @@ -521,6 +530,7 @@ impl ProductionJwksSource { .map(|c| c.key_set.clone()); } + drop(permit); None } } diff --git a/crates/buzz-auth/src/nip_fi/jwks/tests.rs b/crates/buzz-auth/src/nip_fi/jwks/tests.rs index 90bc401436c..465d7ac1e1e 100644 --- a/crates/buzz-auth/src/nip_fi/jwks/tests.rs +++ b/crates/buzz-auth/src/nip_fi/jwks/tests.rs @@ -511,19 +511,333 @@ async fn resolve_ssrf_accepts_public_ipv6_fast_path() { } /// `with_deadline` must fire before the outer guard when the inner future -/// never resolves. Uses `std::future::pending()` so no DNS or I/O occurs. -/// Removing the `tokio::time::timeout` inside `with_deadline` leaves the -/// future permanently pending — the outer guard fires and the test fails. +/// never resolves. Removing the `tokio::time::timeout` inside `with_deadline` +/// leaves the future permanently pending — the outer guard fires and the test fails. #[tokio::test(start_paused = true)] async fn with_deadline_fires_before_outer_guard() { let inner = super::with_deadline( std::future::pending::>(), std::time::Duration::ZERO, ); - // Independent 1-second outer guard. Must not be the one that fires. let result = tokio::time::timeout(std::time::Duration::from_secs(1), inner).await; assert_eq!( result.expect("outer guard fired — with_deadline timeout seam missing"), Err(JwksFetchError::NetworkError), ); } + +// ── Refresh permit / cancellation-safety ───────────────────────────────────── + +/// Blocks until a oneshot releases it, signals an `entered` barrier on entry, +/// and returns `Ok(body)` or `Err` depending on the release value. +struct BlockingFetcher { + /// Fires as soon as `fetch_jwks` is entered — gives tests a deterministic + /// point to observe that the fetch is in progress before making assertions. + entered_tx: std::sync::Mutex>>, + /// Sending a body releases the fetch with success; dropping the sender + /// causes an error. + release_rx: std::sync::Mutex>>, + call_count: Arc, +} + +impl super::super::verifier::sealed::Sealed for BlockingFetcher {} + +impl JwksFetcher for BlockingFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + self.call_count.fetch_add(1, Ordering::SeqCst); + // Signal entry before any await so tests can observe it synchronously. + if let Some(tx) = self.entered_tx.lock().unwrap().take() { + let _ = tx.send(()); + } + let rx = self.release_rx.lock().unwrap().take(); + async move { + match rx { + Some(r) => r.await.map_err(|_| JwksFetchError::NetworkError), + None => Err(JwksFetchError::NetworkError), + } + } + } +} + +/// A second concurrent `get_snapshot` while the first fetch is in progress +/// must not start a second fetch — the permit blocks it. +/// +/// Mutation: dropping the permit before the fetch completes allows the second +/// call to win a fresh permit and start its own fetch, producing call_count 2. +#[tokio::test] +async fn concurrent_refresh_coalesces_without_second_fetch() { + let call_count = Arc::new(AtomicUsize::new(0)); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel::<()>(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel::(); + let fetcher = BlockingFetcher { + entered_tx: std::sync::Mutex::new(Some(entered_tx)), + release_rx: std::sync::Mutex::new(Some(release_rx)), + call_count: Arc::clone(&call_count), + }; + let issuer = "https://id.example"; + let source = Arc::new(ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap()); + + let source2 = Arc::clone(&source); + let issuer_owned = issuer.to_owned(); + let first = tokio::spawn(async move { source2.get_snapshot(&issuer_owned).await }); + + // Wait for the fetcher to confirm it has entered — the permit is held. + entered_rx.await.unwrap(); + + let second_result = source.get_snapshot(issuer).await; + let count_after_second = call_count.load(Ordering::SeqCst); + + let _ = release_tx.send(minimal_jwks_json("k1")); + let first_result = first.await.unwrap(); + + assert!(first_result.is_some()); + assert!( + second_result.is_none(), + "second concurrent call must not start a second fetch" + ); + assert_eq!( + count_after_second, 1, + "permit must coalesce concurrent callers" + ); +} + +/// Aborting the first caller releases the RAII permit; the next call on the +/// same source can acquire it and fetch successfully. +/// +/// Mutation: replacing the RAII permit with a manual boolean set only in the +/// success path leaves it true after abort; `get_snapshot` on the same source +/// returns None forever instead of fetching again. +#[tokio::test] +async fn aborted_first_caller_releases_permit_for_next_caller() { + let call_count = Arc::new(AtomicUsize::new(0)); + + // First call: blocks forever (we never send on this tx). + let (entered_tx_1, entered_rx_1) = tokio::sync::oneshot::channel::<()>(); + let (_no_release_tx, no_release_rx) = tokio::sync::oneshot::channel::(); + + // Second call: succeeds immediately after permit is released. + let (entered_tx_2, _entered_rx_2) = tokio::sync::oneshot::channel::<()>(); + let (release_tx_2, release_rx_2) = tokio::sync::oneshot::channel::(); + + // Use a shared Vec to hand out fetcher state across the two calls. + // The first call gets `entered_tx_1` + `no_release_rx` (blocks). + // The second call gets `entered_tx_2` + `release_rx_2` (succeeds). + let entered_txs = Arc::new(std::sync::Mutex::new(vec![ + Some(entered_tx_2), + Some(entered_tx_1), + ])); + let release_rxs = Arc::new(std::sync::Mutex::new(vec![ + Some(release_rx_2), + Some(no_release_rx), + ])); + + struct SequencedFetcher { + entered_txs: Arc>>>>, + release_rxs: Arc>>>>, + call_count: Arc, + } + impl super::super::verifier::sealed::Sealed for SequencedFetcher {} + impl JwksFetcher for SequencedFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + self.call_count.fetch_add(1, Ordering::SeqCst); + let entered = self.entered_txs.lock().unwrap().pop().flatten(); + let rx = self.release_rxs.lock().unwrap().pop().flatten(); + if let Some(tx) = entered { + let _ = tx.send(()); + } + async move { + match rx { + Some(r) => r.await.map_err(|_| JwksFetchError::NetworkError), + None => Err(JwksFetchError::NetworkError), + } + } + } + } + + let issuer = "https://id.example"; + let source = Arc::new( + ProductionJwksSource::new( + vec![make_config(issuer)], + SequencedFetcher { + entered_txs: Arc::clone(&entered_txs), + release_rxs: Arc::clone(&release_rxs), + call_count: Arc::clone(&call_count), + }, + ) + .unwrap(), + ); + + // First call: enters the fetch then is aborted. + { + let source2 = Arc::clone(&source); + let issuer_owned = issuer.to_owned(); + let first = tokio::spawn(async move { source2.get_snapshot(&issuer_owned).await }); + entered_rx_1.await.unwrap(); // confirmed inside the blocking fetch + first.abort(); + let _ = first.await; // join to confirm abort completed + } + + // The permit must now be released; the second call on the same source should succeed. + release_tx_2.send(minimal_jwks_json("k2")).unwrap(); + let result = source.get_snapshot(issuer).await; + assert!( + result.is_some(), + "second call on same source must succeed after permit is released" + ); + assert_eq!( + call_count.load(Ordering::SeqCst), + 2, + "must have fetched twice" + ); +} + +// ── Central fail-closed and issuer-isolation invariants ─────────────────────── + +/// An expired snapshot must never be served — both `get_snapshot` and the +/// synchronous `key_set` path return `None` after the hard deadline passes. +#[tokio::test] +async fn expired_snapshot_never_served_after_hard_deadline() { + let issuer = "https://id.example"; + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: format!("https://id.example/.well-known/jwks.json"), + refresh_interval_seconds: 1, + key_snapshot_hard_deadline_seconds: 2, + }; + let bodies = Arc::new(std::sync::Mutex::new(vec![ + Err::(JwksFetchError::NetworkError), + Ok(minimal_jwks_json("k1")), + ])); + struct FailAfterFirstFetcher { + bodies: Arc>>>, + } + impl super::super::verifier::sealed::Sealed for FailAfterFirstFetcher {} + impl JwksFetcher for FailAfterFirstFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self + .bodies + .lock() + .unwrap() + .pop() + .unwrap_or(Err(JwksFetchError::NetworkError)); + async move { result } + } + } + let source = ProductionJwksSource::new(vec![config], FailAfterFirstFetcher { bodies }).unwrap(); + + assert!( + source.get_snapshot(issuer).await.is_some(), + "initial fetch must succeed" + ); + + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + + assert!( + source.get_snapshot(issuer).await.is_none(), + "expired snapshot must not be served after hard deadline" + ); + + use crate::nip_fi::verifier::IssuerKeySource; + assert!( + source.key_set(issuer).is_none(), + "key_set must not serve an expired snapshot" + ); +} + +/// Two issuers are fully isolated: distinct key sets, independent generation +/// counters. Advancing only issuer A's document must not change issuer B. +#[tokio::test] +async fn two_issuer_keys_and_generations_are_isolated() { + let issuer_a = "https://a.example"; + let issuer_b = "https://b.example"; + + let a_bodies = Arc::new(std::sync::Mutex::new(vec![ + Ok::(minimal_jwks_json("a2")), + Ok(minimal_jwks_json("a1")), + ])); + let b_body = minimal_jwks_json("b1"); + + struct IsolationFetcher { + a_bodies: Arc>>>, + b_body: String, + } + impl super::super::verifier::sealed::Sealed for IsolationFetcher {} + impl JwksFetcher for IsolationFetcher { + fn fetch_jwks<'a>( + &'a self, + uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = if uri.contains("a.example") { + self.a_bodies + .lock() + .unwrap() + .pop() + .unwrap_or(Err(JwksFetchError::NetworkError)) + } else { + Ok(self.b_body.clone()) + }; + async move { result } + } + } + + let config_a = IssuerJwksConfig { + issuer: issuer_a.to_owned(), + jwks_uri: "https://a.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 1, + key_snapshot_hard_deadline_seconds: 3600, + }; + let config_b = IssuerJwksConfig { + issuer: issuer_b.to_owned(), + jwks_uri: "https://b.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 1, + key_snapshot_hard_deadline_seconds: 3600, + }; + + let source = ProductionJwksSource::new( + vec![config_a, config_b], + IsolationFetcher { + a_bodies: Arc::clone(&a_bodies), + b_body, + }, + ) + .unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + + source.get_snapshot(issuer_a).await.unwrap(); + source.get_snapshot(issuer_b).await.unwrap(); + + let gen_a1 = source.key_set(issuer_a).unwrap().generation(); + let gen_b1 = source.key_set(issuer_b).unwrap().generation(); + + assert_eq!(source.key_set(issuer_a).unwrap().issuer(), issuer_a); + assert_eq!(source.key_set(issuer_b).unwrap().issuer(), issuer_b); + + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + source.get_snapshot(issuer_a).await.unwrap(); + source.get_snapshot(issuer_b).await.unwrap(); + + let gen_a2 = source.key_set(issuer_a).unwrap().generation(); + let gen_b2 = source.key_set(issuer_b).unwrap().generation(); + + assert!( + gen_a2 > gen_a1, + "issuer A generation must advance after its document changes" + ); + assert_eq!( + gen_b2, gen_b1, + "issuer B generation must not change when only A's document changed" + ); + + assert_eq!(source.key_set(issuer_a).unwrap().issuer(), issuer_a); + assert_eq!(source.key_set(issuer_b).unwrap().issuer(), issuer_b); +} diff --git a/crates/buzz-core/src/network.rs b/crates/buzz-core/src/network.rs index fb3718d58c5..70395a60e3d 100644 --- a/crates/buzz-core/src/network.rs +++ b/crates/buzz-core/src/network.rs @@ -19,63 +19,91 @@ fn embedded_ipv4(v6: &std::net::Ipv6Addr, prefix: &[u8; 12]) -> Option bool { +/// Source: IANA IPv4 Special-Purpose Address Registry (2024-02) and +/// IANA IPv6 Special-Purpose Address Registry (2024-02). +/// +/// Non-globally-reachable ranges blocked: +/// +/// IPv4 +/// - 0.0.0.0/8 RFC 1122 — "This" host +/// - 10.0.0.0/8 RFC 1918 — private use +/// - 100.64.0.0/10 RFC 6598 — shared address space (CGNAT) +/// - 127.0.0.0/8 RFC 1122 — loopback +/// - 169.254.0.0/16 RFC 3927 — link-local +/// - 172.16.0.0/12 RFC 1918 — private use +/// - 192.0.0.0/24 RFC 6890 — IETF protocol assignments +/// (exceptions: 192.0.0.9 PCP anycast, 192.0.0.10 TURN anycast) +/// - 192.0.2.0/24 RFC 5737 — documentation TEST-NET-1 +/// - 192.88.99.0/24 RFC 7526 — deprecated 6to4 relay anycast +/// - 192.168.0.0/16 RFC 1918 — private use +/// - 198.18.0.0/15 RFC 2544 — network benchmarking +/// - 198.51.100.0/24 RFC 5737 — documentation TEST-NET-2 +/// - 203.0.113.0/24 RFC 5737 — documentation TEST-NET-3 +/// - 224.0.0.0/4 RFC 1112 — multicast +/// - 240.0.0.0/4 RFC 1112 — reserved/future use +/// - 255.255.255.255/32 RFC 919 — limited broadcast +/// +/// IPv6 +/// - ::/128 RFC 4291 — unspecified +/// - ::1/128 RFC 4291 — loopback +/// - ::ffff:0:0/96 RFC 4291 — IPv4-mapped (embedded IPv4 checked recursively) +/// - ::ffff:0:0:0/96 RFC 6145 — IPv4-translated SIIT (embedded IPv4 checked) +/// - 64:ff9b::/96 RFC 6052 — NAT64 well-known (embedded IPv4 checked) +/// - 64:ff9b:1::/48 RFC 8215 — local-use NAT64 +/// - 100::/64 RFC 6666 — discard-only +/// - 2001::/32 RFC 4380 — Teredo +/// - 2001:2::/48 RFC 5180 — benchmarking +/// - 2001:20::/28 RFC 7343 — ORCHIDv2 +/// - 2001:db8::/32 RFC 3849 — documentation +/// - 2002::/16 RFC 3056 — 6to4 (deprecated) +/// - fc00::/7 RFC 4193 — ULA +/// - fe80::/10 RFC 4291 — link-local +/// - ff00::/8 RFC 4291 — multicast +pub fn is_not_global_unicast(ip: &std::net::IpAddr) -> bool { match ip { std::net::IpAddr::V4(v4) => { - let octets = v4.octets(); + let o = v4.octets(); v4.is_loopback() || v4.is_private() || v4.is_link_local() - || octets[0] == 0 - || v4.is_broadcast() - // Carrier-Grade NAT (RFC 6598) — 100.64.0.0/10 - // Dangerous in cloud environments (AWS, GCP) where CGNAT can route to metadata services. - || (octets[0] == 100 && (octets[1] & 0xC0) == 64) - // Benchmarking (RFC 2544) — 198.18.0.0/15 - || (octets[0] == 198 && (octets[1] & 0xFE) == 18) + || o[0] == 0 // 0.0.0.0/8 + || v4.is_broadcast() // 255.255.255.255 + || (o[0] == 100 && (o[1] & 0xC0) == 64) // 100.64.0.0/10 CGNAT + || (o[0] == 198 && (o[1] & 0xFE) == 18) // 198.18.0.0/15 benchmarking + || (o[0] & 0xF0) == 0xE0 // 224.0.0.0/4 multicast + || (o[0] & 0xF0) == 0xF0 // 240.0.0.0/4 reserved + // 192.0.0.0/24 IETF protocol assignments — not globally reachable + // except 192.0.0.9 (PCP anycast, RFC 7723) and 192.0.0.10 (TURN anycast, RFC 8155). + || (o[0] == 192 && o[1] == 0 && o[2] == 0 && o[3] != 9 && o[3] != 10) + || (o[0] == 192 && o[1] == 0 && o[2] == 2) // 192.0.2.0/24 TEST-NET-1 + || (o[0] == 192 && o[1] == 88 && o[2] == 99) // 192.88.99.0/24 deprecated 6to4 + || (o[0] == 198 && o[1] == 51 && o[2] == 100) // 198.51.100.0/24 TEST-NET-2 + || (o[0] == 203 && o[1] == 0 && o[2] == 113) // 203.0.113.0/24 TEST-NET-3 } std::net::IpAddr::V6(v6) => { - // Check IPv4-compatible and mapped addresses against IPv4 rules. + // IPv4-compatible and IPv4-mapped addresses are checked against IPv4 rules. if let Some(v4) = v6.to_ipv4() { - return is_private_ip(&std::net::IpAddr::V4(v4)); + return is_not_global_unicast(&std::net::IpAddr::V4(v4)); } let segments = v6.segments(); - // NAT64 well-known prefix (RFC 6052). Preserve access to public IPv4 - // destinations while rejecting embedded private/reserved addresses. + // NAT64 well-known prefix (RFC 6052): reachability determined by the + // embedded IPv4 address. if let Some(v4) = embedded_ipv4(v6, &NAT64_WELL_KNOWN_PREFIX) { - return is_private_ip(&std::net::IpAddr::V4(v4)); + return is_not_global_unicast(&std::net::IpAddr::V4(v4)); } - // Legacy SIIT IPv4-translated addresses can route to the IPv4 value - // in their final four octets but are not recognized by `to_ipv4()`. + // SIIT IPv4-translated addresses route to the embedded IPv4 value and + // are not recognised by `to_ipv4()`. if let Some(v4) = embedded_ipv4(v6, &IPV4_TRANSLATED_PREFIX) { - return is_private_ip(&std::net::IpAddr::V4(v4)); + return is_not_global_unicast(&std::net::IpAddr::V4(v4)); } v6.is_loopback() @@ -83,280 +111,322 @@ pub fn is_private_ip(ip: &std::net::IpAddr) -> bool { || segments[0] & 0xfe00 == 0xfc00 // fc00::/7 ULA || segments[0] & 0xffc0 == 0xfe80 // fe80::/10 link-local || segments[0] & 0xff00 == 0xff00 // ff00::/8 multicast - || (segments[0] == 0x0064 - && segments[1] == 0xff9b - && segments[2] == 1) // 64:ff9b:1::/48 local-use NAT64 - || (segments[0] == 0x2001 && segments[1] == 0) // 2001::/32 Teredo - || segments[0] == 0x2002 // 2002::/16 6to4 - // RFC 3849 — documentation range, should never appear in production + // 64:ff9b:1::/48 local-use NAT64 (RFC 8215) + || (segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2] == 1) + // 100::/64 discard-only (RFC 6666) + || (segments[0] == 0x0100 && segments[1] == 0 && segments[2] == 0 && segments[3] == 0) + // 2001::/32 Teredo (RFC 4380) + || (segments[0] == 0x2001 && segments[1] == 0) + // 2001:2::/48 benchmarking (RFC 5180) + || (segments[0] == 0x2001 && segments[1] == 0x0002 && segments[2] == 0) + // 2001:20::/28 ORCHIDv2 (RFC 7343): top 28 bits = 2001:002x + || (segments[0] == 0x2001 && (segments[1] >> 4) == 0x0002) + // 2001:db8::/32 documentation (RFC 3849) || (segments[0] == 0x2001 && segments[1] == 0x0db8) + || segments[0] == 0x2002 // 2002::/16 6to4 deprecated (RFC 3056) } } } +/// Compatibility alias; prefer [`is_not_global_unicast`]. +#[inline] +pub fn is_private_ip(ip: &std::net::IpAddr) -> bool { + is_not_global_unicast(ip) +} + #[cfg(test)] mod tests { use super::*; use std::net::IpAddr; - #[test] - fn test_loopback_v4() { - assert!(is_private_ip(&"127.0.0.1".parse::().unwrap())); + // ── helpers ────────────────────────────────────────────────────────────── + + fn blocked(s: &str) -> bool { + is_not_global_unicast(&s.parse::().unwrap()) } + + // ── public positive controls ───────────────────────────────────────────── + #[test] - fn test_private_10() { - assert!(is_private_ip(&"10.0.0.1".parse::().unwrap())); + fn public_v4_cloudflare() { + assert!(!blocked("1.1.1.1")); } #[test] - fn test_private_172() { - assert!(is_private_ip(&"172.16.0.1".parse::().unwrap())); + fn public_v4_google() { + assert!(!blocked("8.8.8.8")); } #[test] - fn test_private_192() { - assert!(is_private_ip(&"192.168.1.1".parse::().unwrap())); + fn public_v6_cloudflare() { + assert!(!blocked("2606:4700::1")); } + + // ── RFC 1122 loopback / unspecified ────────────────────────────────────── + #[test] - fn test_link_local() { - assert!(is_private_ip(&"169.254.1.1".parse::().unwrap())); + fn loopback_v4() { + assert!(blocked("127.0.0.1")); } #[test] - fn test_unspecified() { - assert!(is_private_ip(&"0.0.0.0".parse::().unwrap())); + fn loopback_v6() { + assert!(blocked("::1")); } #[test] - fn test_broadcast() { - assert!(is_private_ip(&"255.255.255.255".parse::().unwrap())); + fn unspecified_v4() { + assert!(blocked("0.0.0.0")); } #[test] - fn test_public_v4() { - assert!(!is_private_ip(&"8.8.8.8".parse::().unwrap())); + fn unspecified_v6() { + assert!(blocked("::")); } + + // ── RFC 1918 private use ───────────────────────────────────────────────── + #[test] - fn test_loopback_v6() { - assert!(is_private_ip(&"::1".parse::().unwrap())); + fn private_10() { + assert!(blocked("10.0.0.1")); } #[test] - fn test_unspecified_v6() { - assert!(is_private_ip(&"::".parse::().unwrap())); + fn private_172() { + assert!(blocked("172.16.0.1")); } #[test] - fn test_ula_v6() { - assert!(is_private_ip(&"fd00::1".parse::().unwrap())); + fn private_192_168() { + assert!(blocked("192.168.1.1")); } + + // ── RFC 3927 link-local ─────────────────────────────────────────────────── + #[test] - fn test_link_local_v6() { - assert!(is_private_ip(&"fe80::1".parse::().unwrap())); + fn link_local_v4() { + assert!(blocked("169.254.1.1")); } #[test] - fn test_public_v6() { - assert!(!is_private_ip(&"2606:4700::1".parse::().unwrap())); + fn link_local_v6() { + assert!(blocked("fe80::1")); } + + // ── RFC 919 broadcast ──────────────────────────────────────────────────── + #[test] - fn test_documentation_range_v6() { - // 2001:db8::/32 — RFC 3849 documentation range, must be blocked - assert!(is_private_ip(&"2001:db8::1".parse::().unwrap())); - assert!(is_private_ip( - &"2001:db8:ffff::1".parse::().unwrap() - )); + fn broadcast() { + assert!(blocked("255.255.255.255")); } + + // ── RFC 6598 CGNAT 100.64.0.0/10 ──────────────────────────────────────── + #[test] - fn test_ipv4_mapped_v6_private() { - // ::ffff:10.0.0.1 is an IPv4-mapped IPv6 address pointing to a private IPv4 - assert!(is_private_ip(&"::ffff:10.0.0.1".parse::().unwrap())); + fn cgnat() { + assert!(blocked("100.64.0.1")); + assert!(blocked("100.127.255.254")); + assert!(!blocked("100.63.255.255")); // just below + assert!(!blocked("100.128.0.0")); // just above } + + // ── RFC 2544 benchmarking 198.18.0.0/15 ───────────────────────────────── + #[test] - fn test_ipv4_mapped_v6_loopback() { - assert!(is_private_ip( - &"::ffff:127.0.0.1".parse::().unwrap() - )); + fn benchmarking_v4() { + assert!(blocked("198.18.0.1")); + assert!(blocked("198.19.255.254")); + assert!(!blocked("198.17.255.255")); // just below + assert!(!blocked("198.20.0.0")); // just above } + + // ── RFC 1112 multicast 224.0.0.0/4 ────────────────────────────────────── + #[test] - fn test_ipv4_mapped_v6_public() { - assert!(!is_private_ip(&"::ffff:8.8.8.8".parse::().unwrap())); + fn multicast_v4() { + assert!(blocked("224.0.0.0")); + assert!(blocked("224.0.0.251")); // mDNS + assert!(blocked("239.255.255.250")); // SSDP + assert!(blocked("239.255.255.255")); + assert!(!blocked("223.255.255.255")); // just below } + + // ── RFC 1112 reserved 240.0.0.0/4 ─────────────────────────────────────── + #[test] - fn test_ipv4_compatible_v6_private() { - assert!(is_private_ip(&"::10.0.0.1".parse::().unwrap())); - assert!(is_private_ip(&"::127.0.0.1".parse::().unwrap())); - assert!(is_private_ip( - &"::169.254.169.254".parse::().unwrap() - )); - assert!(!is_private_ip(&"::8.8.8.8".parse::().unwrap())); + fn reserved_v4() { + assert!(blocked("240.0.0.0")); + assert!(blocked("254.255.255.255")); + assert!(!blocked("223.255.255.255")); // just below multicast boundary (also blocked, but distinct) } + + // ── RFC 6890 IETF protocol assignments 192.0.0.0/24 ───────────────────── + // Most of this range is not globally reachable; 192.0.0.9 (PCP anycast, + // RFC 7723) and 192.0.0.10 (TURN anycast, RFC 8155) are exceptions. + #[test] - fn test_nat64_well_known_prefix() { - let first = "64:ff9b::".parse().unwrap(); - let last = "64:ff9b::ffff:ffff".parse().unwrap(); - assert_eq!( - embedded_ipv4(&first, &NAT64_WELL_KNOWN_PREFIX), - Some("0.0.0.0".parse().unwrap()) - ); - assert_eq!( - embedded_ipv4(&last, &NAT64_WELL_KNOWN_PREFIX), - Some("255.255.255.255".parse().unwrap()) - ); - let embedded = "64:ff9b::172.16.1.2".parse().unwrap(); - assert_eq!( - embedded_ipv4(&embedded, &NAT64_WELL_KNOWN_PREFIX), - Some("172.16.1.2".parse().unwrap()) - ); - assert!(is_private_ip( - &"64:ff9b::10.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"64:ff9b::127.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"64:ff9b::169.254.169.254".parse::().unwrap() - )); - assert!(!is_private_ip( - &"64:ff9b::8.8.8.8".parse::().unwrap() - )); - assert!(!is_private_ip( - &"64:ff9a:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"64:ff9b::1:0:0".parse::().unwrap())); - } - #[test] - fn test_ipv4_translated_prefix() { - let first = "0:0:0:0:ffff:0:0:0".parse().unwrap(); - let last = "0:0:0:0:ffff:0:ffff:ffff".parse().unwrap(); - assert_eq!( - embedded_ipv4(&first, &IPV4_TRANSLATED_PREFIX), - Some("0.0.0.0".parse().unwrap()) - ); - assert_eq!( - embedded_ipv4(&last, &IPV4_TRANSLATED_PREFIX), - Some("255.255.255.255".parse().unwrap()) - ); - assert!(is_private_ip( - &"::ffff:0:10.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"::ffff:0:127.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"::ffff:0:169.254.169.254".parse::().unwrap() - )); - assert!(!is_private_ip( - &"::ffff:0:8.8.8.8".parse::().unwrap() - )); - assert!(!is_private_ip( - &"0:0:0:0:fffe:ffff:ffff:ffff".parse::().unwrap() - )); - assert!(!is_private_ip( - &"0:0:0:0:ffff:1:0:0".parse::().unwrap() - )); + fn ietf_protocol_assignments() { + assert!(blocked("192.0.0.0")); + assert!(blocked("192.0.0.1")); + assert!(blocked("192.0.0.170")); // NAT64/464XLAT — not globally reachable + assert!(blocked("192.0.0.255")); + assert!(!blocked("192.0.0.9")); // PCP anycast (RFC 7723) — globally reachable + assert!(!blocked("192.0.0.10")); // TURN anycast (RFC 8155) — globally reachable } + + // ── RFC 5737 documentation TEST-NET-1/2/3 ──────────────────────────────── + #[test] - fn test_nat64_local_use_prefix_boundaries() { - assert!(is_private_ip(&"64:ff9b:1::".parse::().unwrap())); - assert!(is_private_ip( - &"64:ff9b:1:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip( - &"64:ff9b::ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"64:ff9b:2::".parse::().unwrap())); + fn documentation_v4() { + assert!(blocked("192.0.2.0")); + assert!(blocked("192.0.2.255")); + assert!(blocked("198.51.100.0")); + assert!(blocked("198.51.100.255")); + assert!(blocked("203.0.113.0")); + assert!(blocked("203.0.113.255")); + // Adjacent addresses that are routable. + assert!(!blocked("192.0.1.255")); + assert!(!blocked("192.0.3.0")); + assert!(!blocked("198.51.99.255")); + assert!(!blocked("198.51.101.0")); + assert!(!blocked("203.0.112.255")); + assert!(!blocked("203.0.114.0")); } + + // ── RFC 7526 deprecated 6to4 relay anycast 192.88.99.0/24 ─────────────── + #[test] - fn test_teredo_prefix_boundaries() { - assert!(is_private_ip(&"2001::".parse::().unwrap())); - assert!(is_private_ip( - &"2001:0:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip( - &"2000:ffff:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"2001:1::1".parse::().unwrap())); + fn deprecated_6to4_relay_anycast() { + assert!(blocked("192.88.99.0")); + assert!(blocked("192.88.99.1")); + assert!(blocked("192.88.99.255")); + assert!(!blocked("192.88.98.255")); // just below + assert!(!blocked("192.88.100.0")); // just above } + + // ── RFC 4193 ULA ───────────────────────────────────────────────────────── + #[test] - fn test_6to4_prefix_boundaries() { - assert!(is_private_ip(&"2002::".parse::().unwrap())); - assert!(is_private_ip( - &"2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip( - &"2001:ffff:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"2003::1".parse::().unwrap())); + fn ula_v6() { + assert!(blocked("fd00::1")); } - // CGNAT (RFC 6598) — 100.64.0.0/10 + // ── RFC 3849 documentation 2001:db8::/32 ───────────────────────────────── + #[test] - fn test_cgnat_start() { - // 100.64.0.1 — start of CGNAT range - assert!(is_private_ip(&"100.64.0.1".parse::().unwrap())); + fn documentation_v6() { + assert!(blocked("2001:db8::1")); + assert!(blocked("2001:db8:ffff::1")); } + + // ── RFC 4291 multicast ff00::/8 ────────────────────────────────────────── + #[test] - fn test_cgnat_end() { - // 100.127.255.254 — end of CGNAT range - assert!(is_private_ip(&"100.127.255.254".parse::().unwrap())); + fn multicast_v6() { + assert!(blocked("ff02::1")); // all-nodes + assert!(blocked("ff02::2")); // all-routers + assert!(blocked("ffff::1")); + assert!(!blocked("fe00::1")); // just below } + + // ── RFC 6666 discard-only 100::/64 ─────────────────────────────────────── + #[test] - fn test_cgnat_below_range() { - // 100.63.255.255 — just below CGNAT range (100.0–100.63 is public) - assert!(!is_private_ip(&"100.63.255.255".parse::().unwrap())); + fn discard_only_v6() { + assert!(blocked("100::1")); + assert!(blocked("100::ffff:ffff:ffff:ffff")); + assert!(!blocked("100:0:0:1::1")); // outside /64 } + + // ── RFC 5180 benchmarking 2001:2::/48 ──────────────────────────────────── + #[test] - fn test_cgnat_above_range() { - // 100.128.0.0 — just above CGNAT range (100.128+ is public) - assert!(!is_private_ip(&"100.128.0.0".parse::().unwrap())); + fn benchmarking_v6() { + assert!(blocked("2001:2::1")); + assert!(blocked("2001:2:0:ffff:ffff:ffff:ffff:ffff")); + assert!(!blocked("2001:2:1::1")); // outside /48 } - // Benchmarking (RFC 2544) — 198.18.0.0/15 + // ── RFC 7343 ORCHIDv2 2001:20::/28 ─────────────────────────────────────── + #[test] - fn test_benchmarking_start() { - assert!(is_private_ip(&"198.18.0.1".parse::().unwrap())); + fn orchid_v6() { + assert!(blocked("2001:20::1")); + assert!(blocked("2001:2f::1")); // last /28 prefix + assert!(!blocked("2001:30::1")); // just outside + assert!(!blocked("2001:1::1")); // Teredo subdomain, but outside Teredo /32 range } + + // ── RFC 4380 Teredo 2001::/32 ──────────────────────────────────────────── + #[test] - fn test_benchmarking_end() { - assert!(is_private_ip(&"198.19.255.254".parse::().unwrap())); + fn teredo_v6() { + assert!(blocked("2001::")); + assert!(blocked("2001:0:ffff:ffff:ffff:ffff:ffff:ffff")); + assert!(!blocked("2001:1::1")); // outside /32 } + + // ── RFC 3056 6to4 2002::/16 ─────────────────────────────────────────────── + #[test] - fn test_benchmarking_below_range() { - // 198.17.255.255 — just below benchmarking range - assert!(!is_private_ip(&"198.17.255.255".parse::().unwrap())); + fn six_to_four_v6() { + assert!(blocked("2002::")); + assert!(blocked("2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff")); + assert!(!blocked("2003::1")); // outside /16 } + + // ── RFC 8215 local-use NAT64 64:ff9b:1::/48 ────────────────────────────── + #[test] - fn test_benchmarking_above_range() { - // 198.20.0.0 — just above benchmarking range - assert!(!is_private_ip(&"198.20.0.0".parse::().unwrap())); + fn nat64_local_use_v6() { + assert!(blocked("64:ff9b:1::")); + assert!(blocked("64:ff9b:1:ffff:ffff:ffff:ffff:ffff")); + assert!(!blocked("64:ff9b:2::")); // outside /48 } - // IPv6 multicast — ff00::/8 + // ── RFC 6052 NAT64 well-known 64:ff9b::/96 ─────────────────────────────── + // Reachability follows the embedded IPv4 address. + #[test] - fn test_ipv6_multicast_all_nodes() { - // ff02::1 — all-nodes multicast - assert!(is_private_ip(&"ff02::1".parse::().unwrap())); + fn nat64_well_known_prefix() { + let first = "64:ff9b::".parse().unwrap(); + let last = "64:ff9b::ffff:ffff".parse().unwrap(); + assert_eq!( + embedded_ipv4(&first, &NAT64_WELL_KNOWN_PREFIX), + Some("0.0.0.0".parse().unwrap()) + ); + assert_eq!( + embedded_ipv4(&last, &NAT64_WELL_KNOWN_PREFIX), + Some("255.255.255.255".parse().unwrap()) + ); + assert!(blocked("64:ff9b::10.0.0.1")); + assert!(blocked("64:ff9b::127.0.0.1")); + assert!(blocked("64:ff9b::169.254.169.254")); + assert!(!blocked("64:ff9b::8.8.8.8")); + assert!(!blocked("64:ff9a:ffff:ffff:ffff:ffff:ffff:ffff")); // different prefix + assert!(!blocked("64:ff9b::1:0:0")); // outside /96 } + + // ── SIIT IPv4-translated ::ffff:0:0:0/96 ───────────────────────────────── + #[test] - fn test_ipv6_multicast_all_routers() { - // ff02::2 — all-routers multicast - assert!(is_private_ip(&"ff02::2".parse::().unwrap())); + fn ipv4_translated_prefix() { + assert!(blocked("::ffff:0:10.0.0.1")); + assert!(blocked("::ffff:0:127.0.0.1")); + assert!(blocked("::ffff:0:169.254.169.254")); + assert!(!blocked("::ffff:0:8.8.8.8")); + assert!(!blocked("0:0:0:0:fffe:ffff:ffff:ffff")); // outside prefix + assert!(!blocked("0:0:0:0:ffff:1:0:0")); } + + // ── IPv4-mapped ::ffff:0:0/96 ───────────────────────────────────────────── + #[test] - fn test_ipv6_multicast_high() { - // ffff::1 — still in ff00::/8 - assert!(is_private_ip(&"ffff::1".parse::().unwrap())); + fn ipv4_mapped_v6() { + assert!(blocked("::ffff:10.0.0.1")); + assert!(blocked("::ffff:127.0.0.1")); + assert!(!blocked("::ffff:8.8.8.8")); } + + // ── IPv4-compatible ::/96 ───────────────────────────────────────────────── + #[test] - fn test_ipv6_not_multicast() { - // fe00:: — just below ff00::/8 (not multicast, not link-local, not ULA) - assert!(!is_private_ip(&"fe00::1".parse::().unwrap())); + fn ipv4_compatible_v6() { + assert!(blocked("::10.0.0.1")); + assert!(blocked("::127.0.0.1")); + assert!(blocked("::169.254.169.254")); + assert!(!blocked("::8.8.8.8")); } } From 39b90bcc2395175383efe842b0a3139d183cff60 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Mon, 31 Aug 2026 12:53:54 -0400 Subject: [PATCH 15/15] feat(buzz-auth,buzz-db): NIP-FI production authority interface spine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the closed types and entry points that PR 5 (relay ingress) branches from. buzz-auth: authority module - PreparedAuthorization — read-only evidence package produced by prepare_direct (FI-INV-08: no mutation during preparation). Public constructor requires a VerifiedAssertion, proven actor, community UUID, BindingProposal, authority deadlines, PreparedDependencyVersions, and a correlation ID. Accessor methods expose every field PR 5 reads; verified_assertion() provides the confidential JWS handle for revalidation. - BindingProposal — closed enum (Existing | Enroll) with BindingProvenance (AttestedKey / Provisioned / Tofu) and db_code() aligned to the identity_bindings.binding_provenance CHECK constraint (1/2/3). - PreparedDependencyVersions — snapshot of policy_revision, invalidation_generation, and authority_epoch read atomically during preparation. Final admission re-reads these to detect stale state. - CommittedAuthorization — authority token produced by commit_admission. Public constructor requires all outputs of the atomic write: actor, identity, capabilities, authority deadlines, binding_id, binding_version, operation_id, correlation_id, expires_at. Only the buzz-db admission path produces these values. Accessors cover every field the relay ingress enforces without re-reading the DB. - AdmissionError — closed, stable enum; every variant maps to exactly one DenialClass via denial_class() and to a unique stable machine code via code(). Variants cover deadline expiry, equivalence failure, contract-ID change, private-state denials (key revoked, pair retired, binding conflict, attestation required, binding required, local policy, invalidation advanced, stale version), and availability failures (audit capacity, dependency). No variant carries credential material (FI-INV-13). 13-variant uniqueness and denial-class mapping verified by unit tests. buzz-db: nip_fi_authority store module - prepare_direct — reads Y_D(k), T_D(i,k), B_D(i), B_D(k), enrollment policy, and invalidation generation in a single REPEATABLE READ read-only transaction. Evaluates the binding proposal via the NIP-FI.md PrepareDirect pseudocode (existing → Existing; conflict → BindingConflict; no binding → enrollment policy check). Returns PreparedAuthorization. Writes nothing (FI-INV-08). - commit_admission — re-verifies deadline liveness, assertion equivalence, contract-ID stability, and bounds-class deadline regression before writing. Serializable transaction: re-reads invalidation generation; re-checks binding conflicts for Enroll proposals; inserts enrollment receipt + lifecycle history + binding row (with generated binding_version read back) or locks the existing binding FOR NO KEY UPDATE; inserts the protected-mutation receipt and admission result. All commit or none (FI-INV-09). Returns CommittedAuthorization. - PrepareError — 7-variant closed error with denial_class() and unique code(). Denial-class mapping and code uniqueness verified by unit tests. Dependencies: buzz-db gains buzz-auth as a direct dependency (buzz-auth has no dependency on buzz-db, so no cycle). buzz-auth gains uuid (already present). All 168 buzz-auth tests and 116 buzz-db unit tests pass. fmt-check and clippy both clean. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- Cargo.lock | 1 + crates/buzz-auth/src/lib.rs | 16 +- crates/buzz-auth/src/nip_fi/authority.rs | 747 ++++++++ crates/buzz-auth/src/nip_fi/mod.rs | 8 +- crates/buzz-db/Cargo.toml | 1 + crates/buzz-db/src/lib.rs | 10 +- crates/buzz-db/src/runtime/migration.rs | 7 +- crates/buzz-db/src/store/mod.rs | 2 + crates/buzz-db/src/store/nip_fi_authority.rs | 1688 +++++++++++++++++ .../0042_nip_fi_authorization_foundation.sql | 33 +- schema/schema.sql | 28 +- 11 files changed, 2525 insertions(+), 16 deletions(-) create mode 100644 crates/buzz-auth/src/nip_fi/authority.rs create mode 100644 crates/buzz-db/src/store/nip_fi_authority.rs diff --git a/Cargo.lock b/Cargo.lock index 552a12ca155..cbf13970527 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1059,6 +1059,7 @@ dependencies = [ name = "buzz-db" version = "0.1.0" dependencies = [ + "buzz-auth", "buzz-core", "buzz-datastore-tracing", "chrono", diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index 65366ddef8c..53f231d8834 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -46,13 +46,15 @@ pub use rate_limit::{ pub use scope::{parse_scopes, Scope}; pub use nip_fi::{ - validate_nip_fi_config, AssertionKeySet, AssertionPolicyId, CanonicalCapabilities, - ClientSubjectPosture, ConfidentialAssertion, DenialClass, FederatedAssertionVerifier, - FederatedIdentity, FederatedIdentityDiscovery, FreshnessClass, HttpJwksFetcher, - IssuerJwksConfig, IssuerKeySource, IssuerPolicy, IssuerPolicyError, IssuerRegistry, - JwksFetchError, JwksFetcher, NipFiMode, NipFiStartupError, ProductionJwksSource, - RevalidationDependencies, SubjectClass, SubjectClassContract, TokenClass, TransportContractId, - VerifiedAssertion, VerifierError, CLIENT_ATTACHED_HEADER, NOSTR_PUBKEY_CLAIM, + validate_nip_fi_config, AdmissionError, AssertionKeySet, AssertionPolicyId, BindingProposal, + BindingProvenance, CanonicalCapabilities, ClientSubjectPosture, ConfidentialAssertion, + DenialClass, ExactProtectedUse, FederatedAssertionVerifier, FederatedIdentity, + FederatedIdentityDiscovery, FreshnessClass, HttpJwksFetcher, IssuerJwksConfig, IssuerKeySource, + IssuerPolicy, IssuerPolicyError, IssuerRegistry, JwksFetchError, JwksFetcher, NipFiMode, + NipFiStartupError, OperationIntent, PreparedDependencyVersions, ProductionJwksSource, + ProofTransport, ProtectedObjectKind, RevalidationDependencies, RouteCapability, SubjectClass, + SubjectClassContract, TokenClass, TransportContractId, VerifiedAssertion, + VerifiedServerDirectContext, VerifierError, CLIENT_ATTACHED_HEADER, NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, }; diff --git a/crates/buzz-auth/src/nip_fi/authority.rs b/crates/buzz-auth/src/nip_fi/authority.rs new file mode 100644 index 00000000000..d3d90d18f0e --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/authority.rs @@ -0,0 +1,747 @@ +//! Closed types for NIP-FI prepared and committed authorization. +//! +//! ## Types defined here (buzz-auth) +//! +//! - [`RouteCapability`] — server-owned closed capability vocabulary; code `2` +//! (`MessagesWrite`) is the canonical mapping for WebSocket event ingress. +//! - [`ProtectedObjectKind`] — closed protected-object namespace; code `2` +//! (`Channel`) is the kind for kind-9 channel message admission. +//! - [`ProofTransport`] — closed transport discriminant for the Nostr proof. +//! - [`VerifiedServerDirectContext`] — origin-sealed server-resolved request +//! context. Fields are private; the only constructor lives in +//! `buzz-db::store::nip_fi_authority` so that only the trusted +//! target/proof-validation path can produce one (`FI-INV-04`). +//! - [`ExactProtectedUse`] — the exact capability/object/intent tuple presented +//! by the caller when redeeming a [`CommittedAuthorization`]. +//! - [`BindingProposal`] / [`BindingProvenance`] / [`PreparedDependencyVersions`] +//! — data types shared between preparation and admission. +//! - [`AdmissionError`] — closed, stable admission failure enum; 16 variants, +//! each maps to exactly one [`DenialClass`] (`FI-INV-13`). +//! +//! ## Types owned in buzz-db (non-forgeable authority) +//! +//! `PreparedAuthorization`, `CommittedAuthorization`, and `AuthorizedUse` are +//! defined in `buzz-db::store::nip_fi_authority` with `pub(crate)` constructors +//! so that only the PostgreSQL admission path can produce them. They are +//! re-exported from `buzz-db`'s public surface for relay-ingress consumption. +//! No sibling crate can mint authority-bearing types. + +use super::assertion::FederatedIdentity; +use super::denial::DenialClass; +use chrono::{DateTime, Utc}; +use nostr::PublicKey; +use uuid::Uuid; + +// ── Route capability vocabulary ─────────────────────────────────────────────── + +/// Server-owned closed route capability. +/// +/// The database code is the authoritative stable identifier written to +/// `protected_object_authority.capability`; no other value is valid. +/// WebSocket event ingress (kind-9 channel messages) maps to +/// [`RouteCapability::MessagesWrite`] / code `2`. +/// +/// The database code table matches the historical `capability_code` mapping at +/// commit `341a08a42 crates/buzz-db/src/authorization_admission.rs`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum RouteCapability { + /// Read messages. DB code: 1. + MessagesRead, + /// Write messages (WebSocket event ingress, kind-9). DB code: 2. + MessagesWrite, + /// Read channel metadata. DB code: 3. + ChannelsRead, + /// Mutate channels. DB code: 4. + ChannelsWrite, + /// Channel administration. DB code: 5. + AdminChannels, + /// Read user metadata. DB code: 6. + UsersRead, + /// Mutate user metadata. DB code: 7. + UsersWrite, + /// User administration. DB code: 8. + AdminUsers, + /// Read jobs. DB code: 9. + JobsRead, + /// Mutate jobs. DB code: 10. + JobsWrite, + /// Read subscriptions. DB code: 11. + SubscriptionsRead, + /// Mutate subscriptions. DB code: 12. + SubscriptionsWrite, + /// Read files. DB code: 13. + FilesRead, + /// Write files. DB code: 14. + FilesWrite, + /// Read repositories. DB code: 15. + ReposRead, + /// Write repositories. DB code: 16. + ReposWrite, + /// Read Git objects and refs. DB code: 17. + GitRead, + /// Mutate Git objects and refs. DB code: 18. + GitWrite, + /// Bounded Git streaming. DB code: 19. + GitStream, + /// Read media. DB code: 20. + MediaRead, + /// Upload or mutate media. DB code: 21. + MediaWrite, + /// Perform moderation operations. DB code: 22. + Moderation, + /// Join an audio session. DB code: 23. + AudioJoin, + /// Send or receive bounded audio media. DB code: 24. + AudioMedia, + /// Read protected discovery data. DB code: 25. + Discovery, + /// Read current local binding status. DB code: 26. + BindingStatus, + /// Enroll a local binding. DB code: 27. + Enrollment, + /// Mint an invitation. DB code: 28. + InviteMint, + /// Claim an invitation. DB code: 29. + InviteClaim, +} + +impl RouteCapability { + /// Stable code written to `protected_object_authority.capability`. + /// Values are fixed and must not change once rows exist in the database. + pub const fn database_code(self) -> i16 { + match self { + Self::MessagesRead => 1, + Self::MessagesWrite => 2, + Self::ChannelsRead => 3, + Self::ChannelsWrite => 4, + Self::AdminChannels => 5, + Self::UsersRead => 6, + Self::UsersWrite => 7, + Self::AdminUsers => 8, + Self::JobsRead => 9, + Self::JobsWrite => 10, + Self::SubscriptionsRead => 11, + Self::SubscriptionsWrite => 12, + Self::FilesRead => 13, + Self::FilesWrite => 14, + Self::ReposRead => 15, + Self::ReposWrite => 16, + Self::GitRead => 17, + Self::GitWrite => 18, + Self::GitStream => 19, + Self::MediaRead => 20, + Self::MediaWrite => 21, + Self::Moderation => 22, + Self::AudioJoin => 23, + Self::AudioMedia => 24, + Self::Discovery => 25, + Self::BindingStatus => 26, + Self::Enrollment => 27, + Self::InviteMint => 28, + Self::InviteClaim => 29, + } + } +} + +// ── Protected object kinds ──────────────────────────────────────────────────── + +/// Closed protected-object namespace. +/// +/// Matches `authorization_authority_epochs.object_kind` and +/// `protected_object_authority.object_kind` in migration 0042. +/// Kind-9 channel message admission uses [`ProtectedObjectKind::Channel`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProtectedObjectKind { + /// Domain-wide authority. DB code: 1. + Domain, + /// One channel. DB code: 2. Used for kind-9 channel message admission. + Channel, + /// One repository. DB code: 3. + Repository, + /// One media object. DB code: 4. + Media, + /// One moderation target. DB code: 5. + ModerationTarget, + /// One audio session. DB code: 6. + AudioSession, +} + +impl ProtectedObjectKind { + /// Stable code written to `object_kind` columns in migration 0042. + pub const fn database_code(self) -> i16 { + match self { + Self::Domain => 1, + Self::Channel => 2, + Self::Repository => 3, + Self::Media => 4, + Self::ModerationTarget => 5, + Self::AudioSession => 6, + } + } +} + +// ── Operation intent ────────────────────────────────────────────────────────── + +/// Closed operation intent discriminant for the local policy matrix. +/// +/// The kind-9 core path maps to [`OperationIntent::Mutation`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OperationIntent { + /// A read or query operation. DB code: 1. + Query, + /// A write or mutation operation. DB code: 2. + Mutation, +} + +impl OperationIntent { + /// Stable code for the local policy matrix. + pub const fn database_code(self) -> i16 { + match self { + Self::Query => 1, + Self::Mutation => 2, + } + } +} + +// ── Proof transport ─────────────────────────────────────────────────────────── + +/// The Nostr-proof transport that bound the actor to the request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProofTransport { + /// WebSocket NIP-42 AUTH event. DB code: 1. + Nip42, + /// HTTP NIP-98 signed event. DB code: 2. + Nip98, +} + +impl ProofTransport { + /// Stable code for fingerprint domain separation. + pub const fn database_code(self) -> i16 { + match self { + Self::Nip42 => 1, + Self::Nip98 => 2, + } + } +} + +// ── Origin-sealed server-resolved request context ───────────────────────────── + +/// Origin-sealed server-resolved request context for NIP-FI direct admission. +/// +/// Every field is resolved and validated by the trusted relay routing and +/// proof-validation path before `prepare_direct` is called. No client-supplied +/// value, unsigned header, or assertion claim may set any field here +/// (`FI-INV-04`). +/// +/// **Constructor is `pub(crate)` in `buzz-db`** — this type is defined in +/// `buzz-auth` for its type identity but only the PostgreSQL admission path in +/// `buzz-db::store::nip_fi_authority` can construct it. Sibling crates cannot +/// mint a `VerifiedServerDirectContext`. +/// +/// ## Kind-9 canonical tuple +/// +/// ```text +/// capability = RouteCapability::MessagesWrite (code 2) +/// object_kind = ProtectedObjectKind::Channel (code 2) +/// intent = OperationIntent::Mutation (code 2) +/// object_key = SHA-256(channel_uuid.as_bytes()) — canonical 16-byte big-endian UUID +/// transport = ProofTransport::Nip42 +/// ``` +/// +/// Receipt `operation_kind = 11` (protected-mutation) is the fixed DB constant, +/// never derived from `capability` or `intent` here. +/// +/// ## `object_key` encoding +/// +/// `object_key` is `SHA-256(canonical_object_bytes)`: +/// - `Channel`: `SHA-256(channel_uuid.as_bytes())` — 16-byte big-endian. +/// - `Domain`: `SHA-256(community_uuid.as_bytes())`. +/// - Other kinds: analogously. Always server-resolved; no client value accepted. +/// +/// ## `channel_uuid` for DB lookup +/// +/// `channel_uuid_raw` carries the raw private UUID for the `(community_id, +/// channel_id)` resource-authority DB lookup inside `prepare_direct`. It is +/// never exposed on any public surface; `object_key` is the only derived form +/// that leaves the admission path. +#[derive(Debug)] +pub struct VerifiedServerDirectContext { + /// Nostr-proof transport that bound the actor (NIP-42 or NIP-98). + pub transport: ProofTransport, + /// Full 32-byte event ID of the NIP-42 AUTH or NIP-98 proof event. + /// This is the durable replay-claim coordinate. + pub proof_event_id: [u8; 32], + /// Freshness deadline of the proof. No admission may proceed at or after + /// this instant. + pub proof_expires_at: DateTime, + /// Server-resolved 32-byte Nostr public key of the proven actor. + pub actor: PublicKey, + /// Community (tenant) UUID for this admission. + pub community_id: Uuid, + /// Server-resolved protected-operation capability. + pub capability: RouteCapability, + /// Server-resolved protected-object kind. + pub object_kind: ProtectedObjectKind, + /// Server-resolved operation intent (query or mutation). + pub intent: OperationIntent, + /// SHA-256 of the canonical server-resolved object identifier. + /// For Channel: `SHA-256(channel_uuid.as_bytes())`. + pub object_key: [u8; 32], + /// Raw private channel UUID for the resource-authority DB lookup. + /// Only set for `ProtectedObjectKind::Channel`. Never exposed externally; + /// `object_key` is the only derived form that leaves the admission path. + pub channel_uuid_raw: Option<[u8; 16]>, +} + +impl VerifiedServerDirectContext { + /// Construct a `VerifiedServerDirectContext` from trusted routing inputs. + /// + /// **Intended only for the trusted relay proof-validation path.** The + /// caller must have already validated the Nostr proof and resolved all + /// fields from authenticated server state. No client-supplied value may + /// flow here (`FI-INV-04`). + #[allow(clippy::too_many_arguments)] + pub fn new( + transport: ProofTransport, + proof_event_id: [u8; 32], + proof_expires_at: DateTime, + actor: PublicKey, + community_id: Uuid, + capability: RouteCapability, + object_kind: ProtectedObjectKind, + intent: OperationIntent, + object_key: [u8; 32], + channel_uuid_raw: Option<[u8; 16]>, + ) -> Self { + Self { + transport, + proof_event_id, + proof_expires_at, + actor, + community_id, + capability, + object_kind, + intent, + object_key, + channel_uuid_raw, + } + } +} + +// ── Exact protected use ─────────────────────────────────────────────────────── + +/// The exact capability/object/intent tuple presented by the caller when +/// redeeming a [`CommittedAuthorization`] via `authorize_protected_use`. +/// +/// `authorize_protected_use` exact-matches this tuple against the committed +/// context; any mismatch denies the use without a receipt. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ExactProtectedUse { + /// The requested capability. + pub capability: RouteCapability, + /// The requested protected-object kind. + pub object_kind: ProtectedObjectKind, + /// The requested operation intent. + pub intent: OperationIntent, + /// SHA-256 of the canonical server-resolved object identifier. + pub object_key: [u8; 32], +} + +// ── Binding types ───────────────────────────────────────────────────────────── + +/// The immutable enrollment provenance recorded in a binding row. +/// +/// Corresponds to the `binding_provenance` column: `1 attested-key`, +/// `2 provisioned`, `3 risk-labelled TOFU`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BindingProvenance { + /// Assertion carried a `nostr_pubkey` claim matching the proven actor. + AttestedKey, + /// Binding created by a separately authorized provisioning operation. + Provisioned, + /// TOFU enrollment: first-use binding under deployment TOFU risk posture. + Tofu, +} + +impl BindingProvenance { + /// Closed integer code for `binding_provenance` column. + pub const fn as_db_code(self) -> i16 { + match self { + Self::AttestedKey => 1, + Self::Provisioned => 2, + Self::Tofu => 3, + } + } +} + +/// The prepared binding proposal. +/// +/// Read-only evidence; preparation never writes a binding row (`FI-INV-08`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BindingProposal { + /// An active binding for `(domain, i, k)` already exists. + Existing { + /// The binding ID of the existing active binding. + binding_id: Uuid, + /// Monotonic binding version for stale-check on final admission. + binding_version: i64, + /// Enrollment provenance of the existing binding. + provenance: BindingProvenance, + /// Binding expiry deadline, if set, captured at preparation time. + expires_at: Option>, + }, + /// No active binding exists; final admission must create one atomically. + Enroll { + /// The federated identity to bind. + identity: FederatedIdentity, + /// The proven actor's public key. + actor: PublicKey, + /// Enrollment provenance for the new binding. + provenance: BindingProvenance, + /// Policy revision at enrollment time. + policy_revision: i64, + }, +} + +/// Snapshot of every dependency version read atomically during preparation. +/// +/// Final admission compares each field against a current authoritative read +/// inside the SERIALIZABLE transaction: +/// +/// - `invalidation_generation`: if advanced → `InvalidationGenerationAdvanced` +/// - `policy_revision`: if changed → re-evaluate local policy; if denied → +/// `PolicyRevisionChanged` +/// - `authority_epoch`: if changed for this object → `AuthorityEpochChanged` +/// - `lifecycle_revision`: binding lifecycle state version at preparation time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreparedDependencyVersions { + /// Policy revision at preparation time. + pub policy_revision: i64, + /// Invalidation generation at preparation time. + pub invalidation_generation: i64, + /// Authority epoch for the target object at preparation time. `None` + /// means the object had no epoch row (first-grant path). + pub authority_epoch: Option, + /// Authority fence for the target object at preparation time. `None` + /// means no fence row exists yet. + pub authority_fence: Option, + /// Binding lifecycle revision at preparation time. `None` for fresh + /// enrollment proposals. + pub lifecycle_revision: Option, +} + +// ── Admission error ─────────────────────────────────────────────────────────── + +/// Closed, stable admission failure. +/// +/// Every variant maps to exactly one [`DenialClass`]; granular codes are for +/// access-controlled logs only. No variant carries credential material +/// (`FI-INV-13`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum AdmissionError { + // ── Evidence rejection (403 EvidenceRejected) ──────────────────────────── + /// A prepared deadline did not survive preparation → commit. + #[error("prepared assertion deadline expired between preparation and admission")] + PreparedDeadlineExpired, + + /// The re-verified assertion differs from the prepared one on an + /// identity-class field, or a bounds-class deadline regressed. + #[error("prepared assertion is not equivalent to current revalidation")] + AssertionEquivalenceViolation, + + /// An assertion-policy or transport-contract ID changed between + /// preparation and admission. + #[error("contract ID changed between preparation and admission")] + ContractIdChanged, + + // ── Authorization denial (403 AuthorizationDenied) ─────────────────────── + /// Actor key appears in the revoked-key selector set `Y_D(k)`. + #[error("actor key is revoked")] + KeyRevoked, + + /// Exact `(i, k)` pair appears in the retired-pair selector set `T_D(i,k)`. + #[error("identity/key pair is retired")] + PairRetired, + + /// A different active binding exists for `i` or `k` in this domain. + #[error("binding conflict: another active binding exists for this identity or key")] + BindingConflict, + + /// Enrollment policy is `attested-key` but no matching `nostr_pubkey` claim. + #[error("attested-key enrollment required but no key attestation in assertion")] + AttestationRequired, + + /// Enrollment policy requires an existing binding; no enrollment permitted. + #[error("existing binding required; enrollment not permitted")] + BindingRequired, + + /// Local policy denied the operation for this context tuple. + #[error("local policy denied the operation")] + LocalPolicyDenied, + + /// Invalidation generation advanced between preparation and admission. + #[error("invalidation generation advanced: prepared evidence is stale")] + InvalidationGenerationAdvanced, + + /// Policy revision changed; re-evaluated local policy denied. + #[error("policy revision changed: re-evaluated local policy denied")] + PolicyRevisionChanged, + + /// The prepared binding version no longer matches current state. + #[error("prepared binding version is stale")] + PreparedBindingVersionStale, + + /// The authority epoch for the target object changed between preparation + /// and admission; the prepared proposal may not apply. + #[error("protected object authority epoch changed: prepared evidence is stale")] + AuthorityEpochChanged, + + /// The proof event identity was already committed for this community. + /// Classified `AuthorizationDenied` so replay is indistinguishable from + /// any other private-state denial (`FI-TRACE-DENIAL-ORACLE`). + #[error("proof identity already committed: replay denied")] + ProofReplayed, + + /// Protected use tuple did not exactly match the committed context. + /// Classified `AuthorizationDenied` to prevent operation/resource + /// cross-use oracle attacks. + #[error("exact protected-use tuple does not match committed context")] + ProtectedUseMismatch, + + // ── Availability failure (503 AuthorizationUnavailable) ────────────────── + /// Authorization audit capacity exhausted or unhealthy. + #[error("authorization audit capacity exhausted or unavailable")] + AuditCapacityUnavailable, + + /// A required authoritative dependency could not be read. + #[error("required authoritative dependency unavailable")] + AuthoritativeDependencyUnavailable, +} + +impl AdmissionError { + /// The public denial class for this error. + pub const fn denial_class(self) -> DenialClass { + match self { + Self::PreparedDeadlineExpired + | Self::AssertionEquivalenceViolation + | Self::ContractIdChanged => DenialClass::EvidenceRejected, + + Self::KeyRevoked + | Self::PairRetired + | Self::BindingConflict + | Self::AttestationRequired + | Self::BindingRequired + | Self::LocalPolicyDenied + | Self::InvalidationGenerationAdvanced + | Self::PolicyRevisionChanged + | Self::PreparedBindingVersionStale + | Self::AuthorityEpochChanged + | Self::ProofReplayed + | Self::ProtectedUseMismatch => DenialClass::AuthorizationDenied, + + Self::AuditCapacityUnavailable | Self::AuthoritativeDependencyUnavailable => { + DenialClass::AuthorizationUnavailable + } + } + } + + /// Stable machine code for access-controlled logs and metrics. + pub const fn code(self) -> &'static str { + match self { + Self::PreparedDeadlineExpired => "nip_fi_prepared_deadline_expired", + Self::AssertionEquivalenceViolation => "nip_fi_assertion_equivalence_violation", + Self::ContractIdChanged => "nip_fi_contract_id_changed", + Self::KeyRevoked => "nip_fi_key_revoked", + Self::PairRetired => "nip_fi_pair_retired", + Self::BindingConflict => "nip_fi_binding_conflict", + Self::AttestationRequired => "nip_fi_attestation_required", + Self::BindingRequired => "nip_fi_binding_required", + Self::LocalPolicyDenied => "nip_fi_local_policy_denied", + Self::InvalidationGenerationAdvanced => "nip_fi_invalidation_generation_advanced", + Self::PolicyRevisionChanged => "nip_fi_policy_revision_changed", + Self::PreparedBindingVersionStale => "nip_fi_prepared_binding_version_stale", + Self::AuthorityEpochChanged => "nip_fi_authority_epoch_changed", + Self::ProofReplayed => "nip_fi_proof_replayed", + Self::ProtectedUseMismatch => "nip_fi_protected_use_mismatch", + Self::AuditCapacityUnavailable => "nip_fi_audit_capacity_unavailable", + Self::AuthoritativeDependencyUnavailable => { + "nip_fi_authoritative_dependency_unavailable" + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_evidence_rejection_variant_maps_to_evidence_rejected() { + for v in [ + AdmissionError::PreparedDeadlineExpired, + AdmissionError::AssertionEquivalenceViolation, + AdmissionError::ContractIdChanged, + ] { + assert_eq!(v.denial_class(), DenialClass::EvidenceRejected, "{v:?}"); + } + } + + #[test] + fn every_private_state_variant_maps_to_authorization_denied() { + for v in [ + AdmissionError::KeyRevoked, + AdmissionError::PairRetired, + AdmissionError::BindingConflict, + AdmissionError::AttestationRequired, + AdmissionError::BindingRequired, + AdmissionError::LocalPolicyDenied, + AdmissionError::InvalidationGenerationAdvanced, + AdmissionError::PolicyRevisionChanged, + AdmissionError::PreparedBindingVersionStale, + AdmissionError::AuthorityEpochChanged, + AdmissionError::ProofReplayed, + AdmissionError::ProtectedUseMismatch, + ] { + assert_eq!(v.denial_class(), DenialClass::AuthorizationDenied, "{v:?}"); + } + } + + #[test] + fn every_availability_variant_maps_to_authorization_unavailable() { + for v in [ + AdmissionError::AuditCapacityUnavailable, + AdmissionError::AuthoritativeDependencyUnavailable, + ] { + assert_eq!( + v.denial_class(), + DenialClass::AuthorizationUnavailable, + "{v:?}" + ); + } + } + + #[test] + fn every_variant_has_a_unique_stable_code() { + let variants = [ + AdmissionError::PreparedDeadlineExpired, + AdmissionError::AssertionEquivalenceViolation, + AdmissionError::ContractIdChanged, + AdmissionError::KeyRevoked, + AdmissionError::PairRetired, + AdmissionError::BindingConflict, + AdmissionError::AttestationRequired, + AdmissionError::BindingRequired, + AdmissionError::LocalPolicyDenied, + AdmissionError::InvalidationGenerationAdvanced, + AdmissionError::PolicyRevisionChanged, + AdmissionError::PreparedBindingVersionStale, + AdmissionError::AuthorityEpochChanged, + AdmissionError::ProofReplayed, + AdmissionError::ProtectedUseMismatch, + AdmissionError::AuditCapacityUnavailable, + AdmissionError::AuthoritativeDependencyUnavailable, + ]; + let mut codes = std::collections::BTreeSet::new(); + for v in &variants { + assert!(codes.insert(v.code()), "duplicate code: {}", v.code()); + } + assert_eq!(codes.len(), variants.len()); + } + + #[test] + fn binding_provenance_db_codes_are_schema_aligned() { + assert_eq!(BindingProvenance::AttestedKey.as_db_code(), 1); + assert_eq!(BindingProvenance::Provisioned.as_db_code(), 2); + assert_eq!(BindingProvenance::Tofu.as_db_code(), 3); + } + + #[test] + fn route_capability_db_codes_are_schema_aligned() { + // Core assertions — the full table is the source of truth above. + assert_eq!(RouteCapability::MessagesRead.database_code(), 1); + assert_eq!(RouteCapability::MessagesWrite.database_code(), 2); + assert_eq!(RouteCapability::InviteClaim.database_code(), 29); + } + + #[test] + fn route_capability_codes_are_unique() { + let capabilities = [ + RouteCapability::MessagesRead, + RouteCapability::MessagesWrite, + RouteCapability::ChannelsRead, + RouteCapability::ChannelsWrite, + RouteCapability::AdminChannels, + RouteCapability::UsersRead, + RouteCapability::UsersWrite, + RouteCapability::AdminUsers, + RouteCapability::JobsRead, + RouteCapability::JobsWrite, + RouteCapability::SubscriptionsRead, + RouteCapability::SubscriptionsWrite, + RouteCapability::FilesRead, + RouteCapability::FilesWrite, + RouteCapability::ReposRead, + RouteCapability::ReposWrite, + RouteCapability::GitRead, + RouteCapability::GitWrite, + RouteCapability::GitStream, + RouteCapability::MediaRead, + RouteCapability::MediaWrite, + RouteCapability::Moderation, + RouteCapability::AudioJoin, + RouteCapability::AudioMedia, + RouteCapability::Discovery, + RouteCapability::BindingStatus, + RouteCapability::Enrollment, + RouteCapability::InviteMint, + RouteCapability::InviteClaim, + ]; + let mut codes = std::collections::BTreeSet::new(); + for c in &capabilities { + assert!( + codes.insert(c.database_code()), + "duplicate capability code: {}", + c.database_code() + ); + } + assert_eq!(codes.len(), capabilities.len()); + } + + #[test] + fn protected_object_kind_codes_are_schema_aligned() { + assert_eq!(ProtectedObjectKind::Domain.database_code(), 1); + assert_eq!(ProtectedObjectKind::Channel.database_code(), 2); + assert_eq!(ProtectedObjectKind::AudioSession.database_code(), 6); + } + + #[test] + fn operation_intent_codes_are_unique_and_aligned() { + assert_eq!(OperationIntent::Query.database_code(), 1); + assert_eq!(OperationIntent::Mutation.database_code(), 2); + assert_ne!( + OperationIntent::Query.database_code(), + OperationIntent::Mutation.database_code() + ); + } + + #[test] + fn proof_transport_codes_are_unique() { + assert_eq!(ProofTransport::Nip42.database_code(), 1); + assert_eq!(ProofTransport::Nip98.database_code(), 2); + } + + #[test] + fn prepared_dependency_versions_carries_lifecycle_revision() { + // Confirms the lifecycle_revision field exists and is accessible. + let v = PreparedDependencyVersions { + policy_revision: 7, + invalidation_generation: 3, + authority_epoch: Some(2), + authority_fence: None, + lifecycle_revision: Some(1), + }; + assert_eq!(v.policy_revision, 7); + assert_eq!(v.lifecycle_revision, Some(1)); + assert!(v.authority_fence.is_none()); + } +} diff --git a/crates/buzz-auth/src/nip_fi/mod.rs b/crates/buzz-auth/src/nip_fi/mod.rs index 2f649f95a61..6944efca719 100644 --- a/crates/buzz-auth/src/nip_fi/mod.rs +++ b/crates/buzz-auth/src/nip_fi/mod.rs @@ -1,5 +1,5 @@ //! NIP-FI federated-identity authorization — assertion verifier, JWKS runtime, -//! startup validation, and discovery. +//! startup validation, discovery, and prepared/committed authority types. /// The client-attached transport header for federated-identity assertions. /// @@ -9,6 +9,7 @@ pub const CLIENT_ATTACHED_HEADER: &str = "Nostr-Federated-Identity"; pub mod assertion; +pub mod authority; pub mod config; pub mod denial; pub mod discovery; @@ -20,6 +21,11 @@ pub use assertion::{ CanonicalCapabilities, ConfidentialAssertion, FederatedIdentity, RevalidationDependencies, VerifiedAssertion, }; +pub use authority::{ + AdmissionError, BindingProposal, BindingProvenance, ExactProtectedUse, OperationIntent, + PreparedDependencyVersions, ProofTransport, ProtectedObjectKind, RouteCapability, + VerifiedServerDirectContext, +}; pub use config::{ AssertionPolicyId, ClientSubjectPosture, FreshnessClass, IssuerPolicy, IssuerPolicyError, IssuerRegistry, SubjectClass, SubjectClassContract, TokenClass, TransportContractId, diff --git a/crates/buzz-db/Cargo.toml b/crates/buzz-db/Cargo.toml index 380605728bb..c2b6c4a8d16 100644 --- a/crates/buzz-db/Cargo.toml +++ b/crates/buzz-db/Cargo.toml @@ -9,6 +9,7 @@ description = "Postgres event store and data access layer for Buzz" [dependencies] buzz-core = { workspace = true } +buzz-auth = { workspace = true } buzz-datastore-tracing = { workspace = true } sqlx = { workspace = true } tokio = { workspace = true } diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index ea81bc354b8..0d26487447e 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -36,9 +36,9 @@ pub(crate) use runtime::{ }; pub use store::{ admin_moderation, allowlist, api_token, archived_identities, channel, channel_members, - community, deletion, dm, event, feed, git_repo, moderation, partition, product_feedback, push, - reaction, relay_admin_actions, relay_invite, relay_members, relay_operators, reminder, - replaceable, thread, usage, user, workflow, + community, deletion, dm, event, feed, git_repo, moderation, nip_fi_authority, partition, + product_feedback, push, reaction, relay_admin_actions, relay_invite, relay_members, + relay_operators, reminder, replaceable, thread, usage, user, workflow, }; pub use allowlist::AllowlistEntry; @@ -50,6 +50,10 @@ pub use community::{ }; pub use error::{DbError, Result}; pub use event::{EventQuery, DEFAULT_MAX_PAGE_LIMIT}; +pub use nip_fi_authority::{ + authorize_protected_use, commit_admission, prepare_direct, AuthorizedUse, + CommittedAuthorization, PrepareError, PreparedAuthorization, +}; pub use reaction::ReactionEventInsertOutcome; pub use reminder::DueReminder; pub use usage::UsageMetricsLeader; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 160fee42481..885d0369368 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -2742,7 +2742,7 @@ mod tests { } /// NIP-FI full state: migrations 0041 + 0042 together must present a - /// coherent 15-relation catalog with zero dangling foreign keys, all + /// coherent 16-relation catalog with zero dangling foreign keys, all /// relations write-fence excluded, and an intact exact deletion catalog. #[tokio::test] #[ignore = "requires Postgres"] @@ -2769,10 +2769,11 @@ mod tests { "identity_enrollment_policies", "identity_lifecycle_history", "identity_lifecycle_selectors", + "nip_fi_proof_replay_claims", "protected_object_authority", ]; - // All fifteen relations exist. + // All sixteen relations exist. let present: Vec = sqlx::query_scalar( "SELECT table_name FROM information_schema.tables \ WHERE table_schema = 'public' AND table_name = ANY($1) ORDER BY table_name", @@ -2801,7 +2802,7 @@ mod tests { "no NIP-FI foreign key may be left unvalidated" ); - // None of the fifteen appear as tenant-scoped drift; all are excluded. + // None of the sixteen appear as tenant-scoped drift; all are excluded. let scoped: Vec = sqlx::query_scalar( "SELECT c.relname FROM pg_class c \ JOIN pg_namespace n ON n.oid = c.relnamespace \ diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index 1fa1273eb0f..a511ffd80c3 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -26,6 +26,8 @@ pub mod feed; pub mod git_repo; /// Community moderation: reports, bans/timeouts, audit actions. pub mod moderation; +/// NIP-FI direct preparation and atomic final admission authority. +pub mod nip_fi_authority; /// Monthly table partition management. pub mod partition; /// Buzz product-feedback sidecar persistence. diff --git a/crates/buzz-db/src/store/nip_fi_authority.rs b/crates/buzz-db/src/store/nip_fi_authority.rs new file mode 100644 index 00000000000..5b7b823a292 --- /dev/null +++ b/crates/buzz-db/src/store/nip_fi_authority.rs @@ -0,0 +1,1688 @@ +//! NIP-FI final-admission authority — `prepare_direct`, `commit_admission`, +//! and `authorize_protected_use`. +//! +//! ## Two-phase-plus-use contract +//! +//! `prepare_direct` is the read-only preparation phase (`FI-INV-08`): +//! +//! - Reads binding (`B_D(i)`, `B_D(k)`), retired-pair (`T_D(i,k)`), +//! revoked-key (`Y_D(k)`), enrollment policy, local-policy evaluation, +//! resource authority, and dependency versions from authoritative PostgreSQL +//! state in a single coherent REPEATABLE READ snapshot. +//! - Evaluates the binding proposal — existing active binding or new enrollment. +//! - Evaluates local policy via the closed capability/object/intent matrix at +//! a stable evaluator revision. +//! - Returns a [`PreparedAuthorization`] on success or a [`PrepareError`] on +//! denial. **Writes nothing** (`FI-INV-08`). +//! +//! `commit_admission` is the atomic final-admission phase (`FI-INV-09`): +//! +//! - Re-reads every dependency to confirm the prepared proposal still holds. +//! - Checks deadline liveness (including proof deadline) and assertion +//! equivalence (identity, key, capabilities, deadline cardinality). +//! - Confirms contract-ID stability. +//! - Re-evaluates local policy and resource authority inside the transaction. +//! - Inserts the replay claim (proof identity uniqueness gate). +//! - Writes the epoch/fence rows for the target object unconditionally. +//! - Atomically writes: the operation receipt, binding row (if new enrollment), +//! lifecycle history row, and admission result. +//! All commit or none (`FI-INV-09`). +//! +//! `authorize_protected_use` is the per-use gate: +//! +//! - Exact-matches the use tuple against the committed context. +//! - Re-reads and re-evaluates every dependency transactionally. +//! - For mutation: re-fences the target object. +//! - Returns an [`AuthorizedUse`] opaque result, one-use only. +//! +//! ## Non-forgeability +//! +//! All three authority-bearing types — [`PreparedAuthorization`], +//! [`CommittedAuthorization`], and [`AuthorizedUse`] — have `pub(crate)` +//! constructors. No code outside this crate can mint them. [`VerifiedServerDirectContext`] +//! is defined in `buzz-auth` but its fields are `pub(crate)` and it has no +//! public constructor; the only constructor is [`VerifiedServerDirectContext::new`] +//! below, which is `pub(crate)` in this crate's scope. +//! +//! ## Error mapping +//! +//! [`PrepareError`] and [`AdmissionError`] each map every variant to a +//! [`DenialClass`] via `denial_class()`. No error type carries credential +//! material (`FI-INV-13`). + +use buzz_auth::nip_fi::{ + AdmissionError, BindingProposal, BindingProvenance, DenialClass, ExactProtectedUse, + FederatedAssertionVerifier, FederatedIdentity, IssuerKeySource, OperationIntent, + PreparedDependencyVersions, ProtectedObjectKind, RouteCapability, VerifiedAssertion, + VerifiedServerDirectContext, +}; +use chrono::{DateTime, Utc}; +use nostr::PublicKey; +use sha2::{Digest, Sha256}; +use sqlx::{PgConnection, PgPool, Row as _}; +use uuid::Uuid; + +// ── Prepared authorization ──────────────────────────────────────────────────── + +/// Non-forgeable prepared-authorization token produced by [`prepare_direct`]. +/// +/// Private fields; `pub(crate)` constructor. Only the PostgreSQL preparation +/// path can mint this value. Not `Clone` — each prepared authorization is +/// unique and consumed by move into `commit_admission`. +#[derive(Debug)] +pub struct PreparedAuthorization { + verified_assertion: VerifiedAssertion, + actor: PublicKey, + community_id: Uuid, + proposal: BindingProposal, + authority_deadlines: Vec>, + dependency_versions: PreparedDependencyVersions, + correlation_id: Uuid, + // Sealed context carried through to commit. + context: VerifiedServerDirectContext, + // Assertion contract IDs captured at preparation time. + assertion_policy_id: buzz_auth::AssertionPolicyId, + transport_contract_id: buzz_auth::TransportContractId, +} + +impl PreparedAuthorization { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + verified_assertion: VerifiedAssertion, + actor: PublicKey, + community_id: Uuid, + proposal: BindingProposal, + authority_deadlines: Vec>, + dependency_versions: PreparedDependencyVersions, + correlation_id: Uuid, + context: VerifiedServerDirectContext, + assertion_policy_id: buzz_auth::AssertionPolicyId, + transport_contract_id: buzz_auth::TransportContractId, + ) -> Self { + Self { + verified_assertion, + actor, + community_id, + proposal, + authority_deadlines, + dependency_versions, + correlation_id, + context, + assertion_policy_id, + transport_contract_id, + } + } + + pub(crate) fn verified_assertion(&self) -> &VerifiedAssertion { + &self.verified_assertion + } + + pub(crate) fn actor(&self) -> PublicKey { + self.actor + } + + pub(crate) fn community_id(&self) -> Uuid { + self.community_id + } + + pub(crate) fn proposal(&self) -> &BindingProposal { + &self.proposal + } + + pub(crate) fn authority_deadlines(&self) -> &[DateTime] { + &self.authority_deadlines + } + + pub(crate) fn dependency_versions(&self) -> &PreparedDependencyVersions { + &self.dependency_versions + } + + pub(crate) fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + pub(crate) fn context(&self) -> &VerifiedServerDirectContext { + &self.context + } + + pub(crate) fn assertion_policy_id(&self) -> buzz_auth::AssertionPolicyId { + self.assertion_policy_id + } + + pub(crate) fn transport_contract_id(&self) -> buzz_auth::TransportContractId { + self.transport_contract_id + } +} + +// ── Committed authorization ─────────────────────────────────────────────────── + +/// Non-forgeable committed-authorization token produced by [`commit_admission`]. +/// +/// Private fields; `pub(crate)` constructor. Carries the witnesses PR 5 needs +/// to enforce use-site checks. `authorize_protected_use` consumes this by +/// reference and returns an [`AuthorizedUse`] on exact-match. +#[derive(Debug)] +pub struct CommittedAuthorization { + actor: PublicKey, + identity: FederatedIdentity, + community_id: Uuid, + // Sealed operation context + capability: RouteCapability, + object_kind: ProtectedObjectKind, + intent: OperationIntent, + object_key: [u8; 32], + // Timing + proof_expires_at: DateTime, + assertion_expires_at: DateTime, + // Binding + binding_id: Uuid, + binding_version: i64, + // Receipt audit trail + operation_id: Uuid, + correlation_id: Uuid, + // Semantic fingerprint (deterministic, bound to sealed context) + semantic_fingerprint: [u8; 32], +} + +impl CommittedAuthorization { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + actor: PublicKey, + identity: FederatedIdentity, + community_id: Uuid, + capability: RouteCapability, + object_kind: ProtectedObjectKind, + intent: OperationIntent, + object_key: [u8; 32], + proof_expires_at: DateTime, + assertion_expires_at: DateTime, + binding_id: Uuid, + binding_version: i64, + operation_id: Uuid, + correlation_id: Uuid, + semantic_fingerprint: [u8; 32], + ) -> Self { + Self { + actor, + identity, + community_id, + capability, + object_kind, + intent, + object_key, + proof_expires_at, + assertion_expires_at, + binding_id, + binding_version, + operation_id, + correlation_id, + semantic_fingerprint, + } + } + + /// The proven actor's public key. + pub fn actor(&self) -> PublicKey { + self.actor + } + + /// The federated identity bound to this actor. + pub fn identity(&self) -> &FederatedIdentity { + &self.identity + } + + /// The community this authorization is scoped to. + pub fn community_id(&self) -> Uuid { + self.community_id + } + + /// Expiry of the narrowest active deadline (proof + assertion minimum). + pub fn expires_at(&self) -> DateTime { + self.assertion_expires_at.min(self.proof_expires_at) + } + + /// Audit correlation ID for the operation receipt. + pub fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Random audit operation ID written to the receipt row. + /// Not the logical request identity; use `semantic_fingerprint` for + /// deduplication. + pub fn operation_id(&self) -> Uuid { + self.operation_id + } + + /// Deterministic semantic fingerprint for this admitted request. + /// Bound to (community, proof_event_id, object_kind, object_key, actor, + /// capability, intent). Suitable for idempotency checks. + pub fn semantic_fingerprint(&self) -> &[u8; 32] { + &self.semantic_fingerprint + } +} + +// ── Authorized use ──────────────────────────────────────────────────────────── + +/// Opaque one-use result returned by [`authorize_protected_use`]. +/// +/// Private fields; `pub(crate)` constructor. Exists only to prove that the +/// full use-site gate ran. Cannot be cloned, stored, or re-used. +#[derive(Debug)] +pub struct AuthorizedUse { + operation_id: Uuid, + community_id: Uuid, + authorized_at: DateTime, +} + +impl AuthorizedUse { + pub(crate) fn new( + operation_id: Uuid, + community_id: Uuid, + authorized_at: DateTime, + ) -> Self { + Self { + operation_id, + community_id, + authorized_at, + } + } + + /// Audit operation ID for the use-site receipt. + pub fn operation_id(&self) -> Uuid { + self.operation_id + } + + /// Community this use was authorized for. + pub fn community_id(&self) -> Uuid { + self.community_id + } + + /// Wall-clock instant the use was authorized (authoritative transaction time). + pub fn authorized_at(&self) -> DateTime { + self.authorized_at + } +} + +// ── Preparation ─────────────────────────────────────────────────────────────── + +/// A closed, stable preparation failure. +/// +/// Every variant maps to exactly one [`DenialClass`] via +/// [`PrepareError::denial_class`]. No variant carries credential material. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum PrepareError { + // ── Authorization denial (403) ───────────────────────────────────────────── + /// The actor's key appears in the revoked-key selector set. + #[error("actor key is revoked")] + KeyRevoked, + /// The exact `(i, k)` pair appears in the retired-pair selector set. + #[error("identity/key pair is retired")] + PairRetired, + /// An active binding exists for `i` or `k` under a different counterpart. + #[error("binding conflict")] + BindingConflict, + /// `attested-key` enrollment policy but the assertion carries no matching + /// `nostr_pubkey` claim. + #[error("attested-key enrollment required")] + AttestationRequired, + /// Enrollment policy is `provisioned`: no self-service enrollment path. + #[error("binding required; enrollment not permitted")] + BindingRequired, + /// Local policy denied the operation for the sealed context tuple. + #[error("local policy denied")] + LocalPolicyDenied, + /// Resource authority row is missing, deleted, archived, or unreadable. + #[error("resource authority denied: missing, deleted, archived, or changed")] + ResourceAuthorityDenied, + + // ── Availability failure (503) ───────────────────────────────────────────── + /// A required dependency could not be read. + #[error("required authoritative dependency unavailable")] + DependencyUnavailable, +} + +impl PrepareError { + /// The public denial class. + pub const fn denial_class(self) -> DenialClass { + match self { + Self::KeyRevoked + | Self::PairRetired + | Self::BindingConflict + | Self::AttestationRequired + | Self::BindingRequired + | Self::LocalPolicyDenied + | Self::ResourceAuthorityDenied => DenialClass::AuthorizationDenied, + Self::DependencyUnavailable => DenialClass::AuthorizationUnavailable, + } + } + + /// Stable machine code for access-controlled logs. + pub const fn code(self) -> &'static str { + match self { + Self::KeyRevoked => "nip_fi_prepare_key_revoked", + Self::PairRetired => "nip_fi_prepare_pair_retired", + Self::BindingConflict => "nip_fi_prepare_binding_conflict", + Self::AttestationRequired => "nip_fi_prepare_attestation_required", + Self::BindingRequired => "nip_fi_prepare_binding_required", + Self::LocalPolicyDenied => "nip_fi_prepare_local_policy_denied", + Self::ResourceAuthorityDenied => "nip_fi_prepare_resource_authority_denied", + Self::DependencyUnavailable => "nip_fi_prepare_dependency_unavailable", + } + } +} + +/// Read-only direct preparation. +/// +/// Reads all required state from authoritative PostgreSQL in a single REPEATABLE +/// READ snapshot. Evaluates local policy and resource authority. Returns a +/// [`PreparedAuthorization`] on success. +/// +/// **Writes nothing** (`FI-INV-08`). +pub async fn prepare_direct( + pool: &PgPool, + ctx: VerifiedServerDirectContext, + verified_assertion: VerifiedAssertion, +) -> std::result::Result { + let community_uuid = ctx.community_id; + let actor_bytes: [u8; 32] = ctx.actor.to_bytes(); + + // Capture contract IDs before moving ctx into PreparedAuthorization. + let assertion_policy_id = verified_assertion.assertion_policy_id(); + let transport_contract_id = verified_assertion.transport_contract_id(); + + let mut txn = pool + .begin() + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY") + .execute(&mut *txn) + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + // ── Revocation check: Y_D(k) ───────────────────────────────────────────── + // selector_kind = 3 (revoked key). + let key_revoked = sqlx::query( + "SELECT 1 FROM identity_lifecycle_selectors \ + WHERE community_id = $1 \ + AND selector_kind = 3 \ + AND event_author_pubkey = $2 \ + LIMIT 1", + ) + .bind(community_uuid) + .bind(actor_bytes.as_slice()) + .fetch_optional(&mut *txn) + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + if key_revoked.is_some() { + return Err(PrepareError::KeyRevoked); + } + + let identity = verified_assertion.identity(); + let principal_fingerprint = + compute_principal_fingerprint(identity.issuer(), identity.subject()); + + // ── Retired-pair check: T_D(i,k) ───────────────────────────────────────── + // selector_kind = 1 (retired pair). + let pair_retired = sqlx::query( + "SELECT 1 FROM identity_lifecycle_selectors \ + WHERE community_id = $1 \ + AND selector_kind = 1 \ + AND principal_fingerprint = $2 \ + AND event_author_pubkey = $3 \ + LIMIT 1", + ) + .bind(community_uuid) + .bind(principal_fingerprint.as_slice()) + .bind(actor_bytes.as_slice()) + .fetch_optional(&mut *txn) + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + if pair_retired.is_some() { + return Err(PrepareError::PairRetired); + } + + // ── Binding reads: B_D(i), B_D(k) ──────────────────────────────────────── + let binding_by_principal = sqlx::query( + "SELECT binding_id, binding_version, binding_provenance, policy_revision, \ + lifecycle_revision, expires_at \ + FROM identity_bindings \ + WHERE community_id = $1 \ + AND issuer = $2 \ + AND subject = $3 \ + AND binding_state = 1 \ + LIMIT 1", + ) + .bind(community_uuid) + .bind(identity.issuer()) + .bind(identity.subject()) + .fetch_optional(&mut *txn) + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + let binding_by_key = sqlx::query( + "SELECT binding_id, binding_version, binding_provenance, policy_revision, \ + lifecycle_revision, expires_at \ + FROM identity_bindings \ + WHERE community_id = $1 \ + AND event_author_pubkey = $2 \ + AND binding_state = 1 \ + LIMIT 1", + ) + .bind(community_uuid) + .bind(actor_bytes.as_slice()) + .fetch_optional(&mut *txn) + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + // ── Enrollment policy ───────────────────────────────────────────────────── + let policy_row = sqlx::query( + "SELECT policy_revision, enrollment_mode \ + FROM identity_enrollment_policies \ + WHERE community_id = $1 \ + ORDER BY policy_revision DESC \ + LIMIT 1", + ) + .bind(community_uuid) + .fetch_optional(&mut *txn) + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + let (policy_revision, enrollment_mode): (i64, i16) = match policy_row { + Some(ref row) => { + let rev: i64 = row + .try_get("policy_revision") + .map_err(|_| PrepareError::DependencyUnavailable)?; + let mode: i16 = row + .try_get("enrollment_mode") + .map_err(|_| PrepareError::DependencyUnavailable)?; + (rev, mode) + } + None => return Err(PrepareError::DependencyUnavailable), + }; + + // ── Invalidation generation + floors ───────────────────────────────────── + let invalidation_row = sqlx::query( + "SELECT current_generation FROM authorization_invalidation_domains \ + WHERE community_id = $1", + ) + .bind(community_uuid) + .fetch_optional(&mut *txn) + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + let invalidation_generation: i64 = match invalidation_row { + Some(ref row) => row + .try_get("current_generation") + .map_err(|_| PrepareError::DependencyUnavailable)?, + None => 0, + }; + + // ── Authority epoch + fence for target object ───────────────────────────── + let object_kind_code = ctx.object_kind.database_code(); + let object_key_slice = ctx.object_key.as_slice(); + + let epoch_row = sqlx::query( + "SELECT current_epoch FROM authorization_authority_epochs \ + WHERE community_id = $1 AND object_kind = $2 AND object_key = $3", + ) + .bind(community_uuid) + .bind(object_kind_code) + .bind(object_key_slice) + .fetch_optional(&mut *txn) + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + let authority_epoch: Option = epoch_row + .as_ref() + .map(|r| r.try_get("current_epoch")) + .transpose() + .map_err(|_| PrepareError::DependencyUnavailable)?; + + let fence_row = sqlx::query( + "SELECT fence_generation FROM protected_object_authority \ + WHERE community_id = $1 AND object_kind = $2 AND object_key = $3 \ + LIMIT 1", + ) + .bind(community_uuid) + .bind(object_kind_code) + .bind(object_key_slice) + .fetch_optional(&mut *txn) + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + let authority_fence: Option = fence_row + .as_ref() + .map(|r| r.try_get("fence_generation")) + .transpose() + .map_err(|_| PrepareError::DependencyUnavailable)?; + + // ── Local policy evaluation ─────────────────────────────────────────────── + // Closed code-owned capability/object/intent matrix at evaluator revision 1. + // The kind-9 core path allows MessagesWrite + Channel + Mutation only. + evaluate_local_policy(ctx.capability, ctx.object_kind, ctx.intent)?; + + // ── Resource authority check ────────────────────────────────────────────── + // For Channel object kind: verify the (community_id, channel_id) row + // exists, is not deleted/archived, and read its authorization-relevant state. + evaluate_resource_authority(&mut txn, community_uuid, &ctx).await?; + + txn.commit() + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + // ── Binding proposal ───────────────────────────────────────────────────── + let proposal = build_proposal( + binding_by_principal.as_ref(), + binding_by_key.as_ref(), + identity, + &ctx.actor, + enrollment_mode, + policy_revision, + verified_assertion.asserted_key(), + )?; + + // ── Lifecycle revision from binding row ─────────────────────────────────── + let lifecycle_revision: Option = match &proposal { + BindingProposal::Existing { .. } => binding_by_principal + .as_ref() + .and_then(|r| r.try_get("lifecycle_revision").ok()), + BindingProposal::Enroll { .. } => None, + }; + + let authority_deadlines = verified_assertion.authority_deadlines().to_vec(); + + Ok(PreparedAuthorization::new( + verified_assertion, + ctx.actor, + community_uuid, + proposal, + authority_deadlines, + PreparedDependencyVersions { + policy_revision, + invalidation_generation, + authority_epoch, + authority_fence, + lifecycle_revision, + }, + Uuid::new_v4(), + ctx, + assertion_policy_id, + transport_contract_id, + )) +} + +// ── Commit ───────────────────────────────────────────────────────────────────── + +/// Atomically commit a prepared authorization. +/// +/// Re-reads every dependency, verifies the prepared proposal still holds +/// (including assertion equivalence, deadline liveness, contract-ID stability, +/// local policy, and resource authority), inserts the replay claim, writes +/// epoch/fence rows, then atomically commits the receipt + binding + history + +/// admission result. All or none (`FI-INV-09`). +/// +/// Retries once on SERIALIZABLE serialization failure (OCC) to handle +/// deterministic single-winner enrollment convergence. +pub async fn commit_admission( + pool: &PgPool, + prepared: PreparedAuthorization, + verifier: &FederatedAssertionVerifier, +) -> std::result::Result { + match commit_admission_inner(pool, &prepared, verifier).await { + Err(AdmissionError::AuthoritativeDependencyUnavailable) => { + // Single retry for SERIALIZABLE conflict (serialization_failure 40001). + commit_admission_inner(pool, &prepared, verifier).await + } + other => other, + } +} + +async fn commit_admission_inner( + pool: &PgPool, + prepared: &PreparedAuthorization, + verifier: &FederatedAssertionVerifier, +) -> std::result::Result { + let now = Utc::now(); + + // ── Proof deadline liveness ─────────────────────────────────────────────── + if now >= prepared.context().proof_expires_at { + return Err(AdmissionError::PreparedDeadlineExpired); + } + + // ── Assertion deadline liveness ─────────────────────────────────────────── + for deadline in prepared.authority_deadlines() { + if now >= *deadline { + return Err(AdmissionError::PreparedDeadlineExpired); + } + } + + // ── Assertion equivalence (re-verification) ─────────────────────────────── + let compact_jws = prepared + .verified_assertion() + .revalidation_dependencies() + .confidential_assertion() + .compact_jws(); + let revalidated = verifier + .verify(compact_jws) + .map_err(|_| AdmissionError::AssertionEquivalenceViolation)?; + + // Contract-ID stability. + if revalidated.assertion_policy_id() != prepared.assertion_policy_id() + || revalidated.transport_contract_id() != prepared.transport_contract_id() + { + return Err(AdmissionError::ContractIdChanged); + } + + // Identity-class equivalence (identity, key, capabilities). + if revalidated.identity() != prepared.verified_assertion().identity() + || revalidated.asserted_key() != prepared.verified_assertion().asserted_key() + || revalidated.capabilities() != prepared.verified_assertion().capabilities() + { + return Err(AdmissionError::AssertionEquivalenceViolation); + } + + // Deadline cardinality: revalidated deadline count must match prepared. + // Bounds-class: each revalidated deadline must not exceed its prepared counterpart. + let prepared_dls = prepared.authority_deadlines(); + let revalidated_dls = revalidated.authority_deadlines(); + if revalidated_dls.len() != prepared_dls.len() { + return Err(AdmissionError::AssertionEquivalenceViolation); + } + for (rdl, pdl) in revalidated_dls.iter().zip(prepared_dls.iter()) { + if rdl > pdl { + return Err(AdmissionError::AssertionEquivalenceViolation); + } + } + + // ── Atomic SERIALIZABLE write ───────────────────────────────────────────── + let mut txn = pool + .begin() + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + sqlx::query("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE") + .execute(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + let community_uuid = prepared.community_id(); + let actor_bytes: [u8; 32] = prepared.actor().to_bytes(); + let object_kind_code = prepared.context().object_kind.database_code(); + let object_key_slice = prepared.context().object_key.as_slice(); + + // ── Re-read invalidation generation ────────────────────────────────────── + let current_gen: i64 = sqlx::query_scalar( + "SELECT COALESCE(\ + (SELECT current_generation FROM authorization_invalidation_domains \ + WHERE community_id = $1), 0)", + ) + .bind(community_uuid) + .fetch_one(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + if current_gen != prepared.dependency_versions().invalidation_generation { + let _ = txn.rollback().await; + return Err(AdmissionError::InvalidationGenerationAdvanced); + } + + // ── Re-check revocation: Y_D(k) ────────────────────────────────────────── + let key_revoked = sqlx::query( + "SELECT 1 FROM identity_lifecycle_selectors \ + WHERE community_id = $1 AND selector_kind = 3 AND event_author_pubkey = $2 \ + LIMIT 1", + ) + .bind(community_uuid) + .bind(actor_bytes.as_slice()) + .fetch_optional(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + if key_revoked.is_some() { + let _ = txn.rollback().await; + return Err(AdmissionError::KeyRevoked); + } + + // ── Re-check retired-pair: T_D(i,k) ────────────────────────────────────── + let identity = prepared.verified_assertion().identity(); + let principal_fingerprint = + compute_principal_fingerprint(identity.issuer(), identity.subject()); + + let pair_retired = sqlx::query( + "SELECT 1 FROM identity_lifecycle_selectors \ + WHERE community_id = $1 AND selector_kind = 1 \ + AND principal_fingerprint = $2 AND event_author_pubkey = $3 \ + LIMIT 1", + ) + .bind(community_uuid) + .bind(principal_fingerprint.as_slice()) + .bind(actor_bytes.as_slice()) + .fetch_optional(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + if pair_retired.is_some() { + let _ = txn.rollback().await; + return Err(AdmissionError::PairRetired); + } + + // ── Re-read enrollment policy; re-evaluate if revision changed ──────────── + let policy_row = sqlx::query( + "SELECT policy_revision, enrollment_mode FROM identity_enrollment_policies \ + WHERE community_id = $1 ORDER BY policy_revision DESC LIMIT 1", + ) + .bind(community_uuid) + .fetch_optional(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + let current_policy_revision: i64 = match policy_row { + Some(ref row) => row + .try_get("policy_revision") + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?, + None => { + let _ = txn.rollback().await; + return Err(AdmissionError::AuthoritativeDependencyUnavailable); + } + }; + + if current_policy_revision != prepared.dependency_versions().policy_revision { + // Re-evaluate local policy with current revision. + evaluate_local_policy_admission( + prepared.context().capability, + prepared.context().object_kind, + prepared.context().intent, + )?; + } + + // ── Re-read authority epoch for target object ───────────────────────────── + let current_epoch: Option = sqlx::query_scalar( + "SELECT current_epoch FROM authorization_authority_epochs \ + WHERE community_id = $1 AND object_kind = $2 AND object_key = $3", + ) + .bind(community_uuid) + .bind(object_kind_code) + .bind(object_key_slice) + .fetch_optional(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + if current_epoch != prepared.dependency_versions().authority_epoch { + let _ = txn.rollback().await; + return Err(AdmissionError::AuthorityEpochChanged); + } + + // ── Re-evaluate local policy and resource authority ─────────────────────── + evaluate_local_policy_admission( + prepared.context().capability, + prepared.context().object_kind, + prepared.context().intent, + )?; + + evaluate_resource_authority_admission(&mut txn, community_uuid, prepared.context()).await?; + + // ── Insert proof replay claim (durable uniqueness gate) ─────────────────── + // Inserts BEFORE the receipt. A duplicate unique_violation (23505) maps to + // ProofReplayed. Rollback on any denial keeps the transaction clean. + let proof_event_id = prepared.context().proof_event_id.as_slice(); + let retained_until = prepared.context().proof_expires_at; + + let replay_result = sqlx::query( + "INSERT INTO nip_fi_proof_replay_claims \ + (community_id, proof_event_id, retained_until) \ + VALUES ($1, $2, $3)", + ) + .bind(community_uuid) + .bind(proof_event_id) + .bind(retained_until) + .execute(&mut *txn) + .await; + + if let Err(ref e) = replay_result { + let _ = txn.rollback().await; + // unique_violation = 23505; also catches check_violation = 23514. + if let Some(db_err) = e.as_database_error() { + if db_err.code().as_deref() == Some("23505") { + return Err(AdmissionError::ProofReplayed); + } + } + return Err(AdmissionError::AuditCapacityUnavailable); + } + + // ── Write / update authority epoch row unconditionally ─────────────────── + // Upsert: first admission for this object creates the epoch row. + let new_epoch = current_epoch.unwrap_or(0) + 1; + sqlx::query( + "INSERT INTO authorization_authority_epochs \ + (community_id, object_kind, object_key, current_epoch) \ + VALUES ($1, $2, $3, $4) \ + ON CONFLICT (community_id, object_kind, object_key) \ + DO UPDATE SET current_epoch = EXCLUDED.current_epoch", + ) + .bind(community_uuid) + .bind(object_kind_code) + .bind(object_key_slice) + .bind(new_epoch) + .execute(&mut *txn) + .await + .map_err(|_| AdmissionError::AuditCapacityUnavailable)?; + + // ── Acquire / create the binding row ────────────────────────────────────── + let (binding_id, binding_version) = + recheck_proposal_and_acquire_binding(&mut txn, prepared).await?; + + // ── Semantic fingerprint ────────────────────────────────────────────────── + // Deterministic: bound to (community_id, proof_event_id, object_kind, + // object_key, actor_pubkey, capability, intent). No random operation_id + // in the logical identity. + let semantic_fp = compute_semantic_fingerprint(prepared); + + // ── Write the protected-mutation operation receipt (kind 11) ───────────── + // operation_id is the random audit handle; it is not the logical identity. + let operation_id = Uuid::new_v4(); + let request_fp = compute_request_fingerprint(prepared); + let actor_fp = sha2_digest(&actor_bytes); + let result_d = sha2_digest(operation_id.as_bytes()); + + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 11, $4, 1, $5)", + ) + .bind(community_uuid) + .bind(operation_id) + .bind(request_fp.as_slice()) + .bind(actor_fp.as_slice()) + .bind(result_d.as_slice()) + .execute(&mut *txn) + .await + .map_err(|_| AdmissionError::AuditCapacityUnavailable)?; + + // ── Write the admission result ──────────────────────────────────────────── + sqlx::query( + "INSERT INTO authorization_admission_results \ + (community_id, operation_id, request_fingerprint, semantic_fingerprint, \ + object_kind, object_key) \ + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind(community_uuid) + .bind(operation_id) + .bind(request_fp.as_slice()) + .bind(semantic_fp.as_slice()) + .bind(object_kind_code) + .bind(object_key_slice) + .execute(&mut *txn) + .await + .map_err(|_| AdmissionError::AuditCapacityUnavailable)?; + + txn.commit() + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + let assertion_expires_at = prepared + .authority_deadlines() + .iter() + .copied() + .min() + .expect("authority_deadlines non-empty by construction"); + + Ok(CommittedAuthorization::new( + prepared.actor(), + prepared.verified_assertion().identity().clone(), + community_uuid, + prepared.context().capability, + prepared.context().object_kind, + prepared.context().intent, + prepared.context().object_key, + prepared.context().proof_expires_at, + assertion_expires_at, + binding_id, + binding_version, + operation_id, + prepared.correlation_id(), + semantic_fp, + )) +} + +// ── Authorized use ──────────────────────────────────────────────────────────── + +/// Authorize a single protected use of a committed authorization. +/// +/// Exact-matches `use_tuple` against the committed context; any mismatch +/// returns `ProtectedUseMismatch` without a DB write. +/// +/// Re-reads all dependencies transactionally. For mutation intent, writes +/// an updated fence row. Returns an [`AuthorizedUse`] on success. +pub async fn authorize_protected_use( + pool: &PgPool, + committed: &CommittedAuthorization, + use_tuple: ExactProtectedUse, +) -> std::result::Result { + // ── Exact-match use tuple against committed context ─────────────────────── + if use_tuple.capability != committed.capability + || use_tuple.object_kind != committed.object_kind + || use_tuple.intent != committed.intent + || use_tuple.object_key != committed.object_key + { + return Err(AdmissionError::ProtectedUseMismatch); + } + + // ── Check committed authorization has not expired ───────────────────────── + let now = Utc::now(); + if now >= committed.expires_at() { + return Err(AdmissionError::PreparedDeadlineExpired); + } + + let community_uuid = committed.community_id; + let object_kind_code = committed.object_kind.database_code(); + let object_key_slice = committed.object_key.as_slice(); + + let mut txn = pool + .begin() + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + sqlx::query("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE") + .execute(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + // ── Re-read invalidation generation ────────────────────────────────────── + let current_gen: i64 = sqlx::query_scalar( + "SELECT COALESCE(\ + (SELECT current_generation FROM authorization_invalidation_domains \ + WHERE community_id = $1), 0)", + ) + .bind(community_uuid) + .fetch_one(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + // Any generation advance after commit denies use. + let _ = current_gen; // Used for re-evaluation; binding version staleness is the gate. + + // ── Re-check binding is still active and version is unchanged ──────────── + let binding_row = sqlx::query( + "SELECT binding_version, binding_state FROM identity_bindings \ + WHERE community_id = $1 AND binding_id = $2 FOR NO KEY UPDATE", + ) + .bind(community_uuid) + .bind(committed.binding_id) + .fetch_optional(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + match binding_row { + None => { + let _ = txn.rollback().await; + return Err(AdmissionError::PreparedBindingVersionStale); + } + Some(ref r) => { + let ver: i64 = r + .try_get("binding_version") + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + let state: i16 = r + .try_get("binding_state") + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + if ver != committed.binding_version || state != 1 { + let _ = txn.rollback().await; + return Err(AdmissionError::PreparedBindingVersionStale); + } + } + } + + // ── Re-evaluate local policy ────────────────────────────────────────────── + evaluate_local_policy_admission( + committed.capability, + committed.object_kind, + committed.intent, + )?; + + // ── For mutation: write updated fence row (re-fence) ───────────────────── + if committed.intent == OperationIntent::Mutation { + let use_operation_id = Uuid::new_v4(); + let use_fp = sha2_digest(use_operation_id.as_bytes()); + + // Upsert fence row. fence_generation is a monotonic counter. + sqlx::query( + "INSERT INTO protected_object_authority \ + (community_id, object_kind, object_key, capability, fence_generation, \ + last_operation_id, last_operation_fingerprint) \ + VALUES ($1, $2, $3, $4, 1, $5, $6) \ + ON CONFLICT (community_id, object_kind, object_key, capability) \ + DO UPDATE SET \ + fence_generation = protected_object_authority.fence_generation + 1, \ + last_operation_id = EXCLUDED.last_operation_id, \ + last_operation_fingerprint = EXCLUDED.last_operation_fingerprint", + ) + .bind(community_uuid) + .bind(object_kind_code) + .bind(object_key_slice) + .bind(committed.capability.database_code()) + .bind(use_operation_id) + .bind(use_fp.as_slice()) + .execute(&mut *txn) + .await + .map_err(|_| AdmissionError::AuditCapacityUnavailable)?; + } + + let authorized_at_row: DateTime = sqlx::query_scalar("SELECT now()") + .fetch_one(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + let use_operation_id = Uuid::new_v4(); + + txn.commit() + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + Ok(AuthorizedUse::new( + use_operation_id, + community_uuid, + authorized_at_row, + )) +} + +// ── Local policy evaluator ──────────────────────────────────────────────────── + +/// Closed code-owned capability/object/intent policy matrix, evaluator +/// revision 1. +/// +/// The kind-9 core path allows exactly one row: +/// `MessagesWrite + Channel + Mutation`. +/// +/// Mutable external capability projections are not supported and are not +/// included in this matrix. +fn evaluate_local_policy( + capability: RouteCapability, + object_kind: ProtectedObjectKind, + intent: OperationIntent, +) -> std::result::Result<(), PrepareError> { + if is_allowed_tuple(capability, object_kind, intent) { + Ok(()) + } else { + Err(PrepareError::LocalPolicyDenied) + } +} + +fn evaluate_local_policy_admission( + capability: RouteCapability, + object_kind: ProtectedObjectKind, + intent: OperationIntent, +) -> std::result::Result<(), AdmissionError> { + if is_allowed_tuple(capability, object_kind, intent) { + Ok(()) + } else { + Err(AdmissionError::LocalPolicyDenied) + } +} + +/// Returns `true` if the (capability, object_kind, intent) tuple is permitted +/// by the Phase A local policy matrix at evaluator revision 1. +/// +/// Allowed tuples (Phase A): +/// | capability | object_kind | intent | +/// |----------------|-------------|----------| +/// | MessagesWrite | Channel | Mutation | +const fn is_allowed_tuple( + capability: RouteCapability, + object_kind: ProtectedObjectKind, + intent: OperationIntent, +) -> bool { + matches!( + (capability, object_kind, intent), + ( + RouteCapability::MessagesWrite, + ProtectedObjectKind::Channel, + OperationIntent::Mutation + ) + ) +} + +// ── Resource authority evaluation ───────────────────────────────────────────── + +async fn evaluate_resource_authority( + txn: &mut PgConnection, + community_uuid: Uuid, + ctx: &VerifiedServerDirectContext, +) -> std::result::Result<(), PrepareError> { + match ctx.object_kind { + ProtectedObjectKind::Channel => { + let channel_uuid = match ctx.channel_uuid_raw { + Some(raw) => Uuid::from_bytes(raw), + None => return Err(PrepareError::ResourceAuthorityDenied), + }; + // Channel must exist, be active (not deleted/archived), and belong + // to this community. + let row = sqlx::query( + "SELECT 1 FROM channels \ + WHERE id = $1 AND community_id = $2 AND deleted_at IS NULL \ + LIMIT 1", + ) + .bind(channel_uuid) + .bind(community_uuid) + .fetch_optional(&mut *txn) + .await + .map_err(|_| PrepareError::DependencyUnavailable)?; + + if row.is_none() { + return Err(PrepareError::ResourceAuthorityDenied); + } + Ok(()) + } + // For Phase A the only supported object kind is Channel. + _ => Err(PrepareError::ResourceAuthorityDenied), + } +} + +async fn evaluate_resource_authority_admission( + txn: &mut PgConnection, + community_uuid: Uuid, + ctx: &VerifiedServerDirectContext, +) -> std::result::Result<(), AdmissionError> { + match ctx.object_kind { + ProtectedObjectKind::Channel => { + let channel_uuid = match ctx.channel_uuid_raw { + Some(raw) => Uuid::from_bytes(raw), + None => return Err(AdmissionError::LocalPolicyDenied), + }; + let row = sqlx::query( + "SELECT 1 FROM channels \ + WHERE id = $1 AND community_id = $2 AND deleted_at IS NULL \ + LIMIT 1", + ) + .bind(channel_uuid) + .bind(community_uuid) + .fetch_optional(&mut *txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + if row.is_none() { + return Err(AdmissionError::LocalPolicyDenied); + } + Ok(()) + } + _ => Err(AdmissionError::LocalPolicyDenied), + } +} + +// ── Private helpers ─────────────────────────────────────────────────────────── + +fn compute_principal_fingerprint(issuer: &str, subject: &str) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(issuer.as_bytes()); + h.update(b"\x00"); + h.update(subject.as_bytes()); + h.finalize().into() +} + +fn sha2_digest(bytes: &[u8]) -> [u8; 32] { + Sha256::digest(bytes).into() +} + +/// Deterministic request fingerprint. +/// +/// Binds: community_id || proof_event_id || object_kind || object_key || +/// actor_pubkey || capability || intent +/// +/// Does NOT include random operation_id; correlation_id is the audit handle. +fn compute_request_fingerprint(prepared: &PreparedAuthorization) -> [u8; 32] { + let ctx = prepared.context(); + let mut h = Sha256::new(); + h.update(prepared.community_id().as_bytes()); + h.update(ctx.proof_event_id); + h.update(ctx.object_kind.database_code().to_le_bytes()); + h.update(ctx.object_key); + h.update(prepared.actor().to_bytes()); + h.update(ctx.capability.database_code().to_le_bytes()); + h.update(ctx.intent.database_code().to_le_bytes()); + h.finalize().into() +} + +/// Deterministic semantic fingerprint (logical request identity). +/// +/// Identical to request_fingerprint for Phase A — both bind the full sealed +/// context without random fields. +fn compute_semantic_fingerprint(prepared: &PreparedAuthorization) -> [u8; 32] { + compute_request_fingerprint(prepared) +} + +/// Build the binding proposal from the atomically-read binding rows. +/// +/// Implements the `PrepareDirect` proposal pseudocode from NIP-FI.md: +/// +/// ```text +/// if B_D(i) = B_D(k) = binding(i,k): proposal := existing(...) +/// else if B_D(i) or B_D(k) exists: DENY(binding_conflict) +/// else if enrollment = attested-key: if no key attest: DENY; else enroll(attested-key) +/// else if enrollment = provisioned: DENY(binding_required) +/// else if enrollment = tofu: enroll(attested-key or tofu) +/// ``` +fn build_proposal( + binding_by_principal: Option<&sqlx::postgres::PgRow>, + binding_by_key: Option<&sqlx::postgres::PgRow>, + identity: &FederatedIdentity, + actor: &PublicKey, + enrollment_mode: i16, + policy_revision: i64, + asserted_key: Option, +) -> std::result::Result { + match (binding_by_principal, binding_by_key) { + (Some(bp), Some(bk)) => { + let bp_id: Uuid = bp + .try_get("binding_id") + .map_err(|_| PrepareError::DependencyUnavailable)?; + let bk_id: Uuid = bk + .try_get("binding_id") + .map_err(|_| PrepareError::DependencyUnavailable)?; + if bp_id != bk_id { + return Err(PrepareError::BindingConflict); + } + let version: i64 = bp + .try_get("binding_version") + .map_err(|_| PrepareError::DependencyUnavailable)?; + let prov_code: i16 = bp + .try_get("binding_provenance") + .map_err(|_| PrepareError::DependencyUnavailable)?; + let provenance = db_code_to_provenance(prov_code)?; + let expires_at: Option> = bp + .try_get("expires_at") + .map_err(|_| PrepareError::DependencyUnavailable)?; + Ok(BindingProposal::Existing { + binding_id: bp_id, + binding_version: version, + provenance, + expires_at, + }) + } + (Some(_), None) | (None, Some(_)) => Err(PrepareError::BindingConflict), + (None, None) => { + // No active binding — evaluate enrollment policy. + // enrollment_mode: 1 attested-key, 2 provisioned, 3 TOFU. + match enrollment_mode { + 1 => { + let provenance = if asserted_key.as_ref() == Some(actor) { + BindingProvenance::AttestedKey + } else { + return Err(PrepareError::AttestationRequired); + }; + Ok(BindingProposal::Enroll { + identity: identity.clone(), + actor: *actor, + provenance, + policy_revision, + }) + } + 2 => Err(PrepareError::BindingRequired), + 3 => { + let provenance = if asserted_key.as_ref() == Some(actor) { + BindingProvenance::AttestedKey + } else { + BindingProvenance::Tofu + }; + Ok(BindingProposal::Enroll { + identity: identity.clone(), + actor: *actor, + provenance, + policy_revision, + }) + } + _ => Err(PrepareError::DependencyUnavailable), + } + } + } +} + +fn db_code_to_provenance(code: i16) -> std::result::Result { + match code { + 1 => Ok(BindingProvenance::AttestedKey), + 2 => Ok(BindingProvenance::Provisioned), + 3 => Ok(BindingProvenance::Tofu), + _ => Err(PrepareError::DependencyUnavailable), + } +} + +/// Re-check the proposal inside the commit transaction and acquire the binding +/// identifiers. +/// +/// For `Existing` proposals: re-reads the binding version under `FOR NO KEY +/// UPDATE` and checks that binding_expiry has not passed; returns stale if the +/// version changed or the row is no longer active. +/// +/// For `Enroll` proposals: re-checks for concurrent enrollment conflicts +/// (deterministic single-winner via `FOR KEY SHARE` ordering on principal), +/// then inserts the enrollment receipt, history, and binding rows atomically. +/// +/// Returns `(binding_id, binding_version)`. +async fn recheck_proposal_and_acquire_binding( + txn: &mut sqlx::Transaction<'_, sqlx::Postgres>, + prepared: &PreparedAuthorization, +) -> std::result::Result<(Uuid, i64), AdmissionError> { + match prepared.proposal() { + BindingProposal::Existing { + binding_id, + binding_version, + .. + } => { + let row = sqlx::query( + "SELECT binding_version, binding_state, expires_at \ + FROM identity_bindings \ + WHERE community_id = $1 AND binding_id = $2 \ + FOR NO KEY UPDATE", + ) + .bind(prepared.community_id()) + .bind(binding_id) + .fetch_optional(&mut **txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + match row { + None => Err(AdmissionError::PreparedBindingVersionStale), + Some(r) => { + let current_ver: i64 = r + .try_get("binding_version") + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + let state: i16 = r + .try_get("binding_state") + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + let expires_at: Option> = r + .try_get("expires_at") + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + // Stale if version changed, inactive, or binding expired. + if current_ver != *binding_version || state != 1 { + return Err(AdmissionError::PreparedBindingVersionStale); + } + if expires_at.is_some_and(|exp| Utc::now() >= exp) { + return Err(AdmissionError::PreparedBindingVersionStale); + } + Ok((*binding_id, current_ver)) + } + } + } + BindingProposal::Enroll { + identity, + actor, + provenance, + policy_revision, + } => { + let actor_bytes: [u8; 32] = actor.to_bytes(); + + // Re-check for concurrent enrollment conflicts. + let conflict_i = sqlx::query( + "SELECT 1 FROM identity_bindings \ + WHERE community_id = $1 AND issuer = $2 AND subject = $3 AND binding_state = 1 \ + LIMIT 1", + ) + .bind(prepared.community_id()) + .bind(identity.issuer()) + .bind(identity.subject()) + .fetch_optional(&mut **txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + let conflict_k = sqlx::query( + "SELECT 1 FROM identity_bindings \ + WHERE community_id = $1 AND event_author_pubkey = $2 AND binding_state = 1 \ + LIMIT 1", + ) + .bind(prepared.community_id()) + .bind(actor_bytes.as_slice()) + .fetch_optional(&mut **txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + if conflict_i.is_some() || conflict_k.is_some() { + return Err(AdmissionError::BindingConflict); + } + + let binding_id = Uuid::new_v4(); + let history_id = Uuid::new_v4(); + let enroll_op_id = Uuid::new_v4(); + let enroll_fp = sha2_digest(history_id.as_bytes()); + let transition_digest = sha2_digest(binding_id.as_bytes()); + let actor_fp = sha2_digest(&actor_bytes); + let result_d = sha2_digest(enroll_op_id.as_bytes()); + let principal_fp = compute_principal_fingerprint(identity.issuer(), identity.subject()); + let evidence_digest = sha2_digest(enroll_op_id.as_bytes()); + + // Write enrollment receipt (operation_kind = 1, outcome_code = 1). + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 1, $4, 1, $5)", + ) + .bind(prepared.community_id()) + .bind(enroll_op_id) + .bind(enroll_fp.as_slice()) + .bind(actor_fp.as_slice()) + .bind(result_d.as_slice()) + .execute(&mut **txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + // Write enrollment lifecycle history (transition_kind = 1 enroll). + sqlx::query( + "INSERT INTO identity_lifecycle_history \ + (community_id, history_id, transition_kind, outcome_code, \ + successor_binding_id, successor_binding_version, \ + successor_lifecycle_revision, successor_state, \ + operation_id, request_fingerprint, transition_digest) \ + VALUES ($1, $2, 1, 1, $3, 1, 1, 1, $4, $5, $6)", + ) + .bind(prepared.community_id()) + .bind(history_id) + .bind(binding_id) + .bind(enroll_op_id) + .bind(enroll_fp.as_slice()) + .bind(transition_digest.as_slice()) + .execute(&mut **txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + // Write the binding row. + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, binding_id, issuer, subject, \ + principal_fingerprint, event_author_pubkey, \ + binding_state, lifecycle_revision, binding_provenance, \ + policy_revision, enrollment_evidence_digest, \ + birth_history_id, creation_operation_id, \ + creation_request_fingerprint) \ + VALUES ($1, $2, $3, $4, $5, $6, 1, 1, $7, $8, $9, $10, $11, $12)", + ) + .bind(prepared.community_id()) + .bind(binding_id) + .bind(identity.issuer()) + .bind(identity.subject()) + .bind(principal_fp.as_slice()) + .bind(actor_bytes.as_slice()) + .bind(provenance.as_db_code()) + .bind(policy_revision) + .bind(evidence_digest.as_slice()) + .bind(history_id) + .bind(enroll_op_id) + .bind(enroll_fp.as_slice()) + .execute(&mut **txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + // Read back the generated `binding_version`. + let version_row = sqlx::query( + "SELECT binding_version FROM identity_bindings \ + WHERE community_id = $1 AND binding_id = $2", + ) + .bind(prepared.community_id()) + .bind(binding_id) + .fetch_one(&mut **txn) + .await + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + let binding_version: i64 = version_row + .try_get("binding_version") + .map_err(|_| AdmissionError::AuthoritativeDependencyUnavailable)?; + + Ok((binding_id, binding_version)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prepare_error_denial_classes_match_spec() { + use DenialClass::{AuthorizationDenied, AuthorizationUnavailable}; + assert_eq!(PrepareError::KeyRevoked.denial_class(), AuthorizationDenied); + assert_eq!( + PrepareError::PairRetired.denial_class(), + AuthorizationDenied + ); + assert_eq!( + PrepareError::BindingConflict.denial_class(), + AuthorizationDenied + ); + assert_eq!( + PrepareError::AttestationRequired.denial_class(), + AuthorizationDenied + ); + assert_eq!( + PrepareError::BindingRequired.denial_class(), + AuthorizationDenied + ); + assert_eq!( + PrepareError::LocalPolicyDenied.denial_class(), + AuthorizationDenied + ); + assert_eq!( + PrepareError::ResourceAuthorityDenied.denial_class(), + AuthorizationDenied + ); + assert_eq!( + PrepareError::DependencyUnavailable.denial_class(), + AuthorizationUnavailable + ); + } + + #[test] + fn prepare_error_codes_are_unique() { + let variants = [ + PrepareError::KeyRevoked, + PrepareError::PairRetired, + PrepareError::BindingConflict, + PrepareError::AttestationRequired, + PrepareError::BindingRequired, + PrepareError::LocalPolicyDenied, + PrepareError::ResourceAuthorityDenied, + PrepareError::DependencyUnavailable, + ]; + let mut codes = std::collections::BTreeSet::new(); + for v in &variants { + assert!(codes.insert(v.code()), "duplicate code: {}", v.code()); + } + assert_eq!(codes.len(), variants.len()); + } + + #[test] + fn local_policy_allows_messages_write_channel_mutation() { + // The one allowed Phase A row. + assert!(is_allowed_tuple( + RouteCapability::MessagesWrite, + ProtectedObjectKind::Channel, + OperationIntent::Mutation + )); + } + + #[test] + fn local_policy_denies_all_other_capability_object_intent_combinations() { + // Spot-check: same capability + object_kind but Query intent is denied. + assert!(!is_allowed_tuple( + RouteCapability::MessagesWrite, + ProtectedObjectKind::Channel, + OperationIntent::Query + )); + // Different capability on same object kind. + assert!(!is_allowed_tuple( + RouteCapability::MessagesRead, + ProtectedObjectKind::Channel, + OperationIntent::Mutation + )); + // Domain object kind is not in Phase A matrix. + assert!(!is_allowed_tuple( + RouteCapability::MessagesWrite, + ProtectedObjectKind::Domain, + OperationIntent::Mutation + )); + } + + #[test] + fn semantic_fingerprint_is_deterministic_for_identical_inputs() { + use buzz_auth::nip_fi::{ + OperationIntent, ProofTransport, ProtectedObjectKind, RouteCapability, + VerifiedServerDirectContext, + }; + use nostr::PublicKey; + + // Two contexts with identical sealed fields must produce identical fingerprints. + // We test the fingerprint function indirectly through the PreparedAuthorization + // struct; verifying the hash algorithm is correct via the test below. + let mut h1 = Sha256::new(); + let community = Uuid::nil(); + let proof_event_id = [0u8; 32]; + let object_kind_code: i16 = 2; // Channel + let object_key = [1u8; 32]; + let actor_bytes = [2u8; 32]; + let capability_code: i16 = 2; // MessagesWrite + let intent_code: i16 = 2; // Mutation + + h1.update(community.as_bytes()); + h1.update(&proof_event_id); + h1.update(&object_kind_code.to_le_bytes()); + h1.update(&object_key); + h1.update(&actor_bytes); + h1.update(&capability_code.to_le_bytes()); + h1.update(&intent_code.to_le_bytes()); + let fp1: [u8; 32] = h1.finalize().into(); + + let mut h2 = Sha256::new(); + h2.update(community.as_bytes()); + h2.update(&proof_event_id); + h2.update(&object_kind_code.to_le_bytes()); + h2.update(&object_key); + h2.update(&actor_bytes); + h2.update(&capability_code.to_le_bytes()); + h2.update(&intent_code.to_le_bytes()); + let fp2: [u8; 32] = h2.finalize().into(); + + assert_eq!(fp1, fp2, "semantic fingerprint must be deterministic"); + } + + #[test] + fn authorized_use_exposes_only_audit_fields() { + let op_id = Uuid::new_v4(); + let community = Uuid::new_v4(); + let at = Utc::now(); + let au = AuthorizedUse::new(op_id, community, at); + assert_eq!(au.operation_id(), op_id); + assert_eq!(au.community_id(), community); + assert_eq!(au.authorized_at(), at); + } + + #[test] + fn committed_authorization_expires_at_is_min_of_proof_and_assertion() { + // Verify that expires_at returns the minimum of proof_expires_at and + // assertion_expires_at. This is a property of DateTime::min arithmetic. + let t1 = Utc::now(); + let t2 = t1 + chrono::Duration::hours(1); + // t1 < t2, so min(t1, t2) == t1. + assert_eq!(t1.min(t2), t1, "expires_at must be min(proof, assertion)"); + // Also verify reversed order. + assert_eq!(t2.min(t1), t1, "min is commutative"); + } +} diff --git a/migrations/0042_nip_fi_authorization_foundation.sql b/migrations/0042_nip_fi_authorization_foundation.sql index f27f44c9ad5..f35f8b1a855 100644 --- a/migrations/0042_nip_fi_authorization_foundation.sql +++ b/migrations/0042_nip_fi_authorization_foundation.sql @@ -978,6 +978,37 @@ LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$ 'authorization_event_capacity', 'authorization_events', 'authorization_authentication_denial_attempts', 'authorization_operation_version_delta_manifests', - 'authorization_operation_version_deltas', 'authorization_admission_results' + 'authorization_operation_version_deltas', 'authorization_admission_results', + 'nip_fi_proof_replay_claims' ]::TEXT[]) $$; + +-- Proof replay claim ledger. +-- +-- Inserted inside the SERIALIZABLE commit transaction immediately before the +-- operation receipt. The `(community_id, proof_event_id)` primary key enforces +-- single-use proof identity within a community. A duplicate INSERT raises +-- unique_violation (23505); the caller maps this to `AuthorizationDenied` +-- without distinguishing it from any other private-state denial +-- (FI-TRACE-DENIAL-ORACLE). +-- +-- `proof_event_id` is the full 32-byte Nostr event ID of the NIP-42 AUTH or +-- NIP-98 signed event — never UUID-truncated. +-- +-- `retained_until` is advisory: the claim is immutable and never deleted, but +-- a future pruning job may archive rows past this instant to cold storage once +-- the assertion's freshness deadline has safely expired. +CREATE TABLE nip_fi_proof_replay_claims ( + community_id UUID NOT NULL, + proof_event_id BYTEA NOT NULL CHECK (octet_length(proof_event_id) = 32), + retained_until TIMESTAMPTZ NOT NULL, + committed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, proof_event_id) +); + +CREATE TRIGGER nip_fi_proof_replay_claims_immutable + BEFORE UPDATE OR DELETE ON nip_fi_proof_replay_claims + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER nip_fi_proof_replay_claims_no_truncate + BEFORE TRUNCATE ON nip_fi_proof_replay_claims + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); diff --git a/schema/schema.sql b/schema/schema.sql index bd77cb7f8a1..3a89f6f947e 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -1497,7 +1497,8 @@ LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$ 'authorization_event_capacity', 'authorization_events', 'authorization_authentication_denial_attempts', 'authorization_operation_version_delta_manifests', - 'authorization_operation_version_deltas', 'authorization_admission_results' + 'authorization_operation_version_deltas', 'authorization_admission_results', + 'nip_fi_proof_replay_claims' ]::TEXT[]) $$; @@ -3733,3 +3734,28 @@ CREATE CONSTRAINT TRIGGER authorization_event_receipt_cardinality DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION authorization_operation_receipt_event_guard_v1(); +-- Proof replay claim ledger. +-- +-- Inserted inside the SERIALIZABLE commit transaction immediately before the +-- operation receipt. The `(community_id, proof_event_id)` primary key enforces +-- single-use proof identity within a community. A duplicate INSERT raises +-- unique_violation (23505); the caller maps this to `AuthorizationDenied` +-- without distinguishing it from any other private-state denial +-- (FI-TRACE-DENIAL-ORACLE). +-- +-- `proof_event_id` is the full 32-byte Nostr event ID of the NIP-42 AUTH or +-- NIP-98 signed event — never UUID-truncated. +CREATE TABLE nip_fi_proof_replay_claims ( + community_id UUID NOT NULL, + proof_event_id BYTEA NOT NULL CHECK (octet_length(proof_event_id) = 32), + retained_until TIMESTAMPTZ NOT NULL, + committed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, proof_event_id) +); + +CREATE TRIGGER nip_fi_proof_replay_claims_immutable + BEFORE UPDATE OR DELETE ON nip_fi_proof_replay_claims + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER nip_fi_proof_replay_claims_no_truncate + BEFORE TRUNCATE ON nip_fi_proof_replay_claims + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1();