Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 5 additions & 12 deletions crates/buzz-auth/src/nip_fi/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@ pub(crate) const MAX_JWKS_KEYS: usize = 64;
/// semantics** so prepared evidence built against an older contract is
/// invalidated. Per-policy fields (issuer, class, bounds, …) are hashed
/// separately and need no bump.
pub(crate) const VERIFIER_CONTRACT_VERSION: u32 = 1;
///
/// v2 (PR #7221): `nostr_pubkey` absence now unconditionally rejects — the
/// per-issuer `require_attested_key` knob is removed and the NIP-FI v2 spec
/// requirement is always enforced.
pub(crate) const VERIFIER_CONTRACT_VERSION: u32 = 2;

/// The transport-contract fingerprint folded into [`TransportContractId`].
/// **Bump on any change** to the client-attached parsing, attachment,
Expand Down Expand Up @@ -347,7 +351,6 @@ pub struct IssuerPolicy {
token_class: TokenClass,
freshness: FreshnessClass,
algorithms: Vec<Algorithm>,
require_attested_key: bool,
skew_seconds: u64,
maximum_assertion_age_seconds: u64,
maximum_status_age_seconds: Option<u64>,
Expand Down Expand Up @@ -404,7 +407,6 @@ impl IssuerPolicy {
token_class: TokenClass,
freshness: FreshnessClass,
algorithms: Vec<Algorithm>,
require_attested_key: bool,
skew_seconds: u64,
maximum_assertion_age_seconds: u64,
maximum_status_age_seconds: Option<u64>,
Expand Down Expand Up @@ -467,7 +469,6 @@ impl IssuerPolicy {
&token_class,
freshness,
&algorithms,
require_attested_key,
skew_seconds,
maximum_assertion_age_seconds,
maximum_status_age_seconds,
Expand All @@ -480,7 +481,6 @@ impl IssuerPolicy {
token_class,
freshness,
algorithms,
require_attested_key,
skew_seconds,
maximum_assertion_age_seconds,
maximum_status_age_seconds,
Expand Down Expand Up @@ -514,11 +514,6 @@ impl IssuerPolicy {
&self.algorithms
}

/// Whether enrollment requires a `nostr_pubkey` claim equal to the actor.
pub const fn require_attested_key(&self) -> bool {
self.require_attested_key
}

/// The accepted clock skew, in seconds.
pub const fn skew_seconds(&self) -> u64 {
self.skew_seconds
Expand Down Expand Up @@ -646,7 +641,6 @@ fn derive_assertion_policy_id(
token_class: &TokenClass,
freshness: FreshnessClass,
algorithms: &[Algorithm],
require_attested_key: bool,
skew_seconds: u64,
maximum_assertion_age_seconds: u64,
maximum_status_age_seconds: Option<u64>,
Expand Down Expand Up @@ -702,7 +696,6 @@ fn derive_assertion_policy_id(
&mut hasher,
algorithms.iter().map(|a| algorithm_tag(*a).as_bytes()),
);
hasher.update([u8::from(require_attested_key)]);
hasher.update(skew_seconds.to_be_bytes());
hasher.update(maximum_assertion_age_seconds.to_be_bytes());
hasher.update(maximum_status_age_seconds.unwrap_or(0).to_be_bytes());
Expand Down
10 changes: 6 additions & 4 deletions crates/buzz-auth/src/nip_fi/jwks/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -896,7 +896,9 @@ async fn two_issuer_keys_and_generations_are_isolated() {

fn sign(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String {
let now = chrono::Utc::now().timestamp();
// nostr_pubkey is required unconditionally by spec v2.
let claims = json!({"iss": iss, "aud": aud, "sub": "u",
"nostr_pubkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"iat": now, "exp": now + 600});
let mut hdr = Header::new(Algorithm::ES256);
hdr.kid = Some(kid.to_owned());
Expand All @@ -921,7 +923,6 @@ async fn two_issuer_keys_and_generations_are_isolated() {
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
false,
60,
3600,
None,
Expand Down Expand Up @@ -1112,7 +1113,9 @@ async fn shared_arc_source_verifier_observes_rotation() {

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

fn sign_token(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String {
let wall_now = chrono::Utc::now().timestamp();
// nostr_pubkey is required unconditionally by spec v2.
let claims = json!({"iss": iss, "aud": aud, "sub": "u",
"nostr_pubkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"iat": wall_now, "exp": wall_now + 600});
let mut hdr = Header::new(Algorithm::ES256);
hdr.kid = Some(kid.to_owned());
Expand Down Expand Up @@ -1572,7 +1575,6 @@ async fn shared_arc_source_verifier_rejects_expired_a1_accepts_a2() {
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
false,
60,
HARD_DEADLINE_SECS,
None,
Expand Down
2 changes: 0 additions & 2 deletions crates/buzz-auth/src/nip_fi/startup/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ fn make_offline_policy(issuer: &str) -> IssuerPolicy {
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![JwtAlgorithm::ES256],
false,
0,
3600,
None,
Expand All @@ -37,7 +36,6 @@ fn make_status_policy(issuer: &str) -> IssuerPolicy {
TokenClass::DedicatedNipFi,
FreshnessClass::CurrentStatus,
vec![JwtAlgorithm::ES256],
false,
0,
3600,
Some(60),
Expand Down
15 changes: 4 additions & 11 deletions crates/buzz-auth/src/nip_fi/verifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ impl<S: IssuerKeySource> FederatedAssertionVerifier<S> {
enforce_claim_semantics(policy, &claims)?;

let subject = claim_string(&claims, SUBJECT_CLAIM, MAX_SUBJECT_BYTES)?;
let asserted_key = parse_nostr_pubkey_claim(policy, &claims)?;
let asserted_key = parse_nostr_pubkey_claim(&claims)?;

let now = Utc::now();
let deadlines = self.check_time_and_deadlines(policy, &key_set, &claims, now)?;
Expand Down Expand Up @@ -720,20 +720,13 @@ fn enforce_claim_semantics(
}

/// Parse the fixed `nostr_pubkey` claim: lowercase hex of exactly one 32-byte
/// key. Bech32 and other aliases deny. Absence is permitted unless the policy
/// requires an attested key.
/// key. Bech32 and other aliases deny. Absence denies; the merged NIP-FI
/// spec v2 (PR #7214) requires the `nostr_pubkey` claim unconditionally.
fn parse_nostr_pubkey_claim(
policy: &IssuerPolicy,
claims: &Map<String, Value>,
) -> Result<Option<PublicKey>, VerifierError> {
match claims.get(NOSTR_PUBKEY_CLAIM) {
None => {
if policy.require_attested_key() {
Err(VerifierError::ClaimRejected)
} else {
Ok(None)
}
}
None => Err(VerifierError::ClaimRejected),
Some(value) => {
let raw = value.as_str().ok_or(VerifierError::ClaimRejected)?;
if raw.len() != 64
Expand Down
69 changes: 41 additions & 28 deletions crates/buzz-auth/src/nip_fi/verifier/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ const TEST_JWK_Y: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA";
const TEST_KID: &str = "test-key-1";
const ISSUER: &str = "https://issuer.example";
const AUDIENCE: &str = "https://relay.example";
/// A canonical lowercase-hex nostr pubkey for tokens that are not testing
/// the nostr_pubkey claim specifically. Spec v2 requires the claim unconditionally.
const TEST_NOSTR_PUBKEY: &str = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";

/// A canonical JWKS contract for the default test issuer. Used wherever a
/// `JwksSourceContract` is required but JWKS behavior is not under test.
Expand Down Expand Up @@ -109,7 +112,6 @@ fn access_token_policy_with(subject_class: SubjectClassContract) -> IssuerPolicy
TokenClass::AccessTokenAtJwt { subject_class },
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
false,
60,
3600,
None,
Expand All @@ -131,7 +133,6 @@ fn dedicated_policy(issuer: &str) -> IssuerPolicy {
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
false,
60,
3600,
None,
Expand All @@ -147,7 +148,6 @@ fn dedicated_policy_with_audiences(audiences: Vec<String>) -> IssuerPolicy {
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
false,
60,
3600,
None,
Expand All @@ -163,7 +163,6 @@ fn dedicated_policy_with_algorithms(algorithms: Vec<Algorithm>) -> IssuerPolicy
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
algorithms,
false,
60,
3600,
None,
Expand Down Expand Up @@ -198,6 +197,10 @@ fn mint_signed_by(pkcs8_pem: &str, typ: Option<&str>, kid: &str, mut claims: Val
obj.entry("aud").or_insert(json!(AUDIENCE));
obj.entry("iat").or_insert(json!(now()));
obj.entry("exp").or_insert(json!(now() + 600));
// Spec v2 requires nostr_pubkey unconditionally; inject a canonical
// test pubkey so tokens that test other behaviours pass the claim check.
obj.entry(NOSTR_PUBKEY_CLAIM)
.or_insert(json!(TEST_NOSTR_PUBKEY));
}
let mut header = Header::new(Algorithm::ES256);
header.kid = Some(kid.to_owned());
Expand All @@ -206,6 +209,26 @@ fn mint_signed_by(pkcs8_pem: &str, typ: Option<&str>, kid: &str, mut claims: Val
jsonwebtoken::encode(&header, &claims, &key).expect("sign")
}

/// Mint a valid, signed token that deliberately omits `nostr_pubkey`. Used
/// only to exercise the unconditional missing-claim rejection path; the normal
/// `mint`/`mint_signed_by` helpers always inject the claim via `or_insert` so
/// they cannot produce an absent-claim token.
fn mint_no_pubkey(typ: Option<&str>, kid: &str, mut claims: Value) -> String {
{
let obj = claims.as_object_mut().expect("claims object");
obj.entry("iss").or_insert(json!(ISSUER));
obj.entry("aud").or_insert(json!(AUDIENCE));
obj.entry("iat").or_insert(json!(now()));
obj.entry("exp").or_insert(json!(now() + 600));
// Intentionally does NOT inject nostr_pubkey.
}
let mut header = Header::new(Algorithm::ES256);
header.kid = Some(kid.to_owned());
header.typ = typ.map(str::to_owned);
let key = EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()).expect("valid EC PEM");
jsonwebtoken::encode(&header, &claims, &key).expect("sign")
}

/// A resource-owner `at+jwt` claim set: valid subject-class marker plus client_id.
fn resource_owner_claims() -> Value {
json!({ "sub": "user-123", "client_id": "app-1", "sub_type": "user" })
Expand Down Expand Up @@ -240,7 +263,8 @@ fn valid_access_token_verifies() {
let assertion = verifier.verify(&token).expect("verifies");
assert_eq!(assertion.identity().issuer(), ISSUER);
assert_eq!(assertion.identity().subject(), "user-123");
assert!(assertion.asserted_key().is_none());
// Spec v2: nostr_pubkey is injected by mint() and unconditionally required.
assert!(assertion.asserted_key().is_some());
assert!(!assertion.authority_deadlines().is_empty());
assert_eq!(assertion.assertion_policy_id(), access_token_policy().id());
}
Expand Down Expand Up @@ -698,22 +722,18 @@ fn uppercase_nostr_pubkey_denies() {
}

#[test]
fn missing_nostr_pubkey_denies_under_attested_key_policy() {
let policy = IssuerPolicy::new(
ISSUER.to_owned(),
vec![AUDIENCE.to_owned()],
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
true, // require attested key
60,
3600,
None,
test_jwks_contract(),
)
.unwrap();
let verifier = verifier_with(policy);
let token = mint(Some("nip-fi+jwt"), TEST_KID, json!({ "sub": "u" }));
fn absent_nostr_pubkey_claim_denies() {
// `nostr_pubkey` absence must unconditionally reject — NIP-FI v2 dropped
// the per-issuer `require_attested_key` knob that previously made it
// optional. This is a direct falsifiable regression test: removing the
// `None => Err(VerifierError::ClaimRejected)` arm from
// `parse_nostr_pubkey_claim` must turn this test red.
let verifier = verifier_with(access_token_policy());
let token = mint_no_pubkey(
Some("at+jwt"),
TEST_KID,
json!({ "sub": "u", "client_id": "a", "sub_type": "user" }),
);
assert_eq!(
verifier.verify(&token).unwrap_err(),
VerifierError::ClaimRejected
Expand Down Expand Up @@ -1105,7 +1125,6 @@ fn current_status_policy() -> IssuerPolicy {
TokenClass::DedicatedNipFi,
FreshnessClass::CurrentStatus,
vec![Algorithm::ES256],
false,
60,
3600,
Some(120), // maximum_status_age required for current-status
Expand Down Expand Up @@ -1385,7 +1404,6 @@ fn assertion_policy_id_is_deterministic_and_semantic() {
changed.token_class().clone(),
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
false,
120, // different skew => different semantics
3600,
None,
Expand All @@ -1411,7 +1429,6 @@ fn offline_policy_rejects_inapplicable_maximum_status_age() {
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
false,
60,
3600,
Some(120),
Expand All @@ -1430,7 +1447,6 @@ fn offline_policy_accepts_absent_maximum_status_age() {
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
false,
60,
3600,
None,
Expand All @@ -1449,7 +1465,6 @@ fn current_status_policy_still_requires_positive_maximum_status_age() {
TokenClass::DedicatedNipFi,
FreshnessClass::CurrentStatus,
vec![Algorithm::ES256],
false,
60,
3600,
None,
Expand All @@ -1463,7 +1478,6 @@ fn current_status_policy_still_requires_positive_maximum_status_age() {
TokenClass::DedicatedNipFi,
FreshnessClass::CurrentStatus,
vec![Algorithm::ES256],
false,
60,
3600,
Some(0),
Expand Down Expand Up @@ -1579,7 +1593,6 @@ fn policy_with_contract(contract: crate::nip_fi::jwks::JwksSourceContract) -> Is
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
false,
60,
3600,
None,
Expand Down
Loading