diff --git a/Cargo.lock b/Cargo.lock index 5e9c5aade3d..d436016fd62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,7 +117,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -128,7 +128,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -460,6 +460,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -935,8 +936,11 @@ dependencies = [ name = "buzz-auth" version = "0.1.0" dependencies = [ + "base64 0.22.1", "buzz-core", + "chrono", "hex", + "jsonwebtoken", "nostr 0.44.7", "rand 0.10.1", "serde", @@ -1717,7 +1721,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2338,7 +2342,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -2549,7 +2553,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2632,7 +2636,7 @@ dependencies = [ "digest 0.11.3", "elliptic-curve", "rfc6979", - "signature", + "signature 3.0.0", "spki", "zeroize", ] @@ -2645,7 +2649,7 @@ checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ "pkcs8", "serdect", - "signature", + "signature 3.0.0", ] [[package]] @@ -2659,7 +2663,7 @@ dependencies = [ "rand_core 0.10.1", "serde", "sha2 0.11.0", - "signature", + "signature 3.0.0", "subtle", "zeroize", ] @@ -2772,7 +2776,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3188,7 +3192,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", + "windows-link 0.2.1", "windows-result 0.4.1", ] @@ -4393,6 +4397,24 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature 2.2.0", + "simple_asn1", + "zeroize", +] + [[package]] name = "k8s-openapi" version = "0.26.1" @@ -6033,7 +6055,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7506,7 +7528,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -8047,7 +8069,7 @@ dependencies = [ "cfg-if 1.0.4", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -8177,7 +8199,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8190,7 +8212,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8249,7 +8271,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8267,7 +8289,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -8532,7 +8554,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8828,6 +8850,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" @@ -8878,6 +8909,18 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -9029,7 +9072,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9657,7 +9700,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9670,7 +9713,7 @@ dependencies = [ "parking_lot", "rustix 1.1.4", "signal-hook", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10440,7 +10483,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10564,6 +10607,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -11024,7 +11073,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 78816ff4827..17a513393ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,6 +100,9 @@ anyhow = "1" uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } +# JWT / JWS verification (NIP-FI federated identity assertions) +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 } diff --git a/Justfile b/Justfile index 6c7740bc7ac..1cd72e0336b 100644 --- a/Justfile +++ b/Justfile @@ -311,6 +311,13 @@ test-unit: ./scripts/test-ensure-local-relay-key.sh if command -v cargo-nextest &>/dev/null; then cargo nextest run -p buzz-core -p buzz-auth --lib + # buzz-auth NIP-FI verifier doctests. The sealed-authority + # `compile_fail` doctests prove the default-feature public API alone + # cannot forge the issuer→JWKS authority; nextest does not run + # doctests, hence this separate step. The verifier's regression suite + # lives in the in-crate `#[cfg(test)] mod tests`, so `--lib` above + # already runs it. + cargo test -p buzz-auth --doc cargo nextest run -p buzz-voice --lib cargo nextest run -p buzz-cli # buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra). diff --git a/crates/buzz-auth/Cargo.toml b/crates/buzz-auth/Cargo.toml index 56b8943a605..e4ac539a988 100644 --- a/crates/buzz-auth/Cargo.toml +++ b/crates/buzz-auth/Cargo.toml @@ -11,8 +11,15 @@ description = "Authentication and authorization for Buzz" test-utils = [] 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"] } + [dependencies] buzz-core = { workspace = true } +base64 = { workspace = true } +chrono = { workspace = true } +jsonwebtoken = { workspace = true } nostr = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index aed9624d9d6..582142b0272 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -25,6 +25,8 @@ pub mod nip42; pub mod nip98; /// NIP-98 replay protection — shared, community-scoped, atomic seen-set. pub mod nip98_replay; +/// NIP-FI federated-identity assertion verifier and contracts. +pub mod nip_fi; /// Per-connection rate limiting. pub mod rate_limit; /// OAuth scope parsing and enforcement. @@ -43,6 +45,14 @@ pub use rate_limit::{ }; pub use scope::{parse_scopes, Scope}; +pub use nip_fi::{ + AssertionKeySet, AssertionPolicyId, CanonicalCapabilities, ClientSubjectPosture, DenialClass, + FederatedAssertionVerifier, FederatedIdentity, FreshnessClass, IssuerKeySource, IssuerPolicy, + IssuerPolicyError, IssuerRegistry, RevalidationDependencies, SubjectClass, + SubjectClassContract, TokenClass, TransportContractId, VerifiedAssertion, VerifierError, + CLIENT_ATTACHED_HEADER, NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, +}; + #[cfg(any(test, feature = "test-utils"))] pub use access::MockAccessChecker; #[cfg(any(test, feature = "test-utils"))] diff --git a/crates/buzz-auth/src/nip_fi/assertion.rs b/crates/buzz-auth/src/nip_fi/assertion.rs new file mode 100644 index 00000000000..428c8f0aae5 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/assertion.rs @@ -0,0 +1,219 @@ +//! The closed, provider-neutral normalized result of assertion validation +//! (`FI-INV-16`, canonical verifier). +//! +//! [`VerifiedAssertion`] is an origin-sealed value: its constructor is +//! crate-private, so an unverified claim set cannot be promoted into authority. +//! Every assertion transport feeds this one contract and none can fork final +//! admission. +//! +//! Per the settled spec ([NIP-FI.md](../../../../docs/nips/NIP-FI.md), +//! "Assertion validation"), the result carries the issuer-qualified identity, +//! the optional asserted key, the canonical claims/capabilities, the non-empty +//! `authority_deadlines`, both semantic contract identities, and the +//! `revalidation_dependencies`. Request/connection binding is *not* part of this +//! value: the actor comes from fresh Nostr proof and the request is sealed +//! separately during preparation. + +use super::config::{AssertionPolicyId, TransportContractId}; +use chrono::{DateTime, Utc}; +use nostr::PublicKey; +use std::fmt; + +/// The issuer-qualified identity `(iss, sub)` returned by validation. Email, +/// display name, employee number, and a bare `sub` are not identities. Equal +/// `sub` under different `iss` are distinct identities. +#[derive(Clone, PartialEq, Eq)] +pub struct FederatedIdentity { + issuer: String, + subject: String, +} + +impl FederatedIdentity { + /// The exact issuer. + pub fn issuer(&self) -> &str { + &self.issuer + } + + /// The exact opaque subject. + pub fn subject(&self) -> &str { + &self.subject + } +} + +impl fmt::Debug for FederatedIdentity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Redacted: identity is a private per-principal fact. + f.write_str("FederatedIdentity([REDACTED])") + } +} + +/// The exact key-snapshot member of `revalidation_dependencies`: the +/// verification-key identity and the snapshot generation that authenticated the +/// assertion. A changed generation requires revalidation; a removed key denies. +#[derive(Clone, PartialEq, Eq)] +pub struct RevalidationDependencies { + verification_key_id: String, + key_snapshot_generation: u64, +} + +impl RevalidationDependencies { + /// The `kid` of the JWK that verified the signature. + pub fn verification_key_id(&self) -> &str { + &self.verification_key_id + } + + /// The generation of the key snapshot used for verification. + pub const fn key_snapshot_generation(&self) -> u64 { + self.key_snapshot_generation + } +} + +impl fmt::Debug for RevalidationDependencies { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("RevalidationDependencies([REDACTED])") + } +} + +/// The closed normalized result of a successful assertion validation. +/// +/// Origin-sealed: only [`super::verifier`] can construct one. +#[derive(Clone, PartialEq, Eq)] +pub struct VerifiedAssertion { + identity: FederatedIdentity, + asserted_key: Option, + capabilities: CanonicalCapabilities, + authority_deadlines: Vec>, + assertion_policy_id: AssertionPolicyId, + transport_contract_id: TransportContractId, + revalidation_dependencies: RevalidationDependencies, +} + +impl VerifiedAssertion { + /// Crate-private constructor invoked only by the verifier after every check + /// has passed. `authority_deadlines` must be non-empty. + #[allow(clippy::too_many_arguments)] + pub(super) fn seal( + issuer: String, + subject: String, + asserted_key: Option, + capabilities: CanonicalCapabilities, + authority_deadlines: Vec>, + assertion_policy_id: AssertionPolicyId, + transport_contract_id: TransportContractId, + revalidation_dependencies: RevalidationDependencies, + ) -> Self { + debug_assert!( + !authority_deadlines.is_empty(), + "authority_deadlines must be non-empty" + ); + Self { + identity: FederatedIdentity { issuer, subject }, + asserted_key, + capabilities, + authority_deadlines, + assertion_policy_id, + transport_contract_id, + revalidation_dependencies, + } + } + + /// The issuer-qualified identity. + pub fn identity(&self) -> &FederatedIdentity { + &self.identity + } + + /// The key the assertion attests, when present. In attested-key enrollment + /// this must equal the proven actor. + pub const fn asserted_key(&self) -> Option { + self.asserted_key + } + + /// The canonical closed claims/capabilities carried by the assertion. + pub const fn capabilities(&self) -> &CanonicalCapabilities { + &self.capabilities + } + + /// The non-empty set of authority deadlines. Every member bounds a lease. + pub fn authority_deadlines(&self) -> &[DateTime] { + &self.authority_deadlines + } + + /// The earliest offline authority deadline — the `upstream_authority_deadline` + /// for `offline-jwt`, before any status witness is applied. + pub fn upstream_authority_deadline(&self) -> DateTime { + self.authority_deadlines + .iter() + .copied() + .min() + .expect("authority_deadlines is non-empty by construction") + } + + /// The stable assertion-policy identity. + pub const fn assertion_policy_id(&self) -> AssertionPolicyId { + self.assertion_policy_id + } + + /// The stable transport-contract identity. + pub const fn transport_contract_id(&self) -> TransportContractId { + self.transport_contract_id + } + + /// The mutable dependencies that must be revalidated under current state. + pub const fn revalidation_dependencies(&self) -> &RevalidationDependencies { + &self.revalidation_dependencies + } +} + +impl fmt::Debug for VerifiedAssertion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("VerifiedAssertion([REDACTED])") + } +} + +impl RevalidationDependencies { + pub(super) fn new(verification_key_id: String, key_snapshot_generation: u64) -> Self { + Self { + verification_key_id, + key_snapshot_generation, + } + } +} + +/// A closed, deterministically encoded set of authorization claims/capabilities +/// captured from the assertion. Only claim names the policy explicitly reads +/// enter it; unchecked claims never do. The canonical encoding sorts by +/// `(name, value)` and deduplicates so equal authoritative input yields +/// byte-equal capabilities regardless of token order or repetition. +#[derive(Clone, PartialEq, Eq, Default)] +pub struct CanonicalCapabilities { + // Sorted by (key, value) and deduplicated for a deterministic canonical + // encoding. + entries: Vec<(String, String)>, +} + +impl CanonicalCapabilities { + /// Build from a set of `(claim_name, value)` pairs, canonicalized by + /// `(name, value)` order with duplicates removed. Membership-set semantics: + /// a repeated pair carries no more authority than a single occurrence. + pub(super) fn from_pairs(mut entries: Vec<(String, String)>) -> Self { + entries.sort(); + entries.dedup(); + Self { entries } + } + + /// The canonical `(name, value)` entries in sorted order. + pub fn entries(&self) -> &[(String, String)] { + &self.entries + } + + /// Whether any capability claim was captured. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +impl fmt::Debug for CanonicalCapabilities { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("CanonicalCapabilities([REDACTED])") + } +} diff --git a/crates/buzz-auth/src/nip_fi/config.rs b/crates/buzz-auth/src/nip_fi/config.rs new file mode 100644 index 00000000000..4b8647a91a0 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/config.rs @@ -0,0 +1,713 @@ +//! 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, audiences, and claim +//! names are deployment configuration, never hardcoded. +//! +//! 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; + +/// 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; + +/// 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. +pub(crate) const VERIFIER_CONTRACT_VERSION: u32 = 1; + +/// 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"; + +/// 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. Present +/// on access tokens, absent on OIDC ID tokens — the generic (non-provider) +/// marker that makes a named-compatibility policy mutually exclusive with ID +/// tokens. 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, + client_subject_values: Vec, + 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, + client_subject_values: Vec, + posture: ClientSubjectPosture, + ) -> Result { + 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 { + 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. +#[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, + /// Named compatibility access token: absent or generic protected `typ=JWT`. + /// Only admissible under a policy whose required claims include + /// [`OAUTH_CLIENT_ID_CLAIM`], which OIDC ID tokens never carry — making the + /// class mutually exclusive with every ID token regardless of `typ`. + NamedCompatibility { + /// Claims that MUST be present; their absence denies. MUST include + /// [`OAUTH_CLIENT_ID_CLAIM`]. + required_claims: Vec, + /// Claims that MUST be absent; their presence denies. Optional + /// defense-in-depth (for example `nonce`, `at_hash`, `c_hash`). + forbidden_claims: Vec, + }, +} + +impl TokenClass { + fn discriminant(&self) -> &'static str { + match self { + Self::AccessTokenAtJwt { .. } => "at+jwt", + Self::DedicatedNipFi => "nip-fi+jwt", + Self::NamedCompatibility { .. } => "named-compat", + } + } + + /// Canonicalize the set-valued fields the verifier reads as membership + /// sets. Only [`Self::NamedCompatibility`]'s required/forbidden claim lists + /// vary by caller order or duplication; the subject-class value sets are + /// already canonicalized in [`SubjectClassContract::new`]. + fn canonicalized(self) -> Self { + match self { + Self::NamedCompatibility { + required_claims, + forbidden_claims, + } => Self::NamedCompatibility { + required_claims: canonical_set(required_claims), + forbidden_claims: canonical_set(forbidden_claims), + }, + other => other, + } + } +} + +/// 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, + token_class: TokenClass, + freshness: FreshnessClass, + subject_claim: String, + algorithms: Vec, + require_attested_key: bool, + skew_seconds: u64, + maximum_assertion_age_seconds: u64, + maximum_status_age_seconds: Option, + 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, + /// A `NamedCompatibility` class did not require [`OAUTH_CLIENT_ID_CLAIM`] and + /// therefore cannot be proven mutually exclusive with OIDC ID tokens. + #[error("named compatibility policy is not exclusive")] + NonExclusiveCompatibility, + /// 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, +} + +impl IssuerPolicy { + /// Validate policy fields and derive its stable [`AssertionPolicyId`]. + #[allow(clippy::too_many_arguments)] + pub fn new( + issuer: String, + audiences: Vec, + token_class: TokenClass, + freshness: FreshnessClass, + subject_claim: String, + algorithms: Vec, + require_attested_key: bool, + skew_seconds: u64, + maximum_assertion_age_seconds: u64, + maximum_status_age_seconds: Option, + ) -> Result { + // Identity-bearing strings are validated for bounds but never mutated: + // exact `iss`/`aud`/`sub`/claim bytes select policies and form the + // identity tuple (NIP-FI.md, "Terms and identifier classes"). + 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 subject_claim.is_empty() || subject_claim.len() > MAX_CLAIM_NAME_LEN { + return Err(IssuerPolicyError::InvalidSubjectClaim); + } + 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); + } + match maximum_status_age_seconds { + Some(0) => return Err(IssuerPolicyError::InvalidTimeBounds), + None if freshness == FreshnessClass::CurrentStatus => { + return Err(IssuerPolicyError::MissingMaximumStatusAge); + } + _ => {} + } + if let TokenClass::NamedCompatibility { + required_claims, .. + } = &token_class + { + // Exclusivity with OIDC ID tokens is proven, not inferred from an + // arbitrary list: the policy MUST require the access-token-only + // `client_id` claim, which ID tokens never carry. + if !required_claims.iter().any(|c| c == OAUTH_CLIENT_ID_CLAIM) { + return Err(IssuerPolicyError::NonExclusiveCompatibility); + } + } + + // The verifier consumes audiences, algorithms, and the compatibility + // claim lists 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 token_class = token_class.canonicalized(); + + let id = derive_assertion_policy_id( + &issuer, + &audiences, + &token_class, + freshness, + &subject_claim, + &algorithms, + require_attested_key, + skew_seconds, + maximum_assertion_age_seconds, + maximum_status_age_seconds, + ); + + Ok(Self { + issuer, + audiences, + token_class, + freshness, + subject_claim, + algorithms, + require_attested_key, + skew_seconds, + maximum_assertion_age_seconds, + maximum_status_age_seconds, + 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 claim name carrying the opaque subject. + pub fn subject_claim(&self) -> &str { + &self.subject_claim + } + + /// The accepted asymmetric algorithms. + pub fn algorithms(&self) -> &[Algorithm] { + &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 + } + + /// 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 { + self.maximum_status_age_seconds + } + + /// The stable policy identity. + pub const fn id(&self) -> AssertionPolicyId { + self.id + } +} + +/// 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, +} + +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 { + 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() + } +} + +/// 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) -> Vec { + 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) -> Vec { + 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, + subject_claim: &str, + algorithms: &[Algorithm], + require_attested_key: bool, + skew_seconds: u64, + maximum_assertion_age_seconds: u64, + maximum_status_age_seconds: Option, +) -> 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 … before lookup"). + for bound in [ + MAX_TOKEN_BYTES, + MAX_KID_BYTES, + MAX_SUBJECT_BYTES, + MAX_CLIENT_ID_BYTES, + ] { + 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::NamedCompatibility { + required_claims, + forbidden_claims, + } => { + hash_seq(&mut hasher, required_claims.iter().map(String::as_bytes)); + hash_seq(&mut hasher, forbidden_claims.iter().map(String::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([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()); + 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) { + hasher.update((items.len() as u64).to_be_bytes()); + for item in items { + hash_field(hasher, item); + } +} diff --git a/crates/buzz-auth/src/nip_fi/denial.rs b/crates/buzz-auth/src/nip_fi/denial.rs new file mode 100644 index 00000000000..33f91e652a9 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/denial.rs @@ -0,0 +1,86 @@ +//! Privacy-preserving denial contract for NIP-FI (`FI-INV-13`, `FI-TRACE-DENIAL-ORACLE`). +//! +//! Public rejection is many-to-one: a fixed set of four classes, each with +//! byte-exact wire text on every surface where its condition can be decided. +//! Responses reveal no identity, key, claim, binding, tombstone, enrollment +//! mode, or private policy fact. The exact bytes are fixed by +//! [NIP-FI.md](../../../../docs/nips/NIP-FI.md) — the rejection table. +//! +//! This module owns only the closed contract. Each deciding layer maps its +//! private condition onto a [`DenialClass`] and emits these exact bytes: +//! assertion validation ([`super::verifier`]) maps every token rejection to +//! [`DenialClass::EvidenceRejected`]; the client-attached transport maps a +//! missing field to [`DenialClass::MissingEvidence`]; preparation and final +//! admission map private-state denials to [`DenialClass::AuthorizationDenied`]; +//! an unreadable authoritative dependency maps to +//! [`DenialClass::AuthorizationUnavailable`]. + +/// A public NIP-FI denial class. Many private conditions collapse to one class +/// so that a response reveals nothing about the private cause. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DenialClass { + /// No assertion or proof was supplied. HTTP `401` with a `Nostr` challenge. + MissingEvidence, + /// Supplied evidence was malformed, invalid, or expired. HTTP `403`. + EvidenceRejected, + /// A private-state denial: replayed evidence, key mismatch, attestation + /// required, binding conflict, retired pair, revoked key, lifecycle gate, + /// binding required/expired, or local policy denial. HTTP `403`. + /// + /// Every condition in this class produces byte-identical responses so that + /// resubmitting captured evidence reveals nothing about committed state. + AuthorizationDenied, + /// A required current authoritative dependency was unreadable. HTTP `503`. + /// The sole class that may depend on server state rather than supplied + /// evidence, and it reveals only unreadability, never a per-principal fact. + AuthorizationUnavailable, +} + +impl DenialClass { + /// The exact UTF-8 Nostr text carried after an applicable NIP-42/NIP-01 + /// prefix, sent when the denial is decided after a connection exists. + pub const fn nostr_text(self) -> &'static str { + match self { + Self::MissingEvidence => "auth-required: authentication required", + Self::EvidenceRejected => "restricted: evidence rejected", + Self::AuthorizationDenied => "restricted: authorization denied", + Self::AuthorizationUnavailable => "restricted: authorization unavailable", + } + } + + /// The HTTP status code sent when the denial is decided on an HTTP request + /// or a WebSocket upgrade, in place of `101`. + pub const fn http_status(self) -> u16 { + match self { + Self::MissingEvidence => 401, + Self::EvidenceRejected | Self::AuthorizationDenied => 403, + Self::AuthorizationUnavailable => 503, + } + } + + /// The exact HTTP response body: the shown UTF-8 bytes with one trailing + /// `LF` and no other bytes. + pub const fn http_body(self) -> &'static str { + match self { + Self::MissingEvidence => "authentication required\n", + Self::EvidenceRejected => "evidence rejected\n", + Self::AuthorizationDenied => "authorization denied\n", + Self::AuthorizationUnavailable => "authorization unavailable\n", + } + } + + /// The `WWW-Authenticate` challenge value, present only for + /// [`Self::MissingEvidence`]. The `Nostr` challenge satisfies RFC 9110 + /// Section 15.5.2. + pub const fn www_authenticate(self) -> Option<&'static str> { + match self { + Self::MissingEvidence => Some("Nostr"), + _ => None, + } + } + + /// The `Content-Type` header value, identical across all classes. + pub const fn content_type(self) -> &'static str { + "text/plain; charset=utf-8" + } +} diff --git a/crates/buzz-auth/src/nip_fi/mod.rs b/crates/buzz-auth/src/nip_fi/mod.rs new file mode 100644 index 00000000000..1002fde9d5f --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/mod.rs @@ -0,0 +1,39 @@ +//! NIP-FI federated-identity authorization — canonical assertion verifier and +//! contracts (Phase A, PR 1). +//! +//! This module is the closed, provider-neutral contract layer at the root of +//! the NIP-FI dependency graph. It defines: +//! +//! - 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`). +//! +//! 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, and no issuer URL, audience, or claim +//! name is hardcoded: they are deployment configuration. + +/// The exact client-attached header field ([NIP-FI.md](../../../docs/nips/NIP-FI.md), +/// "Client-attached transport"). `Authorization` remains reserved for NIP-98. +pub const CLIENT_ATTACHED_HEADER: &str = "Nostr-Federated-Identity"; + +pub mod assertion; +pub mod config; +pub mod denial; +pub mod verifier; + +pub use assertion::{ + CanonicalCapabilities, FederatedIdentity, RevalidationDependencies, VerifiedAssertion, +}; +pub use config::{ + AssertionPolicyId, ClientSubjectPosture, FreshnessClass, IssuerPolicy, IssuerPolicyError, + IssuerRegistry, SubjectClass, SubjectClassContract, TokenClass, TransportContractId, + NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, +}; +pub use denial::DenialClass; +pub use verifier::{AssertionKeySet, FederatedAssertionVerifier, IssuerKeySource, VerifierError}; diff --git a/crates/buzz-auth/src/nip_fi/verifier.rs b/crates/buzz-auth/src/nip_fi/verifier.rs new file mode 100644 index 00000000000..86e96e66ca7 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/verifier.rs @@ -0,0 +1,845 @@ +//! The single provider-neutral assertion verifier (`FI-INV-16`). +//! +//! Every accepted compact JWS feeds this one contract and produces a sealed +//! [`VerifiedAssertion`]. Multi-issuer selection happens here: the exact `iss` +//! carried by the token selects one [`IssuerPolicy`] and its key source; there +//! is no single-global-issuer assumption. Almost every failure collapses to the +//! public [`DenialClass::EvidenceRejected`] class; the exceptions are the +//! unreadable required current dependencies +//! [`VerifierError::KeySourceUnavailable`] and +//! [`VerifierError::StatusWitnessUnavailable`], which map to +//! [`DenialClass::AuthorizationUnavailable`] so a missing authoritative +//! dependency never masquerades as rejected evidence. The granular +//! [`VerifierError`] variants are for access-controlled logs and metrics only. +//! +//! Corrections applied to the mined #1476 verifier, per the settled spec: +//! +//! - **Token class + `typ` enforcement**: a policy selects exactly one class +//! before parsing claims; `at+jwt`, `nip-fi+jwt`, and named-compatibility +//! `typ` values are enforced exactly, and the long-form `application/at+jwt` +//! is rejected. +//! - **ID-token denial**: OIDC ID tokens deny even when `iss`, `aud`, `sub` +//! match, via `typ` mismatch and forbidden-claim exclusion. +//! - **Fixed `nostr_pubkey`**: accepted only as lowercase hex of exactly one +//! 32-byte key; bech32 and other aliases deny. +//! - **Spec-exact time arithmetic**: `now < exp`, `iat <= now + skew`, +//! `now < iat + maximum_assertion_age`, `nbf <= now + skew`, equality at an +//! expiry is expired. + +use super::assertion::{CanonicalCapabilities, RevalidationDependencies, VerifiedAssertion}; +use super::config::{ + is_asymmetric_algorithm, ClientSubjectPosture, FreshnessClass, IssuerPolicy, IssuerRegistry, + SubjectClass, TokenClass, TransportContractId, MAX_CLIENT_ID_BYTES, MAX_KID_BYTES, + MAX_SUBJECT_BYTES, MAX_TOKEN_BYTES, NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, +}; +use super::denial::DenialClass; +use chrono::{DateTime, TimeZone, Utc}; +use jsonwebtoken::jwk::{JwkSet, KeyAlgorithm, PublicKeyUse}; +use jsonwebtoken::{decode, jwk::Jwk, Algorithm, DecodingKey, Validation}; +use nostr::PublicKey; +use serde::de::{Deserializer, Error as _, MapAccess, Visitor}; +use serde_json::{Map, Value}; +use std::collections::BTreeSet; +use std::fmt; + +/// Sealing for [`IssuerKeySource`]: only types defined in this crate can name +/// this private supertrait, so no external `buzz_auth` consumer can implement +/// 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 { + /// Private marker preventing external implementations of the key source. + pub trait Sealed {} +} + +/// One issuer's key source: a JWKS snapshot bound to the exact `iss` it +/// authenticates, with a positive generation and an optional hard deadline +/// beyond which the snapshot can no longer authorize. +/// +/// The issuer binding is the anti-cross-issuer control (`FI-INV`): a snapshot +/// authenticates only tokens whose signed `iss` equals [`Self::issuer`]. The +/// binding is not caller-forgeable, at the request seam or the authority- +/// 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 +/// startup, not a per-request or external input. +/// +/// The crate-private constructor is a live regression: an external crate that +/// tries to build a snapshot — the pass-2 exploit's relabelling step — cannot +/// even name the constructor, so this fails to compile. +/// +/// ```compile_fail +/// use buzz_auth::AssertionKeySet; +/// let _forge = AssertionKeySet::new; +/// ``` +/// +/// [`verify`]: FederatedAssertionVerifier::verify +#[derive(Clone)] +pub struct AssertionKeySet { + issuer: String, + generation: u64, + jwks: JwkSet, + hard_deadline: Option>, +} + +impl AssertionKeySet { + /// Seal a parsed JWKS for exactly one issuer, with a positive cache + /// generation and optional deadline. A zero generation or empty issuer is + /// rejected. Crate-private: only the trusted in-crate configuration path + /// (PR 3's JWKS runtime) may bind key material to an issuer. + /// + /// 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, + jwks: JwkSet, + hard_deadline: Option>, + ) -> Option { + if generation == 0 || issuer.is_empty() { + return None; + } + Some(Self { + issuer, + generation, + jwks, + hard_deadline, + }) + } + + /// The exact `iss` this snapshot authenticates. + pub fn issuer(&self) -> &str { + &self.issuer + } + + /// The positive snapshot generation carried into `revalidation_dependencies`. + pub const fn generation(&self) -> u64 { + self.generation + } +} + +impl fmt::Debug for AssertionKeySet { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("AssertionKeySet([REDACTED])") + } +} + +/// The trusted, verifier-owned mapping from an authenticated issuer to its key +/// snapshot. This is the sole path by which key material enters verification: +/// [`FederatedAssertionVerifier::verify`] takes no snapshot from its caller and +/// 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) +/// is a trusted startup act, not per-request input. +/// +/// This trait is sealed via a private supertrait, so it cannot be implemented +/// outside `buzz_auth`. That closes the authority-construction seam: an +/// external consumer cannot supply its own source that returns issuer B's JWKS +/// labelled as issuer A, because it can neither implement this trait nor build +/// an [`AssertionKeySet`]. The accepted issuer→JWKS authority is entirely +/// crate-owned. +/// +/// The seal is a live regression: an external crate that tries to implement +/// this trait fails to compile because the private supertrait cannot be named. +/// +/// ```compile_fail +/// use buzz_auth::{AssertionKeySet, IssuerKeySource}; +/// struct Forge; +/// impl IssuerKeySource for Forge { +/// fn key_set(&self, _issuer: &str) -> Option { None } +/// } +/// ``` +pub trait IssuerKeySource: sealed::Sealed { + /// The current key snapshot bound to this exact issuer, or `None` when the + /// issuer has no available snapshot. Implementations MUST return only a + /// snapshot whose [`AssertionKeySet::issuer`] equals `issuer`. + fn key_set(&self, issuer: &str) -> Option; +} + +/// 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 +/// 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 +/// guarantees. +#[cfg(test)] +#[derive(Clone, Default)] +pub(crate) struct StaticIssuerKeySource { + snapshots: std::collections::HashMap, + /// When set, returned for every requested issuer regardless of its binding, + /// to exercise the verifier's defensive issuer re-check. + misbound: Option, +} + +#[cfg(test)] +impl StaticIssuerKeySource { + /// Build an honest source from a set of snapshots, keyed by each snapshot's + /// issuer. + pub(crate) fn new(snapshots: impl IntoIterator) -> Self { + Self { + snapshots: snapshots + .into_iter() + .map(|s| (s.issuer().to_owned(), s)) + .collect(), + misbound: None, + } + } + + /// A hostile/buggy source that returns the given snapshot — bound to a + /// different issuer than requested — for every lookup, to exercise the + /// verifier's defensive issuer re-check. + pub(crate) fn misbinding(snapshot: AssertionKeySet) -> Self { + Self { + snapshots: std::collections::HashMap::new(), + misbound: Some(snapshot), + } + } +} + +#[cfg(test)] +impl sealed::Sealed for StaticIssuerKeySource {} + +#[cfg(test)] +impl IssuerKeySource for StaticIssuerKeySource { + fn key_set(&self, issuer: &str) -> Option { + self.misbound + .clone() + .or_else(|| self.snapshots.get(issuer).cloned()) + } +} + +/// The provider-neutral assertion verifier over a closed multi-issuer registry +/// and a trusted [`IssuerKeySource`]. +#[derive(Debug, Clone)] +pub struct FederatedAssertionVerifier { + registry: IssuerRegistry, + key_source: S, + transport_contract_id: TransportContractId, +} + +impl FederatedAssertionVerifier { + /// Construct a verifier over a registry of issuer policies and the trusted + /// key source that serves each issuer's snapshot. + pub fn new(registry: IssuerRegistry, key_source: S) -> Self { + Self { + registry, + key_source, + transport_contract_id: TransportContractId::core_client_attached(), + } + } + + /// The registry this verifier selects policies from. + pub const fn registry(&self) -> &IssuerRegistry { + &self.registry + } + + /// Verify one compact JWS and mint a sealed [`VerifiedAssertion`]. + /// + /// The caller supplies only the token. The key snapshot is resolved + /// internally from the trusted [`IssuerKeySource`] by the token's + /// signature-authenticated `iss`, so no caller can inject or relabel key + /// material for another issuer. + pub fn verify(&self, token: &str) -> Result { + if token.is_empty() || token.len() > MAX_TOKEN_BYTES { + return Err(VerifierError::MalformedToken); + } + + // Parse the JOSE header without trusting it. Reject duplicate members, + // `alg=none`, symmetric algorithms, any critical header, and a + // missing/oversized `kid` before touching claims. + let header = parse_header(token)?; + let signed_issuer = self.unverified_issuer(token)?; + let policy = self + .registry + .policy_for_issuer(&signed_issuer) + .ok_or(VerifierError::UnknownIssuer)?; + + // Resolve the key snapshot internally from the trusted source, keyed by + // the policy's exact `iss`. The snapshot is never a caller argument, so + // issuer B's keys cannot be relabelled as issuer A at the request seam. + let key_set = self + .key_source + .key_set(policy.issuer()) + .ok_or(VerifierError::KeySourceUnavailable)?; + // Defensive invariant: a correct source binds the snapshot to the exact + // issuer requested. A source that violates this contract cannot cross + // issuers. + if key_set.issuer() != policy.issuer() { + return Err(VerifierError::IssuerKeyMismatch); + } + + // A `current-status` policy requires a runtime status witness this + // verifier does not gather (delivered by a later PR). Deny rather than + // seal an assertion documented as verified without its mandatory + // dependency. PR 3 adds the witness path additively. + if policy.freshness() == FreshnessClass::CurrentStatus { + return Err(VerifierError::StatusWitnessUnavailable); + } + + if !policy.algorithms().contains(&header.algorithm) { + return Err(VerifierError::UnsupportedAlgorithm); + } + enforce_token_type(policy.token_class(), header.typ.as_deref())?; + + // Select exactly one matching key by `kid`. + let jwk = select_unique_jwk(&key_set.jwks, &header.kid)?; + validate_jwk(jwk, header.algorithm)?; + let key = DecodingKey::from_jwk(jwk).map_err(|_| VerifierError::InvalidKey)?; + + // Verify signature, `iss`, and `aud`. jsonwebtoken deserializes claims + // with last-wins duplicate handling, so its map is used only for the + // signature/iss/aud gate; every value the result depends on is read + // from `claims` below, our duplicate-rejecting parse of the same + // signature-authenticated payload bytes. A duplicate member fails that + // parse, so the two parses can never disagree on an accepted token. + let mut validation = Validation::new(header.algorithm); + validation.set_issuer(&[policy.issuer()]); + validation.set_audience(policy.audiences()); + validation.set_required_spec_claims(&["exp", "iat", "iss", "aud"]); + validation.validate_exp = false; + validation.validate_nbf = false; + decode::>(token, &key, &validation) + .map_err(|_| VerifierError::InvalidSignatureOrClaims)?; + let claims = parse_unique_claims(token)?; + + enforce_claim_semantics(policy, &claims)?; + + let subject = claim_string(&claims, policy.subject_claim(), MAX_SUBJECT_BYTES)?; + let asserted_key = parse_nostr_pubkey_claim(policy, &claims)?; + + let now = Utc::now(); + let deadlines = self.check_time_and_deadlines(policy, &key_set, &claims, now)?; + let capabilities = capture_capabilities(policy, &claims); + + Ok(VerifiedAssertion::seal( + policy.issuer().to_owned(), + subject, + asserted_key, + capabilities, + deadlines, + policy.id(), + self.transport_contract_id, + RevalidationDependencies::new(header.kid, key_set.generation()), + )) + } + + fn unverified_issuer(&self, token: &str) -> Result { + let claims = parse_unique_claims(token)?; + claim_string(&claims, "iss", MAX_SUBJECT_BYTES).map_err(|_| VerifierError::MalformedToken) + } + + fn check_time_and_deadlines( + &self, + policy: &IssuerPolicy, + key_set: &AssertionKeySet, + claims: &Map, + now: DateTime, + ) -> Result>, VerifierError> { + let iat = numeric_date(claims, "iat")?; + let exp = numeric_date(claims, "exp")?; + let skew = seconds(policy.skew_seconds()); + let max_age = seconds(policy.maximum_assertion_age_seconds()); + + // now < exp (equality is expired). + if now >= exp { + return Err(VerifierError::Expired); + } + // iat <= now + skew. + if iat > checked_add(now, skew)? { + return Err(VerifierError::NotYetValid); + } + // now < iat + maximum_assertion_age. + if now >= checked_add(iat, max_age)? { + return Err(VerifierError::Expired); + } + // Optional nbf <= now + skew. + if let Some(nbf) = optional_numeric_date(claims, "nbf")? { + if nbf > checked_add(now, skew)? { + return Err(VerifierError::NotYetValid); + } + } + + // offline authority deadline = min(exp, iat + max_age, key hard deadline). + let mut deadlines = vec![exp, checked_add(iat, max_age)?]; + if let Some(hard) = key_set.hard_deadline { + if now >= hard { + return Err(VerifierError::Expired); + } + deadlines.push(hard); + } + // `current-status` adds a runtime status deadline in a later PR; the + // offline deadlines computed here always bound it. + debug_assert!(matches!( + policy.freshness(), + FreshnessClass::OfflineJwt | FreshnessClass::CurrentStatus + )); + Ok(deadlines) + } +} + +/// A closed, stable verifier failure carrying no credential material. Almost +/// every variant maps to the public [`DenialClass::EvidenceRejected`] class; +/// [`Self::KeySourceUnavailable`] and [`Self::StatusWitnessUnavailable`] map to +/// [`DenialClass::AuthorizationUnavailable`] instead (see [`Self::denial_class`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum VerifierError { + /// The compact JWS was empty, oversized, or structurally malformed. + #[error("malformed token")] + MalformedToken, + /// A protected-header or claim member appeared more than once. Ambiguous + /// duplicate members are rejected before any value is trusted. + #[error("duplicate member")] + DuplicateMember, + /// No policy is registered for the token's issuer. + #[error("unknown issuer")] + UnknownIssuer, + /// The supplied key snapshot authenticates a different issuer than the + /// token's signed `iss`. Defensive: the trusted [`IssuerKeySource`] is + /// contracted to return only issuer-bound snapshots, so a correct source + /// never triggers this. + #[error("issuer/key mismatch")] + IssuerKeyMismatch, + /// The token's issuer is registered, but the trusted key source has no + /// available snapshot for it (for example, a JWKS refresh has not yet + /// succeeded). An unreadable authoritative dependency, not rejected + /// evidence: the token may be perfectly valid. + #[error("key source unavailable")] + KeySourceUnavailable, + /// The policy declares `current-status` freshness, whose runtime status + /// witness this verifier does not yet gather. Verification defers to the + /// status-bearing runtime rather than sealing without the witness. + #[error("status witness unavailable")] + StatusWitnessUnavailable, + /// The header algorithm is `none`, symmetric, or outside the policy set. + #[error("unsupported algorithm")] + UnsupportedAlgorithm, + /// The header carried a critical extension this verifier does not support. + #[error("unsupported critical header")] + UnsupportedCriticalHeader, + /// The header omitted its bounded `kid`. + #[error("missing key id")] + MissingKeyId, + /// No key, or more than one key, matched the header `kid`. + #[error("ambiguous or unknown key id")] + AmbiguousKeyId, + /// The selected JWK was not admissible for signature verification. + #[error("invalid key")] + InvalidKey, + /// The `typ` header did not match the policy's token class. + #[error("token type rejected")] + TokenTypeRejected, + /// A required or forbidden claim rule for the token class failed, including + /// resource-owner/client-subject ambiguity. + #[error("claim contract rejected")] + ClaimContractRejected, + /// A required provider-free claim was missing or malformed, including a + /// `nostr_pubkey` that was not lowercase-hex of one 32-byte key. + #[error("claim rejected")] + ClaimRejected, + /// The signature, issuer, or audience did not validate. + #[error("signature or claims rejected")] + InvalidSignatureOrClaims, + /// The assertion was expired or beyond its maximum age or key deadline. + #[error("expired")] + Expired, + /// The assertion was not yet valid under `iat`/`nbf` and skew. + #[error("not yet valid")] + NotYetValid, + /// A time claim was missing, non-integer, or arithmetically out of range. + #[error("invalid time bounds")] + InvalidTimeBounds, +} + +impl VerifierError { + /// The public denial class. Almost every verifier failure is evidence + /// rejection (malformed, invalid, or expired evidence). The exceptions are + /// the two unreadable required current dependencies — + /// [`Self::KeySourceUnavailable`] (no verification-key snapshot) and + /// [`Self::StatusWitnessUnavailable`] (no current-status witness) — which + /// map to [`DenialClass::AuthorizationUnavailable`] (503) so that a missing + /// authoritative dependency never masquerades as rejected evidence + /// (NIP-FI.md, rejection table). + pub const fn denial_class(self) -> DenialClass { + match self { + Self::KeySourceUnavailable | Self::StatusWitnessUnavailable => { + DenialClass::AuthorizationUnavailable + } + _ => DenialClass::EvidenceRejected, + } + } + + /// A unique stable machine code, safe for access-controlled logs. + pub const fn code(self) -> &'static str { + match self { + Self::MalformedToken => "nip_fi_malformed_token", + Self::DuplicateMember => "nip_fi_duplicate_member", + Self::UnknownIssuer => "nip_fi_unknown_issuer", + Self::IssuerKeyMismatch => "nip_fi_issuer_key_mismatch", + Self::KeySourceUnavailable => "nip_fi_key_source_unavailable", + Self::StatusWitnessUnavailable => "nip_fi_status_witness_unavailable", + Self::UnsupportedAlgorithm => "nip_fi_unsupported_algorithm", + Self::UnsupportedCriticalHeader => "nip_fi_unsupported_critical_header", + Self::MissingKeyId => "nip_fi_missing_key_id", + Self::AmbiguousKeyId => "nip_fi_ambiguous_key_id", + Self::InvalidKey => "nip_fi_invalid_key", + Self::TokenTypeRejected => "nip_fi_token_type_rejected", + Self::ClaimContractRejected => "nip_fi_claim_contract_rejected", + Self::ClaimRejected => "nip_fi_claim_rejected", + Self::InvalidSignatureOrClaims => "nip_fi_invalid_signature_or_claims", + Self::Expired => "nip_fi_expired", + Self::NotYetValid => "nip_fi_not_yet_valid", + Self::InvalidTimeBounds => "nip_fi_invalid_time_bounds", + } + } +} + +/// A minimally parsed JOSE header. +struct ParsedHeader { + algorithm: Algorithm, + kid: String, + typ: Option, +} + +fn parse_header(token: &str) -> Result { + let segment = token + .split('.') + .next() + .filter(|s| !s.is_empty()) + .ok_or(VerifierError::MalformedToken)?; + let bytes = base64url_decode(segment)?; + let header = parse_unique_object(&bytes)?; + + // Any critical extension is unknown to this verifier and denies. + if header.contains_key("crit") { + return Err(VerifierError::UnsupportedCriticalHeader); + } + + let alg = header + .get("alg") + .and_then(Value::as_str) + .ok_or(VerifierError::MalformedToken)?; + let algorithm = parse_algorithm(alg)?; + if !is_asymmetric_algorithm(algorithm) { + return Err(VerifierError::UnsupportedAlgorithm); + } + + let kid = header + .get("kid") + .and_then(Value::as_str) + .filter(|k| !k.is_empty() && k.len() <= MAX_KID_BYTES) + .ok_or(VerifierError::MissingKeyId)? + .to_owned(); + + let typ = match header.get("typ") { + None => None, + Some(Value::String(s)) => Some(s.clone()), + // A present but non-string `typ` is malformed. + Some(_) => return Err(VerifierError::MalformedToken), + }; + + Ok(ParsedHeader { + algorithm, + kid, + typ, + }) +} + +fn parse_algorithm(alg: &str) -> Result { + match alg { + "RS256" => Ok(Algorithm::RS256), + "RS384" => Ok(Algorithm::RS384), + "RS512" => Ok(Algorithm::RS512), + "PS256" => Ok(Algorithm::PS256), + "PS384" => Ok(Algorithm::PS384), + "PS512" => Ok(Algorithm::PS512), + "ES256" => Ok(Algorithm::ES256), + "ES384" => Ok(Algorithm::ES384), + "EdDSA" => Ok(Algorithm::EdDSA), + // `none` and symmetric HMAC algorithms are rejected as unsupported. + "none" | "HS256" | "HS384" | "HS512" => Err(VerifierError::UnsupportedAlgorithm), + _ => Err(VerifierError::UnsupportedAlgorithm), + } +} + +/// Enforce the policy's single token class against the header `typ`. +fn enforce_token_type(class: &TokenClass, typ: Option<&str>) -> Result<(), VerifierError> { + match class { + TokenClass::AccessTokenAtJwt { .. } => match typ { + Some("at+jwt") => Ok(()), + _ => Err(VerifierError::TokenTypeRejected), + }, + TokenClass::DedicatedNipFi => match typ { + Some("nip-fi+jwt") => Ok(()), + _ => Err(VerifierError::TokenTypeRejected), + }, + TokenClass::NamedCompatibility { .. } => match typ { + None | Some("JWT") => Ok(()), + _ => Err(VerifierError::TokenTypeRejected), + }, + } +} + +/// Enforce class-specific claim rules: `at+jwt` `client_id` presence and +/// resource-owner/client-subject classification via the issuer's +/// [`SubjectClassContract`], and named-compatibility required/forbidden claims +/// (which exclude OIDC ID tokens). +fn enforce_claim_semantics( + policy: &IssuerPolicy, + claims: &Map, +) -> Result<(), VerifierError> { + match policy.token_class() { + TokenClass::AccessTokenAtJwt { subject_class } => { + // One non-empty bounded `client_id` is mandatory (exact bytes, no + // canonicalization). + claims + .get(OAUTH_CLIENT_ID_CLAIM) + .and_then(Value::as_str) + .filter(|c| !c.is_empty() && c.len() <= MAX_CLIENT_ID_BYTES) + .ok_or(VerifierError::ClaimContractRejected)?; + // Classify the subject from the authenticated marker claim. A value + // matching neither set (or the claim absent) is ambiguous and + // denies; a client-subject token denies unless the issuer recorded + // the non-collision guarantee. + let marker = claims + .get(subject_class.marker_claim()) + .and_then(Value::as_str); + match subject_class.classify(marker) { + Some(SubjectClass::ResourceOwner) => Ok(()), + Some(SubjectClass::ClientSubject) => match subject_class.posture() { + ClientSubjectPosture::AcceptNonColliding => Ok(()), + ClientSubjectPosture::Reject => Err(VerifierError::ClaimContractRejected), + }, + None => Err(VerifierError::ClaimContractRejected), + } + } + TokenClass::DedicatedNipFi => Ok(()), + TokenClass::NamedCompatibility { + required_claims, + forbidden_claims, + } => { + if required_claims.iter().any(|c| !claims.contains_key(c)) + || forbidden_claims.iter().any(|c| claims.contains_key(c)) + { + return Err(VerifierError::ClaimContractRejected); + } + Ok(()) + } + } +} + +/// 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. +fn parse_nostr_pubkey_claim( + policy: &IssuerPolicy, + claims: &Map, +) -> Result, VerifierError> { + match claims.get(NOSTR_PUBKEY_CLAIM) { + None => { + if policy.require_attested_key() { + Err(VerifierError::ClaimRejected) + } else { + Ok(None) + } + } + Some(value) => { + let raw = value.as_str().ok_or(VerifierError::ClaimRejected)?; + if raw.len() != 64 + || !raw + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(VerifierError::ClaimRejected); + } + let key = PublicKey::from_hex(raw).map_err(|_| VerifierError::ClaimRejected)?; + Ok(Some(key)) + } + } +} + +/// 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 +/// never enter the result. +fn capture_capabilities( + _policy: &IssuerPolicy, + claims: &Map, +) -> CanonicalCapabilities { + let mut entries = Vec::new(); + if let Some(scope) = claims.get("scope").and_then(Value::as_str) { + for token in scope.split(' ').filter(|s| !s.is_empty()) { + entries.push(("scope".to_owned(), token.to_owned())); + } + } + CanonicalCapabilities::from_pairs(entries) +} + +fn select_unique_jwk<'a>(jwks: &'a JwkSet, kid: &str) -> Result<&'a Jwk, VerifierError> { + let mut matching = jwks + .keys + .iter() + .filter(|jwk| jwk.common.key_id.as_deref() == Some(kid)); + let jwk = matching.next().ok_or(VerifierError::AmbiguousKeyId)?; + if matching.next().is_some() { + return Err(VerifierError::AmbiguousKeyId); + } + Ok(jwk) +} + +fn validate_jwk(jwk: &Jwk, token_algorithm: Algorithm) -> Result<(), VerifierError> { + let usage_ok = jwk + .common + .public_key_use + .as_ref() + .is_none_or(|use_| use_ == &PublicKeyUse::Signature); + let algorithm_ok = jwk + .common + .key_algorithm + .is_none_or(|alg| jwk_algorithm_matches(alg, token_algorithm)); + if usage_ok && algorithm_ok { + Ok(()) + } else { + Err(VerifierError::InvalidKey) + } +} + +fn jwk_algorithm_matches(key: KeyAlgorithm, token: Algorithm) -> bool { + matches!( + (key, token), + (KeyAlgorithm::RS256, Algorithm::RS256) + | (KeyAlgorithm::RS384, Algorithm::RS384) + | (KeyAlgorithm::RS512, Algorithm::RS512) + | (KeyAlgorithm::PS256, Algorithm::PS256) + | (KeyAlgorithm::PS384, Algorithm::PS384) + | (KeyAlgorithm::PS512, Algorithm::PS512) + | (KeyAlgorithm::ES256, Algorithm::ES256) + | (KeyAlgorithm::ES384, Algorithm::ES384) + | (KeyAlgorithm::EdDSA, Algorithm::EdDSA) + ) +} + +fn claim_string( + claims: &Map, + claim: &str, + max_len: usize, +) -> Result { + // Exact bytes: no trimming or canonicalization. `iss`/`sub` are identity + // components; distinct byte strings must stay distinct. + claims + .get(claim) + .and_then(Value::as_str) + .filter(|v| !v.is_empty() && v.len() <= max_len) + .map(str::to_owned) + .ok_or(VerifierError::ClaimRejected) +} + +fn numeric_date(claims: &Map, claim: &str) -> Result, VerifierError> { + let secs = claims + .get(claim) + .and_then(Value::as_i64) + .ok_or(VerifierError::InvalidTimeBounds)?; + Utc.timestamp_opt(secs, 0) + .single() + .ok_or(VerifierError::InvalidTimeBounds) +} + +fn optional_numeric_date( + claims: &Map, + claim: &str, +) -> Result>, VerifierError> { + match claims.get(claim) { + None => Ok(None), + Some(value) => { + let secs = value.as_i64().ok_or(VerifierError::InvalidTimeBounds)?; + Utc.timestamp_opt(secs, 0) + .single() + .map(Some) + .ok_or(VerifierError::InvalidTimeBounds) + } + } +} + +fn seconds(value: u64) -> chrono::Duration { + chrono::Duration::seconds(value as i64) +} + +fn checked_add(at: DateTime, delta: chrono::Duration) -> Result, VerifierError> { + at.checked_add_signed(delta) + .ok_or(VerifierError::InvalidTimeBounds) +} + +/// Parse the claims segment as a JSON object, rejecting any duplicate member. +fn parse_unique_claims(token: &str) -> Result, VerifierError> { + let segment = token + .split('.') + .nth(1) + .filter(|s| !s.is_empty()) + .ok_or(VerifierError::MalformedToken)?; + let bytes = base64url_decode(segment)?; + parse_unique_object(&bytes) +} + +/// Deserialize a JSON object, denying a repeated key. `serde_json`'s default +/// `Map` deserialization is last-wins, which would let a duplicate `alg`, +/// `typ`, `iss`, `sub`, or time member be interpreted differently than a +/// verifier that reads the first occurrence — a parser-differential ambiguity +/// (NIP-FI.md, "rejects ambiguous protected-header or claim members"). This +/// visitor rejects the second occurrence outright. +fn parse_unique_object(bytes: &[u8]) -> Result, VerifierError> { + struct UniqueObject; + + impl<'de> Visitor<'de> for UniqueObject { + type Value = Map; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a JSON object with unique member names") + } + + fn visit_map>(self, mut access: A) -> Result { + let mut map = Map::new(); + let mut seen = BTreeSet::new(); + while let Some(key) = access.next_key::()? { + if !seen.insert(key.clone()) { + return Err(A::Error::custom("duplicate member")); + } + let value = access.next_value::()?; + map.insert(key, value); + } + Ok(map) + } + } + + let mut de = serde_json::Deserializer::from_slice(bytes); + let map = de + .deserialize_map(UniqueObject) + .map_err(|e| classify_json_error(&e))?; + // Reject trailing bytes after the object (a second concatenated document). + de.end().map_err(|_| VerifierError::MalformedToken)?; + Ok(map) +} + +/// A duplicate-member custom error maps to [`VerifierError::DuplicateMember`]; +/// every other parse failure is a malformed token. +fn classify_json_error(error: &serde_json::Error) -> VerifierError { + if error.to_string().contains("duplicate member") { + VerifierError::DuplicateMember + } else { + VerifierError::MalformedToken + } +} + +fn base64url_decode(segment: &str) -> Result, VerifierError> { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(segment) + .map_err(|_| VerifierError::MalformedToken) +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-auth/src/nip_fi/verifier/tests.rs b/crates/buzz-auth/src/nip_fi/verifier/tests.rs new file mode 100644 index 00000000000..09ab2431f8d --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/verifier/tests.rs @@ -0,0 +1,1022 @@ +//! Behavior tests for the NIP-FI canonical assertion verifier and contracts +//! (PR 1). Exercises the exact-wire-text denial contract, deterministic +//! contract IDs, token-class enforcement including ID-token denial, and +//! multi-issuer `(iss, sub)` selection, against real ES256-signed assertions. +//! +//! In-crate unit tests: the crate-owned [`StaticIssuerKeySource`] and the +//! crate-private `AssertionKeySet::new` constructor are the only way to supply +//! key material to the verifier, and both are `cfg(test)`-only — reachable +//! here because this module compiles inside `buzz_auth` under `cargo test`, but +//! not exposed to any dependent crate under any Cargo feature. That keeps the +//! issuer→JWKS authority entirely crate-owned. + +use super::*; +use crate::nip_fi::{IssuerPolicyError, SubjectClassContract, CLIENT_ATTACHED_HEADER}; +use jsonwebtoken::jwk::JwkSet; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; +use serde_json::{json, Value}; + +// A fixed P-256 test key (PKCS#8 PEM) and its public JWK coordinates. +const TEST_EC_PKCS8_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ +WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ +zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ +-----END PRIVATE KEY-----\n"; +const TEST_JWK_X: &str = "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI"; +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 second, independent P-256 key: issuer B's real signing key, used to prove +// that a token signed by B and claiming `iss=A` cannot mint an A identity. +const TEST_EC_PKCS8_PEM_B: &str = "-----BEGIN PRIVATE KEY-----\n\ +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgKcmDf3+zDWyC96/X\n\ +Gv8aYK552uF5aE6nXKzxAfl4fSWhRANCAATf0ccbp1c4mMd6WvSuliv5ZAS8iIWL\n\ +Ne2tqOfFa0hRpa41DANab1/EuDGi7PtIo8xSYwkaoib1MAJlfLvRMjQA\n\ +-----END PRIVATE KEY-----\n"; +const TEST_JWK_X_B: &str = "39HHG6dXOJjHelr0rpYr-WQEvIiFizXtrajnxWtIUaU"; +const TEST_JWK_Y_B: &str = "rjUMA1pvX8S4MaLs-0ijzFJjCRqiJvUwAmV8u9EyNAA"; + +// A trusted [`StaticIssuerKeySource`] is used throughout, standing in for +// PR 3's JWKS runtime. Because the key-source trait is sealed, an external +// crate cannot implement its own source at all — the authority-construction +// seam is closed, and the only way to exercise the verifier is this +// crate-owned source. It returns only a snapshot bound to the exact issuer +// requested — the invariant the real source guarantees. +fn test_jwks(kid: &str) -> JwkSet { + jwks_with_coords(kid, TEST_JWK_X, TEST_JWK_Y) +} + +fn jwks_with_coords(kid: &str, x: &str, y: &str) -> JwkSet { + serde_json::from_value(json!({ + "keys": [{ + "kty": "EC", + "crv": "P-256", + "use": "sig", + "alg": "ES256", + "kid": kid, + "x": x, + "y": y, + }] + })) + .expect("valid JWKS") +} + +fn key_set_for(issuer: &str) -> AssertionKeySet { + AssertionKeySet::new(issuer.to_owned(), 1, test_jwks(TEST_KID), None) + .expect("nonzero generation, non-empty issuer") +} + +/// A resource-owner/client-subject contract that rejects client-subject tokens. +/// Resource-owner and client-subject subjects are distinguished by a `sub_type` +/// marker claim with disjoint value sets. +fn subject_class_reject() -> SubjectClassContract { + SubjectClassContract::new( + "sub_type".to_owned(), + vec!["user".to_owned()], + vec!["client".to_owned()], + ClientSubjectPosture::Reject, + ) + .expect("valid subject-class contract") +} + +fn access_token_policy() -> IssuerPolicy { + access_token_policy_with(subject_class_reject()) +} + +fn access_token_policy_with(subject_class: SubjectClassContract) -> IssuerPolicy { + IssuerPolicy::new( + ISSUER.to_owned(), + vec![AUDIENCE.to_owned()], + TokenClass::AccessTokenAtJwt { subject_class }, + FreshnessClass::OfflineJwt, + "sub".to_owned(), + vec![Algorithm::ES256], + false, + 60, + 3600, + None, + ) + .expect("valid policy") +} + +fn dedicated_policy(issuer: &str) -> IssuerPolicy { + IssuerPolicy::new( + issuer.to_owned(), + vec![AUDIENCE.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + "sub".to_owned(), + vec![Algorithm::ES256], + false, + 60, + 3600, + None, + ) + .expect("valid policy") +} + +fn dedicated_policy_with_audiences(audiences: Vec) -> IssuerPolicy { + IssuerPolicy::new( + ISSUER.to_owned(), + audiences, + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + "sub".to_owned(), + vec![Algorithm::ES256], + false, + 60, + 3600, + None, + ) + .expect("valid policy") +} + +fn dedicated_policy_with_algorithms(algorithms: Vec) -> IssuerPolicy { + IssuerPolicy::new( + ISSUER.to_owned(), + vec![AUDIENCE.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + "sub".to_owned(), + algorithms, + false, + 60, + 3600, + None, + ) + .expect("valid policy") +} + +fn named_compat_policy_with( + required_claims: Vec, + forbidden_claims: Vec, +) -> IssuerPolicy { + IssuerPolicy::new( + ISSUER.to_owned(), + vec![AUDIENCE.to_owned()], + TokenClass::NamedCompatibility { + required_claims, + forbidden_claims, + }, + FreshnessClass::OfflineJwt, + "sub".to_owned(), + vec![Algorithm::ES256], + false, + 60, + 3600, + None, + ) + .expect("valid policy") +} + +fn verifier_with(policy: IssuerPolicy) -> FederatedAssertionVerifier { + let mut registry = IssuerRegistry::new(); + let issuer = policy.issuer().to_owned(); + registry.insert(policy); + FederatedAssertionVerifier::new(registry, StaticIssuerKeySource::new([key_set_for(&issuer)])) +} + +fn now() -> i64 { + chrono::Utc::now().timestamp() +} + +/// Mint a signed ES256 assertion with the given `typ`, `kid`, and claims, +/// signed by the default (issuer A) key. +/// Fills in default `iss`/`aud`/`iat`/`exp` if absent. +fn mint(typ: Option<&str>, kid: &str, claims: Value) -> String { + mint_signed_by(TEST_EC_PKCS8_PEM, typ, kid, claims) +} + +/// Mint a signed ES256 assertion with an explicit signing key (PKCS#8 PEM). +fn mint_signed_by(pkcs8_pem: &str, 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)); + } + 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(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" }) +} + +/// Base64url-encode a JSON string into a JWS segment. +fn b64_segment(json_text: &str) -> String { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json_text.as_bytes()) +} + +// ---- Happy path ---------------------------------------------------------- + +#[test] +fn valid_access_token_verifies() { + let verifier = verifier_with(access_token_policy()); + let token = mint(Some("at+jwt"), TEST_KID, resource_owner_claims()); + 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()); + assert!(!assertion.authority_deadlines().is_empty()); + assert_eq!(assertion.assertion_policy_id(), access_token_policy().id()); +} + +// ---- Token class / typ enforcement, ID-token denial ---------------------- + +#[test] +fn id_token_denies_even_when_iss_aud_sub_match() { + let verifier = verifier_with(access_token_policy()); + let token = mint( + Some("JWT"), + TEST_KID, + json!({ "sub": "user-123", "client_id": "app-1", "sub_type": "user", "nonce": "n" }), + ); + let err = verifier.verify(&token).unwrap_err(); + assert_eq!(err, VerifierError::TokenTypeRejected); + assert_eq!(err.denial_class(), DenialClass::EvidenceRejected); +} + +// ---- Named-compatibility exclusivity vs OIDC ID tokens ------------------- + +fn named_compat_policy() -> IssuerPolicy { + IssuerPolicy::new( + ISSUER.to_owned(), + vec![AUDIENCE.to_owned()], + TokenClass::NamedCompatibility { + // Requiring the access-token-only `client_id` claim is what proves + // exclusivity with every OIDC ID token. + required_claims: vec![OAUTH_CLIENT_ID_CLAIM.to_owned()], + forbidden_claims: vec!["nonce".to_owned()], + }, + FreshnessClass::OfflineJwt, + "sub".to_owned(), + vec![Algorithm::ES256], + false, + 60, + 3600, + None, + ) + .expect("valid named-compat policy") +} + +#[test] +fn named_compat_policy_requires_client_id_claim() { + // A named-compat policy that does not require `client_id` cannot be proven + // mutually exclusive with ID tokens, so construction is rejected. + let err = IssuerPolicy::new( + ISSUER.to_owned(), + vec![AUDIENCE.to_owned()], + TokenClass::NamedCompatibility { + required_claims: vec!["scope".to_owned()], + forbidden_claims: vec!["nonce".to_owned()], + }, + FreshnessClass::OfflineJwt, + "sub".to_owned(), + vec![Algorithm::ES256], + false, + 60, + 3600, + None, + ) + .unwrap_err(); + assert_eq!(err, IssuerPolicyError::NonExclusiveCompatibility); +} + +#[test] +fn named_compat_accepts_access_token_with_generic_typ() { + let verifier = verifier_with(named_compat_policy()); + // Generic `typ=JWT` access token carrying `client_id`. + let token = mint( + Some("JWT"), + TEST_KID, + json!({ "sub": "user-123", "client_id": "app-1" }), + ); + assert!(verifier.verify(&token).is_ok()); +} + +#[test] +fn named_compat_denies_generic_oidc_id_token() { + let verifier = verifier_with(named_compat_policy()); + // A realistic OIDC ID token: generic `typ`, matching iss/aud/sub, no + // `client_id`. It fails the required-claim rule. + let token = mint( + None, + TEST_KID, + json!({ "sub": "user-123", "nonce": "abc", "at_hash": "xyz" }), + ); + assert_eq!( + verifier.verify(&token).unwrap_err(), + VerifierError::ClaimContractRejected + ); +} + +#[test] +fn dedicated_class_rejects_at_jwt_typ_and_accepts_nip_fi() { + let verifier = verifier_with(dedicated_policy(ISSUER)); + let wrong = mint(Some("at+jwt"), TEST_KID, json!({ "sub": "u" })); + assert_eq!( + verifier.verify(&wrong).unwrap_err(), + VerifierError::TokenTypeRejected + ); + let ok = mint(Some("nip-fi+jwt"), TEST_KID, json!({ "sub": "u" })); + assert!(verifier.verify(&ok).is_ok()); +} + +#[test] +fn access_token_without_client_id_denies() { + let verifier = verifier_with(access_token_policy()); + let token = mint( + Some("at+jwt"), + TEST_KID, + json!({ "sub": "user-123", "sub_type": "user" }), + ); + assert_eq!( + verifier.verify(&token).unwrap_err(), + VerifierError::ClaimContractRejected + ); +} + +// ---- Resource-owner / client-subject classification ---------------------- + +#[test] +fn resource_owner_marker_verifies() { + let verifier = verifier_with(access_token_policy()); + let token = mint( + Some("at+jwt"), + TEST_KID, + json!({ "sub": "user-123", "client_id": "app-1", "sub_type": "user" }), + ); + assert!(verifier.verify(&token).is_ok()); +} + +#[test] +fn client_subject_marker_denies_under_reject_posture() { + let verifier = verifier_with(access_token_policy()); + let token = mint( + Some("at+jwt"), + TEST_KID, + json!({ "sub": "svc-1", "client_id": "app-1", "sub_type": "client" }), + ); + assert_eq!( + verifier.verify(&token).unwrap_err(), + VerifierError::ClaimContractRejected + ); +} + +#[test] +fn client_subject_marker_verifies_under_accept_non_colliding_posture() { + let contract = SubjectClassContract::new( + "sub_type".to_owned(), + vec!["user".to_owned()], + vec!["client".to_owned()], + ClientSubjectPosture::AcceptNonColliding, + ) + .unwrap(); + let verifier = verifier_with(access_token_policy_with(contract)); + let token = mint( + Some("at+jwt"), + TEST_KID, + json!({ "sub": "svc-1", "client_id": "app-1", "sub_type": "client" }), + ); + assert!(verifier.verify(&token).is_ok()); +} + +#[test] +fn unclassifiable_subject_marker_denies() { + // A marker value in neither set cannot be classified as resource-owner or + // client-subject, so the token is ambiguous and denies. + let verifier = verifier_with(access_token_policy()); + let token = mint( + Some("at+jwt"), + TEST_KID, + json!({ "sub": "user-123", "client_id": "app-1", "sub_type": "mystery" }), + ); + assert_eq!( + verifier.verify(&token).unwrap_err(), + VerifierError::ClaimContractRejected + ); +} + +#[test] +fn subject_class_contract_rejects_overlapping_value_sets() { + let err = SubjectClassContract::new( + "sub_type".to_owned(), + vec!["user".to_owned(), "shared".to_owned()], + vec!["shared".to_owned()], + ClientSubjectPosture::Reject, + ) + .unwrap_err(); + assert_eq!(err, IssuerPolicyError::NonExclusiveSubjectClass); +} + +// ---- Algorithm / key rejection ------------------------------------------- + +#[test] +fn hs256_symmetric_algorithm_denies() { + use base64::Engine; + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header = b64.encode(json!({"alg":"HS256","kid":TEST_KID,"typ":"at+jwt"}).to_string()); + let payload = b64.encode( + json!({"iss":ISSUER,"aud":AUDIENCE,"sub":"u","client_id":"a","iat":now(),"exp":now()+600}) + .to_string(), + ); + let token = format!("{header}.{payload}.AAAA"); + let verifier = verifier_with(access_token_policy()); + assert_eq!( + verifier.verify(&token).unwrap_err(), + VerifierError::UnsupportedAlgorithm + ); +} + +#[test] +fn alg_none_denies() { + use base64::Engine; + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header = b64.encode(json!({"alg":"none","kid":TEST_KID,"typ":"at+jwt"}).to_string()); + let payload = b64.encode(json!({"iss":ISSUER,"aud":AUDIENCE,"sub":"u"}).to_string()); + let token = format!("{header}.{payload}."); + let verifier = verifier_with(access_token_policy()); + assert_eq!( + verifier.verify(&token).unwrap_err(), + VerifierError::UnsupportedAlgorithm + ); +} + +#[test] +fn unknown_kid_denies() { + let verifier = verifier_with(access_token_policy()); + let token = mint( + Some("at+jwt"), + "other-kid", + json!({ "sub": "u", "client_id": "a" }), + ); + assert_eq!( + verifier.verify(&token).unwrap_err(), + VerifierError::AmbiguousKeyId + ); +} + +#[test] +fn tampered_signature_denies() { + let verifier = verifier_with(access_token_policy()); + let mut token = mint( + Some("at+jwt"), + TEST_KID, + json!({ "sub": "u", "client_id": "a" }), + ); + // Flip the last signature character. + let last = token.pop().unwrap(); + token.push(if last == 'A' { 'B' } else { 'A' }); + assert_eq!( + verifier.verify(&token).unwrap_err(), + VerifierError::InvalidSignatureOrClaims + ); +} + +#[test] +fn wrong_audience_denies() { + let verifier = verifier_with(access_token_policy()); + let token = mint( + Some("at+jwt"), + TEST_KID, + json!({ "sub": "u", "client_id": "a", "aud": "https://other.example" }), + ); + assert_eq!( + verifier.verify(&token).unwrap_err(), + VerifierError::InvalidSignatureOrClaims + ); +} + +// ---- nostr_pubkey handling ----------------------------------------------- + +#[test] +fn lowercase_hex_nostr_pubkey_is_accepted() { + let verifier = verifier_with(access_token_policy()); + let real = nostr::Keys::generate().public_key().to_hex(); + let token = mint( + Some("at+jwt"), + TEST_KID, + json!({ "sub": "u", "client_id": "a", "sub_type": "user", NOSTR_PUBKEY_CLAIM: real }), + ); + let assertion = verifier.verify(&token).expect("verifies"); + assert!(assertion.asserted_key().is_some()); +} + +#[test] +fn uppercase_nostr_pubkey_denies() { + let verifier = verifier_with(access_token_policy()); + let upper = nostr::Keys::generate().public_key().to_hex().to_uppercase(); + let token = mint( + Some("at+jwt"), + TEST_KID, + json!({ "sub": "u", "client_id": "a", "sub_type": "user", NOSTR_PUBKEY_CLAIM: upper }), + ); + assert_eq!( + verifier.verify(&token).unwrap_err(), + VerifierError::ClaimRejected + ); +} + +#[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, + "sub".to_owned(), + vec![Algorithm::ES256], + true, // require attested key + 60, + 3600, + None, + ) + .unwrap(); + let verifier = verifier_with(policy); + let token = mint(Some("nip-fi+jwt"), TEST_KID, json!({ "sub": "u" })); + assert_eq!( + verifier.verify(&token).unwrap_err(), + VerifierError::ClaimRejected + ); +} + +// ---- Time bounds ---------------------------------------------------------- + +#[test] +fn expired_assertion_denies() { + let verifier = verifier_with(access_token_policy()); + let token = mint( + Some("at+jwt"), + TEST_KID, + json!({ "sub": "u", "client_id": "a", "sub_type": "user", "iat": now() - 1200, "exp": now() - 600 }), + ); + assert_eq!(verifier.verify(&token).unwrap_err(), VerifierError::Expired); +} + +#[test] +fn assertion_beyond_maximum_age_denies() { + let verifier = verifier_with(access_token_policy()); + let token = mint( + Some("at+jwt"), + TEST_KID, + json!({ "sub": "u", "client_id": "a", "sub_type": "user", "iat": now() - 4000, "exp": now() + 600 }), + ); + assert_eq!(verifier.verify(&token).unwrap_err(), VerifierError::Expired); +} + +// ---- Multi-issuer selection ---------------------------------------------- + +#[test] +fn unknown_issuer_denies() { + let verifier = verifier_with(access_token_policy()); + let token = mint( + Some("at+jwt"), + TEST_KID, + json!({ "sub": "u", "client_id": "a", "iss": "https://evil.example" }), + ); + assert_eq!( + verifier.verify(&token).unwrap_err(), + VerifierError::UnknownIssuer + ); +} + +#[test] +fn same_subject_distinct_issuers_are_distinct_identities() { + let issuer_a = "https://a.example"; + let issuer_b = "https://b.example"; + let policy_a = dedicated_policy(issuer_a); + let policy_b = dedicated_policy(issuer_b); + assert_ne!(policy_a.id(), policy_b.id()); + + let mut registry = IssuerRegistry::new(); + registry.insert(policy_a); + registry.insert(policy_b); + // Both issuers share the same test signing key here; the source binds a + // snapshot to each issuer and the verifier selects by authenticated `iss`. + let verifier = FederatedAssertionVerifier::new( + registry, + StaticIssuerKeySource::new([key_set_for(issuer_a), key_set_for(issuer_b)]), + ); + + let sign = |iss: &str| { + let claims = json!({ "sub": "shared-sub", "iss": iss }); + mint(Some("nip-fi+jwt"), TEST_KID, claims) + }; + let a = verifier.verify(&sign(issuer_a)).expect("a verifies"); + let b = verifier.verify(&sign(issuer_b)).expect("b verifies"); + assert_eq!(a.identity().subject(), b.identity().subject()); + assert_ne!(a.identity().issuer(), b.identity().issuer()); + assert_ne!(a.assertion_policy_id(), b.assertion_policy_id()); +} + +// ---- Cross-issuer key-source confusion (CRITICAL #1) --------------------- + +#[test] +fn cross_issuer_token_cannot_mint_through_any_seam() { + // The structural regression for the key-source-confusion bypass. Two seams + // are covered: + // + // 1. Request seam: issuer B signs a token with its own real key while the + // signed claim says `iss=A`. `verify` takes only the token and resolves + // the snapshot from the trusted source keyed by the authenticated `iss`, + // so B's keys can never authenticate a token claiming issuer A. + // + // 2. Authority-construction seam: an external `buzz_auth` consumer cannot + // even build the relabelling authority. `AssertionKeySet` has no public + // constructor and `IssuerKeySource` is sealed, so external code can + // neither put B's JWKS into a snapshot labelled A nor supply its own + // source that does. The exploit that minted sealed `(A, victim)` at the + // public verifier constructor no longer type-checks — see the two + // `compile_fail` doctests on `AssertionKeySet` (`verifier.rs:70-73`) and + // `IssuerKeySource` (`verifier.rs:142-148`). + let issuer_a = "https://a.example"; + let issuer_b = "https://b.example"; + + // Each issuer's source snapshot carries only its own real public key. Even + // here — inside the crate, using the test-only constructor — the snapshot's + // issuer label is bound to the JWKS it actually authenticates. + let key_a = key_set_for(issuer_a); + let key_b = AssertionKeySet::new( + issuer_b.to_owned(), + 1, + jwks_with_coords(TEST_KID, TEST_JWK_X_B, TEST_JWK_Y_B), + None, + ) + .unwrap(); + + let mut registry = IssuerRegistry::new(); + registry.insert(dedicated_policy(issuer_a)); + registry.insert(dedicated_policy(issuer_b)); + let verifier = + FederatedAssertionVerifier::new(registry, StaticIssuerKeySource::new([key_a, key_b])); + + // Token signed by B's key, claiming `iss=A`. The verifier selects issuer + // A's policy and issuer A's snapshot; B's signature fails against A's key. + let forged = mint_signed_by( + TEST_EC_PKCS8_PEM_B, + Some("nip-fi+jwt"), + TEST_KID, + json!({ "iss": issuer_a, "sub": "victim" }), + ); + assert_eq!( + verifier.verify(&forged).unwrap_err(), + VerifierError::InvalidSignatureOrClaims, + "B-signed token claiming iss=A must not mint an A identity" + ); + + // Sanity: each issuer's own honestly-signed token verifies under its bound + // snapshot, so the deny above is the forgery, not a broken key source. + let honest_a = mint_signed_by( + TEST_EC_PKCS8_PEM, + Some("nip-fi+jwt"), + TEST_KID, + json!({ "iss": issuer_a, "sub": "u" }), + ); + let honest_b = mint_signed_by( + TEST_EC_PKCS8_PEM_B, + Some("nip-fi+jwt"), + TEST_KID, + json!({ "iss": issuer_b, "sub": "u" }), + ); + assert_eq!( + verifier.verify(&honest_a).unwrap().identity().issuer(), + issuer_a + ); + assert_eq!( + verifier.verify(&honest_b).unwrap().identity().issuer(), + issuer_b + ); +} + +#[test] +fn registered_issuer_without_key_snapshot_is_unavailable_not_rejected() { + // A registered issuer whose trusted source has no snapshot is an + // unreadable authoritative dependency, not rejected evidence: the token + // may be valid. It maps to AuthorizationUnavailable (503), never + // EvidenceRejected, so a JWKS gap can't masquerade as a bad token. + let registry = { + let mut r = IssuerRegistry::new(); + r.insert(dedicated_policy(ISSUER)); + r + }; + // Empty key source: the issuer is registered but has no snapshot. + let verifier = FederatedAssertionVerifier::new(registry, StaticIssuerKeySource::new([])); + let token = mint(Some("nip-fi+jwt"), TEST_KID, json!({ "sub": "u" })); + let err = verifier.verify(&token).unwrap_err(); + assert_eq!(err, VerifierError::KeySourceUnavailable); + assert_eq!(err.denial_class(), DenialClass::AuthorizationUnavailable); +} + +#[test] +fn misbinding_key_source_is_rejected_by_defensive_check() { + // Defense-in-depth: even the crate-owned source, if it returned a snapshot + // labelled for a different issuer than requested, must not authenticate. + // The verifier re-checks the returned snapshot's issuer against the + // selected policy and denies on mismatch, so a source contract violation + // cannot cross issuers even though the honest source never triggers this. + let mut registry = IssuerRegistry::new(); + registry.insert(dedicated_policy(ISSUER)); + let verifier = FederatedAssertionVerifier::new( + registry, + StaticIssuerKeySource::misbinding(key_set_for("https://other.example")), + ); + let token = mint(Some("nip-fi+jwt"), TEST_KID, json!({ "sub": "u" })); + assert_eq!( + verifier.verify(&token).unwrap_err(), + VerifierError::IssuerKeyMismatch + ); +} + +// ---- Duplicate-member rejection (IMPORTANT #2) --------------------------- +// +// Duplicate members are rejected while parsing the protected header and the +// claims segment — both before signature verification — so these tokens carry +// a dummy signature; the parse denies first. + +#[test] +fn duplicate_claim_member_denies() { + let verifier = verifier_with(access_token_policy()); + // Duplicate `sub`: last-wins parsing would silently pick "attacker". + let claims = format!( + r#"{{"iss":"{ISSUER}","aud":"{AUDIENCE}","iat":{iat},"exp":{exp},"client_id":"a","sub_type":"user","sub":"victim","sub":"attacker"}}"#, + iat = now(), + exp = now() + 600, + ); + let header = r#"{"alg":"ES256","kid":"test-key-1","typ":"at+jwt"}"#; + let token = format!("{}.{}.AAAA", b64_segment(header), b64_segment(&claims)); + assert_eq!( + verifier.verify(&token).unwrap_err(), + VerifierError::DuplicateMember + ); +} + +#[test] +fn duplicate_header_member_denies() { + let verifier = verifier_with(access_token_policy()); + // Duplicate `alg` in the protected header; last-wins would read "none". + let header = r#"{"alg":"ES256","alg":"none","kid":"test-key-1","typ":"at+jwt"}"#; + let claims = format!( + r#"{{"iss":"{ISSUER}","aud":"{AUDIENCE}","iat":{iat},"exp":{exp},"client_id":"a","sub":"u","sub_type":"user"}}"#, + iat = now(), + exp = now() + 600, + ); + let token = format!("{}.{}.AAAA", b64_segment(header), b64_segment(&claims)); + assert_eq!( + verifier.verify(&token).unwrap_err(), + VerifierError::DuplicateMember + ); +} + +// ---- CurrentStatus deferral (IMPORTANT #7) ------------------------------- + +#[test] +fn current_status_policy_denies_without_witness() { + let policy = IssuerPolicy::new( + ISSUER.to_owned(), + vec![AUDIENCE.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::CurrentStatus, + "sub".to_owned(), + vec![Algorithm::ES256], + false, + 60, + 3600, + Some(120), // maximum_status_age required for current-status + ) + .expect("valid current-status policy"); + let verifier = verifier_with(policy); + let token = mint(Some("nip-fi+jwt"), TEST_KID, json!({ "sub": "u" })); + let err = verifier.verify(&token).unwrap_err(); + assert_eq!(err, VerifierError::StatusWitnessUnavailable); + // An unreadable required current dependency is authorization-unavailable + // (503), never rejected evidence (403): the token may be perfectly valid. + assert_eq!(err.denial_class(), DenialClass::AuthorizationUnavailable); + assert_eq!(err.denial_class().http_status(), 503); +} + +#[test] +fn subject_bytes_are_preserved_exactly_not_trimmed() { + // A subject with surrounding whitespace must survive verbatim: trimming + // would collapse distinct byte strings into one identity. + let verifier = verifier_with(access_token_policy()); + let token = mint( + Some("at+jwt"), + TEST_KID, + json!({ "sub": " user-123 ", "client_id": "app-1", "sub_type": "user" }), + ); + let assertion = verifier.verify(&token).expect("verifies"); + assert_eq!(assertion.identity().subject(), " user-123 "); +} + +// ---- Deterministic contract IDs ------------------------------------------ + +#[test] +fn assertion_policy_id_is_deterministic_and_semantic() { + let p1 = access_token_policy(); + let p2 = access_token_policy(); + assert_eq!(p1.id(), p2.id(), "same contract => same id"); + + let changed = access_token_policy_with(subject_class_reject()); + let changed = IssuerPolicy::new( + ISSUER.to_owned(), + vec![AUDIENCE.to_owned()], + changed.token_class().clone(), + FreshnessClass::OfflineJwt, + "sub".to_owned(), + vec![Algorithm::ES256], + false, + 120, // different skew => different semantics + 3600, + None, + ) + .unwrap(); + assert_ne!(p1.id(), changed.id()); +} + +#[test] +fn assertion_policy_id_moves_with_subject_class_contract() { + // The subject-class contract is a normative input to the policy ID. + let base = access_token_policy_with(subject_class_reject()); + let different_values = access_token_policy_with( + SubjectClassContract::new( + "sub_type".to_owned(), + vec!["human".to_owned()], // different resource-owner value set + vec!["client".to_owned()], + ClientSubjectPosture::Reject, + ) + .unwrap(), + ); + let different_posture = access_token_policy_with( + SubjectClassContract::new( + "sub_type".to_owned(), + vec!["user".to_owned()], + vec!["client".to_owned()], + ClientSubjectPosture::AcceptNonColliding, // different posture + ) + .unwrap(), + ); + assert_ne!(base.id(), different_values.id()); + assert_ne!(base.id(), different_posture.id()); +} + +#[test] +fn assertion_policy_id_is_invariant_under_audience_permutation_and_duplicates() { + // Audiences are consumed as a membership set, so caller order and + // duplicates carry no semantics and must not move the policy ID. + let base = dedicated_policy_with_audiences(vec![ + "https://a.example".to_owned(), + "https://b.example".to_owned(), + ]); + let permuted = dedicated_policy_with_audiences(vec![ + "https://b.example".to_owned(), + "https://a.example".to_owned(), + ]); + let duplicated = dedicated_policy_with_audiences(vec![ + "https://b.example".to_owned(), + "https://a.example".to_owned(), + "https://a.example".to_owned(), + ]); + assert_eq!(base.id(), permuted.id()); + assert_eq!(base.id(), duplicated.id()); + // A different audience set still moves the ID. + let different = dedicated_policy_with_audiences(vec!["https://a.example".to_owned()]); + assert_ne!(base.id(), different.id()); +} + +#[test] +fn assertion_policy_id_is_invariant_under_algorithm_permutation_and_duplicates() { + let base = dedicated_policy_with_algorithms(vec![Algorithm::ES256, Algorithm::RS256]); + let permuted = dedicated_policy_with_algorithms(vec![Algorithm::RS256, Algorithm::ES256]); + let duplicated = dedicated_policy_with_algorithms(vec![ + Algorithm::RS256, + Algorithm::ES256, + Algorithm::RS256, + ]); + assert_eq!(base.id(), permuted.id()); + assert_eq!(base.id(), duplicated.id()); + let different = dedicated_policy_with_algorithms(vec![Algorithm::ES256]); + assert_ne!(base.id(), different.id()); +} + +#[test] +fn assertion_policy_id_is_invariant_under_subject_class_value_permutation_and_duplicates() { + let base = access_token_policy_with( + SubjectClassContract::new( + "sub_type".to_owned(), + vec!["user".to_owned(), "owner".to_owned()], + vec!["client".to_owned()], + ClientSubjectPosture::Reject, + ) + .unwrap(), + ); + let permuted = access_token_policy_with( + SubjectClassContract::new( + "sub_type".to_owned(), + vec!["owner".to_owned(), "user".to_owned(), "user".to_owned()], + vec!["client".to_owned()], + ClientSubjectPosture::Reject, + ) + .unwrap(), + ); + assert_eq!(base.id(), permuted.id()); +} + +#[test] +fn assertion_policy_id_is_invariant_under_compat_claim_permutation_and_duplicates() { + let base = named_compat_policy_with( + vec!["client_id".to_owned(), "scope".to_owned()], + vec!["nonce".to_owned(), "at_hash".to_owned()], + ); + let permuted = named_compat_policy_with( + vec![ + "scope".to_owned(), + "client_id".to_owned(), + "client_id".to_owned(), + ], + vec!["at_hash".to_owned(), "nonce".to_owned()], + ); + assert_eq!(base.id(), permuted.id()); + let different = named_compat_policy_with( + vec!["client_id".to_owned()], + vec!["nonce".to_owned(), "at_hash".to_owned()], + ); + assert_ne!(base.id(), different.id()); +} + +// ---- Canonical scope capture --------------------------------------------- + +#[test] +fn scope_capture_is_canonical_under_order_and_duplicates() { + // The `scope` claim is a space-delimited set: equivalent scope sets must + // seal byte-equal capabilities regardless of token order or repetition. + let verifier = verifier_with(dedicated_policy(ISSUER)); + let a = verifier + .verify(&mint( + Some("nip-fi+jwt"), + TEST_KID, + json!({ "sub": "u", "scope": "read write admin" }), + )) + .expect("verifies"); + let b = verifier + .verify(&mint( + Some("nip-fi+jwt"), + TEST_KID, + json!({ "sub": "u", "scope": "admin write read write" }), + )) + .expect("verifies"); + assert_eq!(a.capabilities().entries(), b.capabilities().entries()); + assert_eq!( + a.capabilities().entries(), + &[ + ("scope".to_owned(), "admin".to_owned()), + ("scope".to_owned(), "read".to_owned()), + ("scope".to_owned(), "write".to_owned()), + ] + ); +} + +#[test] +fn transport_contract_id_is_stable() { + assert_eq!( + TransportContractId::core_client_attached(), + TransportContractId::core_client_attached() + ); + assert_eq!(CLIENT_ATTACHED_HEADER, "Nostr-Federated-Identity"); +} + +// ---- Exact-wire-text denial contract (all four classes) ------------------ + +#[test] +fn denial_classes_carry_exact_wire_text() { + let m = DenialClass::MissingEvidence; + assert_eq!(m.nostr_text(), "auth-required: authentication required"); + assert_eq!(m.http_status(), 401); + assert_eq!(m.http_body(), "authentication required\n"); + assert_eq!(m.www_authenticate(), Some("Nostr")); + assert_eq!(m.content_type(), "text/plain; charset=utf-8"); + + let e = DenialClass::EvidenceRejected; + assert_eq!(e.nostr_text(), "restricted: evidence rejected"); + assert_eq!(e.http_status(), 403); + assert_eq!(e.http_body(), "evidence rejected\n"); + assert_eq!(e.www_authenticate(), None); + + let d = DenialClass::AuthorizationDenied; + assert_eq!(d.nostr_text(), "restricted: authorization denied"); + assert_eq!(d.http_status(), 403); + assert_eq!(d.http_body(), "authorization denied\n"); + + let u = DenialClass::AuthorizationUnavailable; + assert_eq!(u.nostr_text(), "restricted: authorization unavailable"); + assert_eq!(u.http_status(), 503); + assert_eq!(u.http_body(), "authorization unavailable\n"); +}