Files
buzz/crates/buzz-auth/src/nip_fi/config.rs
Will Pfleger 04babf0265 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>
2026-09-02 13:56:41 -04:00

735 lines
29 KiB
Rust

//! Multi-issuer assertion-policy configuration and the two NIP-FI semantic
//! contract identities.
//!
//! Identity is issuer-qualified `(iss, sub)`; there is no single-global-issuer
//! assumption. An [`IssuerRegistry`] selects exactly one [`IssuerPolicy`] by the
//! exact `iss` value returned by JWT decoding; a single-issuer deployment is
//! just a registry of length one.
//!
//! Buzz ships the generic OSS contract only: issuer URLs and audiences are
//! deployment configuration. The identity claim names are fixed — `sub` is the
//! subject coordinate and `nostr_pubkey` the bound key — so no deployment can
//! promote a mutable attribute into identity.
//!
//! Two deployment-local but deterministic identities are defined here
//! ([NIP-FI.md](../../../../docs/nips/NIP-FI.md), "Policy identity and
//! snapshots"):
//!
//! - [`AssertionPolicyId`] `= H(canonical assertion-policy contract)` — changes
//! when accepted assertion semantics change, never when key or status
//! snapshot contents rotate.
//! - [`TransportContractId`] `= H(canonical transport contract)` — identifies
//! the client-attached field, parsing, attachment, no-fallback, and
//! context-preservation semantics.
use jsonwebtoken::Algorithm;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fmt;
use super::jwks::JwksSourceContract;
/// Maximum accepted length of an `iss` or `aud` string.
const MAX_URI_LEN: usize = 2_048;
/// Maximum accepted length of a claim name.
const MAX_CLAIM_NAME_LEN: usize = 128;
/// Maximum accepted length of a configured claim value (subject-class markers).
const MAX_CLAIM_VALUE_LEN: usize = 2_048;
/// Maximum accepted clock skew, in seconds.
const MAX_SKEW_SECONDS: u64 = 300;
/// Maximum accepted assertion age, in seconds.
const MAX_ASSERTION_AGE_SECONDS: u64 = 86_400;
// Normative size rules for the assertion the verifier bounds before lookup or
// logging. They live here so they fold into `assertion_policy_id`: a change to
// any bound moves the ID mechanically. The verifier imports them.
/// Maximum accepted compact-JWS length, in bytes.
pub(crate) const MAX_TOKEN_BYTES: usize = 64 * 1024;
/// Maximum accepted `kid` length, in bytes.
pub(crate) const MAX_KID_BYTES: usize = 512;
/// Maximum accepted subject length, in bytes.
pub(crate) const MAX_SUBJECT_BYTES: usize = 2_048;
/// Maximum accepted `client_id` length, in bytes.
pub(crate) const MAX_CLIENT_ID_BYTES: usize = 2_048;
/// Maximum number of keys in one authenticated JWKS snapshot. The verifier
/// scans the snapshot by an attacker-controlled `kid` on every unauthenticated
/// token naming a configured issuer, so the authenticated key set is bounded
/// before lookup (NIP-FI.md "bounds the … authenticated key set before
/// lookup"). Real issuer JWKS carry a handful of keys even across rotation;
/// this cap blocks an oversized snapshot from turning each lookup into an
/// attacker-driven O(keys) scan.
pub(crate) const MAX_JWKS_KEYS: usize = 64;
/// The compiled-verifier-behavior fingerprint folded into every
/// [`AssertionPolicyId`]. It stands in for the normative semantic inputs that
/// are not otherwise field-encoded: duplicate-member rejection, exact-byte
/// (non-canonicalizing) identity handling, the JWKS-snapshot key-source
/// contract (kid selection, generation versioning, hard deadline), claim
/// capture, and the offline time arithmetic. **Bump on any change to those
/// semantics** so prepared evidence built against an older contract is
/// invalidated. Per-policy fields (issuer, class, bounds, …) are hashed
/// separately and need no bump.
///
/// 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,
/// no-fallback, or context-preservation semantics.
pub(crate) const TRANSPORT_CONTRACT_VERSION: u32 = 1;
/// The fixed name of the Nostr-key claim ([NIP-FI.md](../../../../docs/nips/NIP-FI.md),
/// "Assertion validation"). Not configurable: other encodings and aliases deny.
pub const NOSTR_PUBKEY_CLAIM: &str = "nostr_pubkey";
/// The fixed identity-subject claim. Identity is the exact tuple `(iss, sub)`
/// (NIP-FI.md:35-41), so the subject coordinate is always the JWT `sub` claim
/// and is never deployment-configurable: an operator cannot seal a mutable
/// attribute such as `email` or `display_name` as identity (NIP-FI.md:173-175,
/// :296-298). Attributes other than `sub` may be captured as claims/capabilities
/// but never as the identity coordinate.
pub const SUBJECT_CLAIM: &str = "sub";
/// Stable identifier for the accepted assertion-policy semantics.
///
/// Deliberately excludes key material, snapshot versions, and mutable state:
/// benign JWKS rotation must not change policy lineage.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct AssertionPolicyId([u8; 32]);
impl AssertionPolicyId {
/// The stable 32-byte policy digest.
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
impl fmt::Debug for AssertionPolicyId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "AssertionPolicyId({})", hex::encode(self.0))
}
}
/// Stable identifier for the client-attached transport contract semantics.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct TransportContractId([u8; 32]);
impl TransportContractId {
/// The core client-attached transport contract identity.
///
/// Covers the exact field name, `Bearer` parsing, request/upgrade
/// attachment, no-fallback, and context-preservation semantics of
/// [`super::CLIENT_ATTACHED_HEADER`]. Changing any of those semantics
/// changes this constant; request data does not.
pub fn core_client_attached() -> Self {
let mut hasher = Sha256::new();
hasher.update(b"buzz:nip-fi:transport-contract:v1\0");
// Explicit contract version: bump on any change to the parsing,
// attachment, no-fallback, or context-preservation semantics below so
// prepared evidence bound to an older transport contract is invalidated.
hasher.update(TRANSPORT_CONTRACT_VERSION.to_be_bytes());
hash_field(&mut hasher, super::CLIENT_ATTACHED_HEADER.as_bytes());
hash_field(&mut hasher, b"Bearer");
// No-fallback, request-attached, one-field, context-preserving.
hash_field(
&mut hasher,
b"no-fallback;single-field;request-attached;server-owned-context",
);
Self(hasher.finalize().into())
}
/// The stable 32-byte transport-contract digest.
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
impl fmt::Debug for TransportContractId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "TransportContractId({})", hex::encode(self.0))
}
}
/// The RFC 9068 / OAuth 2.0 access-token claim naming the OAuth client. An
/// `at+jwt` access token MUST carry exactly one non-empty bounded value.
/// Not deployment-configurable.
pub const OAUTH_CLIENT_ID_CLAIM: &str = "client_id";
/// Whether an issuer policy admits tokens whose subject represents the OAuth
/// client (client-credentials or client-subject tokens).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientSubjectPosture {
/// Client-subject tokens are ineligible; only resource-owner tokens admit.
Reject,
/// Client-subject tokens are eligible. The issuer has guaranteed their
/// `(iss, sub)` coordinates cannot collide with resource-owner coordinates
/// (NIP-FI.md token-class rule); the operator records that guarantee here.
AcceptNonColliding,
}
impl ClientSubjectPosture {
const fn tag(self) -> &'static str {
match self {
Self::Reject => "client-subject:reject",
Self::AcceptNonColliding => "client-subject:accept-non-colliding",
}
}
}
/// A closed, issuer-configured contract that classifies an access token's
/// subject as resource-owner or OAuth-client from one authenticated marker
/// claim, using mutually exclusive value sets. A token matching both sets or
/// neither is ambiguous and denies — "admits both interpretations" is
/// unrepresentable as an accepted result. When client-subject tokens are
/// admitted, the operator records the non-collision guarantee via
/// [`ClientSubjectPosture::AcceptNonColliding`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubjectClassContract {
marker_claim: String,
resource_owner_values: Vec<String>,
client_subject_values: Vec<String>,
posture: ClientSubjectPosture,
}
/// The classification of one token's subject under a [`SubjectClassContract`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubjectClass {
/// The subject is the human/resource owner.
ResourceOwner,
/// The subject represents the OAuth client.
ClientSubject,
}
impl SubjectClassContract {
/// Build and validate a subject-class contract. The two value sets must be
/// non-empty, bounded, and disjoint, so classification is total and
/// mutually exclusive. Rejects overlap with [`IssuerPolicyError::NonExclusiveSubjectClass`].
pub fn new(
marker_claim: String,
resource_owner_values: Vec<String>,
client_subject_values: Vec<String>,
posture: ClientSubjectPosture,
) -> Result<Self, IssuerPolicyError> {
if marker_claim.is_empty() || marker_claim.len() > MAX_CLAIM_NAME_LEN {
return Err(IssuerPolicyError::InvalidSubjectClaim);
}
let bounded = |vs: &[String]| {
!vs.is_empty()
&& vs
.iter()
.all(|v| !v.is_empty() && v.len() <= MAX_CLAIM_VALUE_LEN)
};
if !bounded(&resource_owner_values) || !bounded(&client_subject_values) {
return Err(IssuerPolicyError::NonExclusiveSubjectClass);
}
// These value sets are consumed as membership sets during
// classification, so caller order and duplicates carry no semantics.
// Canonicalize before storage so the derived policy ID is invariant
// under permutation and duplication (NIP-FI.md "Policy identity").
let resource_owner_values = canonical_set(resource_owner_values);
let client_subject_values = canonical_set(client_subject_values);
if resource_owner_values
.iter()
.any(|v| client_subject_values.contains(v))
{
return Err(IssuerPolicyError::NonExclusiveSubjectClass);
}
Ok(Self {
marker_claim,
resource_owner_values,
client_subject_values,
posture,
})
}
/// The authenticated marker claim classified.
pub fn marker_claim(&self) -> &str {
&self.marker_claim
}
/// Values marking a resource-owner subject.
pub fn resource_owner_values(&self) -> &[String] {
&self.resource_owner_values
}
/// Values marking an OAuth-client subject.
pub fn client_subject_values(&self) -> &[String] {
&self.client_subject_values
}
/// The client-subject admission posture.
pub const fn posture(&self) -> ClientSubjectPosture {
self.posture
}
/// Classify a marker value. Exactly one set matches or the token is
/// ambiguous. Values are compared by exact bytes.
pub fn classify(&self, marker_value: Option<&str>) -> Option<SubjectClass> {
let value = marker_value?;
let ro = self.resource_owner_values.iter().any(|v| v == value);
let cs = self.client_subject_values.iter().any(|v| v == value);
match (ro, cs) {
(true, false) => Some(SubjectClass::ResourceOwner),
(false, true) => Some(SubjectClass::ClientSubject),
// Disjoint sets make (true, true) impossible; (false, false) is an
// unclassifiable subject.
_ => None,
}
}
}
/// The single token class an issuer policy accepts before parsing claims.
/// Policy selects exactly one; failure under one class never triggers another.
///
/// Only `at+jwt` and `nip-fi+jwt` are offered. There is deliberately no
/// generic/absent-`typ` "named compatibility" variant: such a class cannot be
/// proven disjoint from an OIDC ID token by claim presence alone (an issuer can
/// mint an ID token carrying `client_id`), and the only authenticated
/// discriminator is `typ`, which that mode declines to constrain. Its absence
/// is a live regression — an external crate that names the removed variant
/// fails to compile:
///
/// ```compile_fail
/// use buzz_auth::TokenClass;
/// let _forge = TokenClass::NamedCompatibility {
/// required_claims: vec!["client_id".to_owned()],
/// forbidden_claims: vec![],
/// };
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TokenClass {
/// RFC 9068 `at+jwt` access token: protected `typ` is exactly `at+jwt`.
/// Validated under this document's claim contract, not the full RFC 9068
/// profile. Requires one non-empty bounded `client_id`; its subject is
/// classified by an authenticated [`SubjectClassContract`].
AccessTokenAtJwt {
/// The mutually exclusive resource-owner/client-subject contract.
subject_class: SubjectClassContract,
},
/// A dedicated Buzz assertion: protected `typ` is exactly `nip-fi+jwt`.
DedicatedNipFi,
}
impl TokenClass {
fn discriminant(&self) -> &'static str {
match self {
Self::AccessTokenAtJwt { .. } => "at+jwt",
Self::DedicatedNipFi => "nip-fi+jwt",
}
}
}
/// The server-owned freshness class an issuer policy declares. Folded into
/// [`AssertionPolicyId`]. The verifier validates the offline portion; a
/// `CurrentStatus` policy additionally requires a runtime status witness
/// (delivered by a later PR), which the verifier does not itself gather.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FreshnessClass {
/// Validates the JWT and authenticated key snapshot only.
OfflineJwt,
/// Additionally requires an authenticated current-status witness at runtime.
CurrentStatus,
}
impl FreshnessClass {
const fn tag(self) -> &'static str {
match self {
Self::OfflineJwt => "offline-jwt",
Self::CurrentStatus => "current-status",
}
}
}
/// One issuer's accepted assertion semantics. Its [`AssertionPolicyId`] is
/// derived from every field below; a semantic change changes the ID.
#[derive(Debug, Clone)]
pub struct IssuerPolicy {
issuer: String,
audiences: Vec<String>,
token_class: TokenClass,
freshness: FreshnessClass,
algorithms: Vec<Algorithm>,
skew_seconds: u64,
maximum_assertion_age_seconds: u64,
maximum_status_age_seconds: Option<u64>,
/// The authenticated key-source contract: validated JWKS URI, refresh
/// interval, and hard deadline. Included in `derive_assertion_policy_id`
/// so that a change to the endpoint, refresh schedule, or hard-deadline
/// rule changes the policy ID and invalidates all prepared evidence.
jwks_source_contract: JwksSourceContract,
id: AssertionPolicyId,
}
/// Why an [`IssuerPolicy`] could not be constructed. Independent of any token.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum IssuerPolicyError {
/// `iss` was empty or exceeded the length bound.
#[error("invalid issuer")]
InvalidIssuer,
/// The audience set was empty or contained an invalid value.
#[error("invalid audience set")]
InvalidAudiences,
/// The subject claim name was empty or exceeded the length bound.
#[error("invalid subject claim")]
InvalidSubjectClaim,
/// The algorithm set was empty or contained a symmetric or `none` algorithm.
#[error("invalid algorithm set")]
InvalidAlgorithms,
/// A time or size rule was outside its accepted bound.
#[error("invalid time bounds")]
InvalidTimeBounds,
/// `current-status` freshness requires a positive finite `maximum_status_age`.
#[error("missing maximum status age")]
MissingMaximumStatusAge,
/// `offline-jwt` freshness never reads `maximum_status_age`, so accepting a
/// value would move the policy ID for two semantically identical offline
/// policies. It is rejected at construction.
#[error("inapplicable maximum status age")]
InapplicableMaximumStatusAge,
/// A `SubjectClassContract`'s value sets were empty, unbounded, or overlapped,
/// so subject classification could not be total and mutually exclusive.
#[error("subject class contract is not exclusive")]
NonExclusiveSubjectClass,
/// The [`JwksSourceContract`] was not valid — invalid URI, zero or
/// out-of-range timing, or `refresh_interval >= hard_deadline`.
#[error("invalid JWKS source contract")]
InvalidJwksSourceContract,
}
impl IssuerPolicy {
/// Validate policy fields and derive its stable [`AssertionPolicyId`].
#[allow(clippy::too_many_arguments)]
pub fn new(
issuer: String,
audiences: Vec<String>,
token_class: TokenClass,
freshness: FreshnessClass,
algorithms: Vec<Algorithm>,
skew_seconds: u64,
maximum_assertion_age_seconds: u64,
maximum_status_age_seconds: Option<u64>,
jwks_source_contract: JwksSourceContract,
) -> Result<Self, IssuerPolicyError> {
// Identity-bearing strings are validated for bounds but never mutated:
// exact `iss`/`aud`/`sub` bytes select policies and form the identity
// tuple (NIP-FI.md, "Terms and identifier classes"). The subject
// coordinate is the fixed `sub` claim, not a configurable name.
if issuer.is_empty() || issuer.len() > MAX_URI_LEN {
return Err(IssuerPolicyError::InvalidIssuer);
}
if audiences.is_empty()
|| audiences
.iter()
.any(|a| a.is_empty() || a.len() > MAX_URI_LEN)
{
return Err(IssuerPolicyError::InvalidAudiences);
}
if algorithms.is_empty() || !algorithms.iter().copied().all(is_asymmetric_algorithm) {
return Err(IssuerPolicyError::InvalidAlgorithms);
}
if skew_seconds > MAX_SKEW_SECONDS
|| maximum_assertion_age_seconds == 0
|| maximum_assertion_age_seconds > MAX_ASSERTION_AGE_SECONDS
{
return Err(IssuerPolicyError::InvalidTimeBounds);
}
// `maximum_status_age` is read only by `current-status` verification.
// Tie its applicability to the freshness class so semantically
// identical offline policies always derive one ID: `current-status`
// requires a positive finite value; `offline-jwt` must omit it. Both
// rejects fail closed at construction, keeping the canonical ID
// encoding total over valid configs (NIP-FI.md:176-181, :219-237).
match (freshness, maximum_status_age_seconds) {
(FreshnessClass::CurrentStatus, None) => {
return Err(IssuerPolicyError::MissingMaximumStatusAge);
}
(FreshnessClass::CurrentStatus, Some(0)) => {
return Err(IssuerPolicyError::InvalidTimeBounds);
}
(FreshnessClass::OfflineJwt, Some(_)) => {
return Err(IssuerPolicyError::InapplicableMaximumStatusAge);
}
(FreshnessClass::CurrentStatus, Some(_)) | (FreshnessClass::OfflineJwt, None) => {}
}
// The verifier consumes audiences and algorithms as membership sets, so
// caller order and duplicates carry no accepted-assertion semantics.
// Canonicalize before storage and ID derivation so the policy ID is
// invariant under permutation and duplication (NIP-FI.md "Policy
// identity and snapshots"). Subject-class value sets are already
// canonicalized in `SubjectClassContract::new`.
let audiences = canonical_set(audiences);
let algorithms = canonical_algorithm_set(algorithms);
let id = derive_assertion_policy_id(
&issuer,
&audiences,
&token_class,
freshness,
&algorithms,
skew_seconds,
maximum_assertion_age_seconds,
maximum_status_age_seconds,
&jwks_source_contract,
);
Ok(Self {
issuer,
audiences,
token_class,
freshness,
algorithms,
skew_seconds,
maximum_assertion_age_seconds,
maximum_status_age_seconds,
jwks_source_contract,
id,
})
}
/// The exact `iss` value this policy is selected by.
pub fn issuer(&self) -> &str {
&self.issuer
}
/// The configured audiences; at least one must match the token `aud`.
pub fn audiences(&self) -> &[String] {
&self.audiences
}
/// The single accepted token class.
pub fn token_class(&self) -> &TokenClass {
&self.token_class
}
/// The declared freshness class.
pub const fn freshness(&self) -> FreshnessClass {
self.freshness
}
/// The accepted asymmetric algorithms.
pub fn algorithms(&self) -> &[Algorithm] {
&self.algorithms
}
/// The accepted clock skew, in seconds.
pub const fn skew_seconds(&self) -> u64 {
self.skew_seconds
}
/// The maximum assertion age, in seconds.
pub const fn maximum_assertion_age_seconds(&self) -> u64 {
self.maximum_assertion_age_seconds
}
/// The maximum status age, in seconds, when `current-status` is declared.
pub const fn maximum_status_age_seconds(&self) -> Option<u64> {
self.maximum_status_age_seconds
}
/// The stable policy identity.
pub const fn id(&self) -> AssertionPolicyId {
self.id
}
/// The authenticated key-source contract for this policy's JWKS endpoint.
pub fn jwks_source_contract(&self) -> &JwksSourceContract {
&self.jwks_source_contract
}
}
/// A closed set of issuer policies keyed by exact `iss`. Selection preserves
/// every tuple component: equal `sub` under different `iss` are distinct
/// identities.
#[derive(Debug, Clone, Default)]
pub struct IssuerRegistry {
policies: BTreeMap<String, IssuerPolicy>,
}
impl IssuerRegistry {
/// An empty registry accepting no issuers.
pub fn new() -> Self {
Self::default()
}
/// Register a policy. Returns the previous policy for the same `iss`, if any.
pub fn insert(&mut self, policy: IssuerPolicy) -> Option<IssuerPolicy> {
self.policies.insert(policy.issuer.clone(), policy)
}
/// Select the policy for an exact `iss`. No prefix, suffix, or normalization
/// match is performed.
pub fn policy_for_issuer(&self, issuer: &str) -> Option<&IssuerPolicy> {
self.policies.get(issuer)
}
/// The number of registered issuers.
pub fn len(&self) -> usize {
self.policies.len()
}
/// Whether the registry is empty.
pub fn is_empty(&self) -> bool {
self.policies.is_empty()
}
/// Iteration order is deliberately unspecified; callers must not depend on
/// registration order.
pub fn all_policies(&self) -> impl Iterator<Item = &IssuerPolicy> {
self.policies.values()
}
}
/// Sort and deduplicate a set-valued list of strings into its canonical form.
/// Membership-set fields (audiences, subject-class values, compatibility claim
/// names) hash and compare identically under any caller permutation or
/// duplication once canonicalized.
fn canonical_set(mut values: Vec<String>) -> Vec<String> {
values.sort_unstable();
values.dedup();
values
}
/// Canonicalize a set-valued algorithm list, ordered by its stable wire tag so
/// the derived policy ID is invariant under permutation and duplication.
fn canonical_algorithm_set(mut algorithms: Vec<Algorithm>) -> Vec<Algorithm> {
algorithms.sort_unstable_by_key(|a| algorithm_tag(*a));
algorithms.dedup();
algorithms
}
/// Whether an algorithm is an accepted asymmetric signature algorithm.
/// `alg=none` and symmetric (HMAC) algorithms are always rejected.
pub(crate) fn is_asymmetric_algorithm(algorithm: Algorithm) -> bool {
matches!(
algorithm,
Algorithm::RS256
| Algorithm::RS384
| Algorithm::RS512
| Algorithm::PS256
| Algorithm::PS384
| Algorithm::PS512
| Algorithm::ES256
| Algorithm::ES384
| Algorithm::EdDSA
)
}
fn algorithm_tag(algorithm: Algorithm) -> &'static str {
match algorithm {
Algorithm::HS256 => "HS256",
Algorithm::HS384 => "HS384",
Algorithm::HS512 => "HS512",
Algorithm::RS256 => "RS256",
Algorithm::RS384 => "RS384",
Algorithm::RS512 => "RS512",
Algorithm::ES256 => "ES256",
Algorithm::ES384 => "ES384",
Algorithm::PS256 => "PS256",
Algorithm::PS384 => "PS384",
Algorithm::PS512 => "PS512",
Algorithm::EdDSA => "EdDSA",
}
}
#[allow(clippy::too_many_arguments)]
fn derive_assertion_policy_id(
issuer: &str,
audiences: &[String],
token_class: &TokenClass,
freshness: FreshnessClass,
algorithms: &[Algorithm],
skew_seconds: u64,
maximum_assertion_age_seconds: u64,
maximum_status_age_seconds: Option<u64>,
jwks_source_contract: &JwksSourceContract,
) -> AssertionPolicyId {
let mut hasher = Sha256::new();
hasher.update(b"buzz:nip-fi:assertion-policy:v1\0");
// Compiled-verifier-behavior fingerprint: covers duplicate-member
// rejection, exact-byte identity handling, the key-source contract, claim
// capture, and time arithmetic — the normative semantics not otherwise
// field-encoded. A change to any of them bumps VERIFIER_CONTRACT_VERSION and
// moves every policy ID.
hasher.update(VERIFIER_CONTRACT_VERSION.to_be_bytes());
// Normative size rules (NIP-FI.md "bounds the assertion, headers, claims,
// subject, key identifiers, and authenticated key set … before lookup").
for bound in [
MAX_TOKEN_BYTES,
MAX_KID_BYTES,
MAX_SUBJECT_BYTES,
MAX_CLIENT_ID_BYTES,
MAX_JWKS_KEYS,
] {
hasher.update((bound as u64).to_be_bytes());
}
hash_field(&mut hasher, issuer.as_bytes());
hash_seq(&mut hasher, audiences.iter().map(String::as_bytes));
hash_field(&mut hasher, token_class.discriminant().as_bytes());
match token_class {
TokenClass::AccessTokenAtJwt { subject_class } => {
hash_field(&mut hasher, subject_class.marker_claim().as_bytes());
hash_seq(
&mut hasher,
subject_class
.resource_owner_values()
.iter()
.map(String::as_bytes),
);
hash_seq(
&mut hasher,
subject_class
.client_subject_values()
.iter()
.map(String::as_bytes),
);
hash_field(&mut hasher, subject_class.posture().tag().as_bytes());
}
TokenClass::DedicatedNipFi => {}
}
hash_field(&mut hasher, freshness.tag().as_bytes());
hash_field(&mut hasher, SUBJECT_CLAIM.as_bytes());
hash_field(&mut hasher, NOSTR_PUBKEY_CLAIM.as_bytes());
hash_seq(
&mut hasher,
algorithms.iter().map(|a| algorithm_tag(*a).as_bytes()),
);
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());
// Authenticated key-source contract (NIP-FI.md, "Policy identity and
// snapshots"): URI selects the authenticated source; interval defines
// bounded refresh; hard deadline defines the accepted time rule. These are
// contract, not mutable state — key rotation (JWKS content change) leaves
// all three unchanged and must not move the ID.
hasher.update(b"jwks-source-contract\0");
hash_field(&mut hasher, jwks_source_contract.jwks_uri().as_bytes());
hasher.update(
jwks_source_contract
.refresh_interval_seconds()
.to_be_bytes(),
);
hasher.update(
jwks_source_contract
.key_snapshot_hard_deadline_seconds()
.to_be_bytes(),
);
AssertionPolicyId(hasher.finalize().into())
}
/// Length-prefix one field so distinct field boundaries cannot collide.
fn hash_field(hasher: &mut Sha256, bytes: &[u8]) {
hasher.update((bytes.len() as u64).to_be_bytes());
hasher.update(bytes);
}
/// Length-prefix a sequence: element count, then each length-prefixed element.
fn hash_seq<'a>(hasher: &mut Sha256, items: impl ExactSizeIterator<Item = &'a [u8]>) {
hasher.update((items.len() as u64).to_be_bytes());
for item in items {
hash_field(hasher, item);
}
}