chore(db): drop Phase-A NIP-FI relay-side authority ledger (#7221)

Removes the dead relay-side authority ledger introduced by migrations
0041 and 0042, and the dead `require_attested_key` verifier knob from
`buzz-auth`. Both are unreachable by design under NIP-FI spec v2 (#7214,
squash `d4420eb47`), which makes OSS Buzz stateless for identity: the
relay neither stores nor verifies an authority chain.

## What changes

**`migrations/0044_drop_nip_fi_ledger.sql`**
Drops all fifteen NIP-FI ledger tables and their trigger functions using
`CASCADE` to resolve the circular deferred FK between
`identity_bindings` and `identity_lifecycle_history`. Drops proceed in
FK dependency order: selectors → history/bindings →
enrollment_policies/receipts → parallel drop of auth tables. Restores
`community_write_fence_excluded_table` to its pre-0041 body (removes
NIP-FI table names from the exclusion array).

**`schema/schema.sql`**
Removes the NIP-FI section (~1885 lines of tables, functions, and
triggers) and updates `community_write_fence_excluded_table` to match.

**`crates/buzz-db/src/runtime/migration.rs`**
- Updates the `embedded_migrator_contains_consolidated_initial_schema`
sanity check: count 43→44, adds 0044 assertion block (verifies `DROP
TABLE` statements and absence of NIP-FI names from `schema.sql`).
- Removes ~2580 lines of NIP-FI Postgres integration tests (all
`#[tokio::test] #[ignore = "requires Postgres"]` from the 0041/0042
behavioral coverage).
- Removes the `extract_excluded_table_array` drift check (0042 body no
longer matches `schema.sql` by design).
- Adds `migration_0044_drops_populated_nip_fi_ledger_cleanly`: runs
migrations to 0042, seeds rows in `authorization_operation_receipts` and
`authorization_invalidation_domains`, then runs to 0044 and verifies all
fifteen NIP-FI tables are absent.

**`crates/buzz-auth/src/nip_fi/config.rs`**
Removes `require_attested_key: bool` from `IssuerPolicy` — field,
constructor parameter, accessor, and its contribution to
`derive_assertion_policy_id`.

**`crates/buzz-auth/src/nip_fi/verifier.rs`**
`parse_nostr_pubkey_claim` no longer takes a `policy` parameter. The
`None` (absent claim) arm now returns
`Err(VerifierError::ClaimRejected)` unconditionally instead of
conditionally on `policy.require_attested_key()`.

**`crates/buzz-auth/src/nip_fi/verifier/tests.rs`**
- Removes `missing_nostr_pubkey_denies_under_attested_key_policy` (the
sole `require_attested_key: true` call site).
- Removes `false,` from all eleven `IssuerPolicy::new` call sites.
- Injects `nostr_pubkey` by default in `mint_signed_by` (spec v2
requires it unconditionally).
- Updates `valid_access_token_verifies` to assert
`asserted_key().is_some()`.

**`crates/buzz-auth/src/nip_fi/startup/tests.rs` + `jwks/tests.rs`**
Removes `false,` from all `IssuerPolicy::new` call sites and adds
`nostr_pubkey` to all token-minting helpers.

## Verification

- Fresh-DB migration run to head: all migrations apply cleanly in
sequence.
- Populated-0041/0042-DB migration through 0044: seeds rows in live
NIP-FI tables, verifies all fifteen are dropped without error.

Closes the dead-code inventory item from the spec-v2 cleanup plan
(channel `48374f48`). Follows #7214.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-09-02 13:56:41 -04:00
committed by GitHub
parent 2af9773d62
commit 04babf0265
9 changed files with 205 additions and 4523 deletions

View File

@@ -69,7 +69,11 @@ pub(crate) const MAX_JWKS_KEYS: usize = 64;
/// semantics** so prepared evidence built against an older contract is
/// invalidated. Per-policy fields (issuer, class, bounds, …) are hashed
/// separately and need no bump.
pub(crate) const VERIFIER_CONTRACT_VERSION: u32 = 1;
///
/// v2 (PR #7221): `nostr_pubkey` absence now unconditionally rejects — the
/// per-issuer `require_attested_key` knob is removed and the NIP-FI v2 spec
/// requirement is always enforced.
pub(crate) const VERIFIER_CONTRACT_VERSION: u32 = 2;
/// The transport-contract fingerprint folded into [`TransportContractId`].
/// **Bump on any change** to the client-attached parsing, attachment,
@@ -347,7 +351,6 @@ pub struct IssuerPolicy {
token_class: TokenClass,
freshness: FreshnessClass,
algorithms: Vec<Algorithm>,
require_attested_key: bool,
skew_seconds: u64,
maximum_assertion_age_seconds: u64,
maximum_status_age_seconds: Option<u64>,
@@ -404,7 +407,6 @@ impl IssuerPolicy {
token_class: TokenClass,
freshness: FreshnessClass,
algorithms: Vec<Algorithm>,
require_attested_key: bool,
skew_seconds: u64,
maximum_assertion_age_seconds: u64,
maximum_status_age_seconds: Option<u64>,
@@ -467,7 +469,6 @@ impl IssuerPolicy {
&token_class,
freshness,
&algorithms,
require_attested_key,
skew_seconds,
maximum_assertion_age_seconds,
maximum_status_age_seconds,
@@ -480,7 +481,6 @@ impl IssuerPolicy {
token_class,
freshness,
algorithms,
require_attested_key,
skew_seconds,
maximum_assertion_age_seconds,
maximum_status_age_seconds,
@@ -514,11 +514,6 @@ impl IssuerPolicy {
&self.algorithms
}
/// Whether enrollment requires a `nostr_pubkey` claim equal to the actor.
pub const fn require_attested_key(&self) -> bool {
self.require_attested_key
}
/// The accepted clock skew, in seconds.
pub const fn skew_seconds(&self) -> u64 {
self.skew_seconds
@@ -646,7 +641,6 @@ fn derive_assertion_policy_id(
token_class: &TokenClass,
freshness: FreshnessClass,
algorithms: &[Algorithm],
require_attested_key: bool,
skew_seconds: u64,
maximum_assertion_age_seconds: u64,
maximum_status_age_seconds: Option<u64>,
@@ -702,7 +696,6 @@ fn derive_assertion_policy_id(
&mut hasher,
algorithms.iter().map(|a| algorithm_tag(*a).as_bytes()),
);
hasher.update([u8::from(require_attested_key)]);
hasher.update(skew_seconds.to_be_bytes());
hasher.update(maximum_assertion_age_seconds.to_be_bytes());
hasher.update(maximum_status_age_seconds.unwrap_or(0).to_be_bytes());

View File

@@ -896,7 +896,9 @@ async fn two_issuer_keys_and_generations_are_isolated() {
fn sign(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String {
let now = chrono::Utc::now().timestamp();
// nostr_pubkey is required unconditionally by spec v2.
let claims = json!({"iss": iss, "aud": aud, "sub": "u",
"nostr_pubkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"iat": now, "exp": now + 600});
let mut hdr = Header::new(Algorithm::ES256);
hdr.kid = Some(kid.to_owned());
@@ -921,7 +923,6 @@ async fn two_issuer_keys_and_generations_are_isolated() {
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
false,
60,
3600,
None,
@@ -1112,7 +1113,9 @@ async fn shared_arc_source_verifier_observes_rotation() {
fn sign_token(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String {
let now = chrono::Utc::now().timestamp();
// nostr_pubkey is required unconditionally by spec v2.
let claims = json!({"iss": iss, "aud": aud, "sub": "u",
"nostr_pubkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"iat": now, "exp": now + 600});
let mut hdr = Header::new(Algorithm::ES256);
hdr.kid = Some(kid.to_owned());
@@ -1171,7 +1174,6 @@ async fn shared_arc_source_verifier_observes_rotation() {
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
false,
60,
3600,
None,
@@ -1233,7 +1235,6 @@ fn jwks_contract_uri_canonicalization_convergence_and_divergence() {
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
false,
30,
600,
None,
@@ -1489,7 +1490,9 @@ async fn shared_arc_source_verifier_rejects_expired_a1_accepts_a2() {
fn sign_token(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String {
let wall_now = chrono::Utc::now().timestamp();
// nostr_pubkey is required unconditionally by spec v2.
let claims = json!({"iss": iss, "aud": aud, "sub": "u",
"nostr_pubkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"iat": wall_now, "exp": wall_now + 600});
let mut hdr = Header::new(Algorithm::ES256);
hdr.kid = Some(kid.to_owned());
@@ -1572,7 +1575,6 @@ async fn shared_arc_source_verifier_rejects_expired_a1_accepts_a2() {
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
false,
60,
HARD_DEADLINE_SECS,
None,

View File

@@ -21,7 +21,6 @@ fn make_offline_policy(issuer: &str) -> IssuerPolicy {
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![JwtAlgorithm::ES256],
false,
0,
3600,
None,
@@ -37,7 +36,6 @@ fn make_status_policy(issuer: &str) -> IssuerPolicy {
TokenClass::DedicatedNipFi,
FreshnessClass::CurrentStatus,
vec![JwtAlgorithm::ES256],
false,
0,
3600,
Some(60),

View File

@@ -362,7 +362,7 @@ impl<S: IssuerKeySource> FederatedAssertionVerifier<S> {
enforce_claim_semantics(policy, &claims)?;
let subject = claim_string(&claims, SUBJECT_CLAIM, MAX_SUBJECT_BYTES)?;
let asserted_key = parse_nostr_pubkey_claim(policy, &claims)?;
let asserted_key = parse_nostr_pubkey_claim(&claims)?;
let now = Utc::now();
let deadlines = self.check_time_and_deadlines(policy, &key_set, &claims, now)?;
@@ -720,20 +720,13 @@ fn enforce_claim_semantics(
}
/// Parse the fixed `nostr_pubkey` claim: lowercase hex of exactly one 32-byte
/// key. Bech32 and other aliases deny. Absence is permitted unless the policy
/// requires an attested key.
/// key. Bech32 and other aliases deny. Absence denies; the merged NIP-FI
/// spec v2 (PR #7214) requires the `nostr_pubkey` claim unconditionally.
fn parse_nostr_pubkey_claim(
policy: &IssuerPolicy,
claims: &Map<String, Value>,
) -> Result<Option<PublicKey>, VerifierError> {
match claims.get(NOSTR_PUBKEY_CLAIM) {
None => {
if policy.require_attested_key() {
Err(VerifierError::ClaimRejected)
} else {
Ok(None)
}
}
None => Err(VerifierError::ClaimRejected),
Some(value) => {
let raw = value.as_str().ok_or(VerifierError::ClaimRejected)?;
if raw.len() != 64

View File

@@ -27,6 +27,9 @@ const TEST_JWK_Y: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA";
const TEST_KID: &str = "test-key-1";
const ISSUER: &str = "https://issuer.example";
const AUDIENCE: &str = "https://relay.example";
/// A canonical lowercase-hex nostr pubkey for tokens that are not testing
/// the nostr_pubkey claim specifically. Spec v2 requires the claim unconditionally.
const TEST_NOSTR_PUBKEY: &str = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
/// A canonical JWKS contract for the default test issuer. Used wherever a
/// `JwksSourceContract` is required but JWKS behavior is not under test.
@@ -109,7 +112,6 @@ fn access_token_policy_with(subject_class: SubjectClassContract) -> IssuerPolicy
TokenClass::AccessTokenAtJwt { subject_class },
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
false,
60,
3600,
None,
@@ -131,7 +133,6 @@ fn dedicated_policy(issuer: &str) -> IssuerPolicy {
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
false,
60,
3600,
None,
@@ -147,7 +148,6 @@ fn dedicated_policy_with_audiences(audiences: Vec<String>) -> IssuerPolicy {
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
false,
60,
3600,
None,
@@ -163,7 +163,6 @@ fn dedicated_policy_with_algorithms(algorithms: Vec<Algorithm>) -> IssuerPolicy
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
algorithms,
false,
60,
3600,
None,
@@ -198,6 +197,10 @@ fn mint_signed_by(pkcs8_pem: &str, typ: Option<&str>, kid: &str, mut claims: Val
obj.entry("aud").or_insert(json!(AUDIENCE));
obj.entry("iat").or_insert(json!(now()));
obj.entry("exp").or_insert(json!(now() + 600));
// Spec v2 requires nostr_pubkey unconditionally; inject a canonical
// test pubkey so tokens that test other behaviours pass the claim check.
obj.entry(NOSTR_PUBKEY_CLAIM)
.or_insert(json!(TEST_NOSTR_PUBKEY));
}
let mut header = Header::new(Algorithm::ES256);
header.kid = Some(kid.to_owned());
@@ -206,6 +209,26 @@ fn mint_signed_by(pkcs8_pem: &str, typ: Option<&str>, kid: &str, mut claims: Val
jsonwebtoken::encode(&header, &claims, &key).expect("sign")
}
/// Mint a valid, signed token that deliberately omits `nostr_pubkey`. Used
/// only to exercise the unconditional missing-claim rejection path; the normal
/// `mint`/`mint_signed_by` helpers always inject the claim via `or_insert` so
/// they cannot produce an absent-claim token.
fn mint_no_pubkey(typ: Option<&str>, kid: &str, mut claims: Value) -> String {
{
let obj = claims.as_object_mut().expect("claims object");
obj.entry("iss").or_insert(json!(ISSUER));
obj.entry("aud").or_insert(json!(AUDIENCE));
obj.entry("iat").or_insert(json!(now()));
obj.entry("exp").or_insert(json!(now() + 600));
// Intentionally does NOT inject nostr_pubkey.
}
let mut header = Header::new(Algorithm::ES256);
header.kid = Some(kid.to_owned());
header.typ = typ.map(str::to_owned);
let key = EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()).expect("valid EC PEM");
jsonwebtoken::encode(&header, &claims, &key).expect("sign")
}
/// A resource-owner `at+jwt` claim set: valid subject-class marker plus client_id.
fn resource_owner_claims() -> Value {
json!({ "sub": "user-123", "client_id": "app-1", "sub_type": "user" })
@@ -240,7 +263,8 @@ fn valid_access_token_verifies() {
let assertion = verifier.verify(&token).expect("verifies");
assert_eq!(assertion.identity().issuer(), ISSUER);
assert_eq!(assertion.identity().subject(), "user-123");
assert!(assertion.asserted_key().is_none());
// Spec v2: nostr_pubkey is injected by mint() and unconditionally required.
assert!(assertion.asserted_key().is_some());
assert!(!assertion.authority_deadlines().is_empty());
assert_eq!(assertion.assertion_policy_id(), access_token_policy().id());
}
@@ -698,22 +722,18 @@ fn uppercase_nostr_pubkey_denies() {
}
#[test]
fn missing_nostr_pubkey_denies_under_attested_key_policy() {
let policy = IssuerPolicy::new(
ISSUER.to_owned(),
vec![AUDIENCE.to_owned()],
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
true, // require attested key
60,
3600,
None,
test_jwks_contract(),
)
.unwrap();
let verifier = verifier_with(policy);
let token = mint(Some("nip-fi+jwt"), TEST_KID, json!({ "sub": "u" }));
fn absent_nostr_pubkey_claim_denies() {
// `nostr_pubkey` absence must unconditionally reject — NIP-FI v2 dropped
// the per-issuer `require_attested_key` knob that previously made it
// optional. This is a direct falsifiable regression test: removing the
// `None => Err(VerifierError::ClaimRejected)` arm from
// `parse_nostr_pubkey_claim` must turn this test red.
let verifier = verifier_with(access_token_policy());
let token = mint_no_pubkey(
Some("at+jwt"),
TEST_KID,
json!({ "sub": "u", "client_id": "a", "sub_type": "user" }),
);
assert_eq!(
verifier.verify(&token).unwrap_err(),
VerifierError::ClaimRejected
@@ -1105,7 +1125,6 @@ fn current_status_policy() -> IssuerPolicy {
TokenClass::DedicatedNipFi,
FreshnessClass::CurrentStatus,
vec![Algorithm::ES256],
false,
60,
3600,
Some(120), // maximum_status_age required for current-status
@@ -1385,7 +1404,6 @@ fn assertion_policy_id_is_deterministic_and_semantic() {
changed.token_class().clone(),
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
false,
120, // different skew => different semantics
3600,
None,
@@ -1411,7 +1429,6 @@ fn offline_policy_rejects_inapplicable_maximum_status_age() {
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
false,
60,
3600,
Some(120),
@@ -1430,7 +1447,6 @@ fn offline_policy_accepts_absent_maximum_status_age() {
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
false,
60,
3600,
None,
@@ -1449,7 +1465,6 @@ fn current_status_policy_still_requires_positive_maximum_status_age() {
TokenClass::DedicatedNipFi,
FreshnessClass::CurrentStatus,
vec![Algorithm::ES256],
false,
60,
3600,
None,
@@ -1463,7 +1478,6 @@ fn current_status_policy_still_requires_positive_maximum_status_age() {
TokenClass::DedicatedNipFi,
FreshnessClass::CurrentStatus,
vec![Algorithm::ES256],
false,
60,
3600,
Some(0),
@@ -1579,7 +1593,6 @@ fn policy_with_contract(contract: crate::nip_fi::jwks::JwksSourceContract) -> Is
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
false,
60,
3600,
None,

File diff suppressed because it is too large Load Diff