Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2211017
feat(relay): enforce NIP-FI assertion+NIP-98 pairing on all HTTP surf…
Sep 2, 2026
afd938d
fix(nip-fi): address F1–F5 review findings and CI clippy
Sep 2, 2026
e87b737
fix(nip-fi-http): I1–I5 round-2 fixes
Sep 3, 2026
7f8ed8c
chore(nip-fi-http): rebase onto main, update NIP-FI.md line cites
Sep 3, 2026
7965052
fix(nip-fi-http): correct NIP-98 signing URLs and Off/DenyProtected t…
Sep 3, 2026
6ec5076
fix(nip-fi-http): I1 inventory coupling, I3 extractor order, I4 decla…
Sep 3, 2026
275c516
fix(nip-fi-http): T2 post-admission 400 on malformed query; T1 fail-c…
Sep 3, 2026
f45127c
Merge remote-tracking branch 'origin/main' into hayt/nip-fi-http-enfo…
Sep 3, 2026
1e35d44
fix(nip-fi-http): T1-IMP1 guard validates token transport; T1-IMP2 ex…
Sep 3, 2026
67fd0df
fix(nip-fi-http): move git-policy Postgres test to bridge postgres_te…
Sep 3, 2026
ec7414f
fix(nip-fi-http): T1-IMP1 guard performs full offline assertion verif…
Sep 3, 2026
0e4a63f
refactor(nip-fi): replace two-step pattern with NipFiAdmission closur…
Sep 3, 2026
97c679c
fix(nip-fi): resolve clippy lint failures from CI Rust Lint job
Sep 3, 2026
d4581b7
refactor(nip-fi): seal raw NIP-98 bridge verifier from protected hand…
Sep 3, 2026
3c77d51
refactor(nip-fi): seal Nip98Proof pubkey field — T1-IMP1 falsifier co…
Sep 3, 2026
d28fd59
Merge remote-tracking branch 'origin/main' into hayt/nip-fi-http-enfo…
Sep 3, 2026
6ebc426
fix(nip-fi): address Carl review round 5122159336 (all 6 findings)
Sep 21, 2026
ab48272
Merge remote-tracking branch 'origin/main' into hayt/nip-fi-http-enfo…
Sep 21, 2026
ab42efb
fix(nip-fi): resolve post-merge breakage from main's new callers
Sep 21, 2026
7bba0a1
fix(nip-fi): address Thufir recheck findings (R1-R6, MINORs)
Sep 22, 2026
7373d70
fix(nip-fi): close all pass-2 blocking findings (transport Off-mode, …
Sep 22, 2026
e7fe4ea
fix(nip-fi): address all remaining pass-3 findings before merge
Sep 22, 2026
79a8452
fix(nip-fi): address all pass-4 review findings
Sep 22, 2026
b29aa0e
style: run cargo fmt to fix 3 rustfmt sites
Sep 22, 2026
c76d83e
fix(nip-fi): address all pass-5 review findings
Sep 22, 2026
4f3bcb7
fix(nip-fi): address all pass-6 review findings
Sep 22, 2026
c073f66
fix(nip-fi): address all pass-7 review findings
Sep 22, 2026
e722454
fix(nip-fi): address all pass-8 review findings
Sep 22, 2026
09a7898
fix(nip-fi): restore legacy Git 401 header bytes in Off mode
Sep 23, 2026
9fcf609
test(nip-fi): repair media and bridge fixtures to reach real handler …
Sep 23, 2026
345d570
test(nip-fi): pin Git busy and info/refs denials, correct fixture claims
Sep 23, 2026
bab687a
Merge remote-tracking branch 'origin/main' into hayt/nip-fi-http-enfo…
Sep 23, 2026
506583b
test(nip-fi): correct stale timing and mutation-prediction comments
Sep 23, 2026
d17d66a
fix(nip-fi): return DenyProtected 503 before NIP-98 runs
Sep 23, 2026
505cbb0
Merge remote-tracking branch 'origin/main' into hayt/nip-fi-http-enfo…
Sep 23, 2026
2e74cd6
docs(nip-fi): scope admission guarantees by mode
Sep 23, 2026
68fcf56
test(relay): make NIP-FI P2 witnesses reach the behavior they claim
Sep 23, 2026
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
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion crates/buzz-auth/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ pub use nip_fi::{
IssuerJwksConfig, IssuerKeySource, IssuerPolicy, IssuerPolicyError, IssuerRegistry,
JwksFetchError, JwksFetcher, JwksSourceContract, NipFiMode, NipFiStartupError,
ProductionJwksSource, RevalidationDependencies, SubjectClass, SubjectClassContract, TokenClass,
TransportContractId, VerifiedAssertion, VerifierError, CLIENT_ATTACHED_HEADER,
TransportContractId, VerifiedAssertion, VerifierError, VerifyAssertion, CLIENT_ATTACHED_HEADER,
NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM,
};

Expand All @@ -61,6 +61,10 @@ pub use access::MockAccessChecker;
#[cfg(any(test, feature = "test-utils"))]
pub use nip98_replay::AlwaysFreshReplayGuard;
#[cfg(any(test, feature = "test-utils"))]
pub use nip_fi::jwks::ScriptedJwksFetcher;
#[cfg(any(test, feature = "test-utils"))]
pub use nip_fi::StaticIssuerKeySource;
#[cfg(any(test, feature = "test-utils"))]
pub use rate_limit::AlwaysAllowRateLimiter;

/// How the connection was authenticated.
Expand Down
152 changes: 147 additions & 5 deletions crates/buzz-auth/src/nip98.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,13 @@ pub fn verify_nip98_event(
// sign without one. Rejecting duplicates closes the real attack: a valid-first
// /contradictory-second pair would let `.find()` accept the first and silently
// ignore the second, bypassing the body-hash check.
//
// Digest format contract: the content value must be exactly 64 lowercase
// hex characters (a valid sha256 digest). A one-element `["payload"]` tag
// with no content, or a malformed digest, must be rejected — the tag
// *claims* body-hash binding but provides no valid digest. An absent tag
// makes no claim; body-bearing routes that opt out of payload binding
// (e.g. `/events`, `/query`, `/count`) fall here legitimately.
{
let count = event
.tags
Expand All @@ -161,11 +168,40 @@ pub fn verify_nip98_event(
)));
}
}
// Keep a present-but-malformed tag distinct from an absent (optional) tag.
if let (Some(payload_tag), Some(body_bytes)) = (event.tags.find(TagKind::Payload), body) {
let payload_hex = payload_tag.content().ok_or_else(|| {
AuthError::Nip98Invalid("payload tag is missing its SHA-256 hash".to_string())
})?;
// Validate the payload tag digest when the tag is present.
//
// A present tag *claims* body-hash binding. A missing hash value (one-element
// tag or empty string content) is structurally invalid — the claim is made but
// no digest is provided — and must be rejected. An absent tag makes no claim;
// body-bearing routes that opt out of payload binding (e.g. `/events`, `/query`,
// `/count`) fall here legitimately.
//
// When the tag is present, the content must be exactly 64 lowercase hex chars.
let payload_tag = if let Some(tag) = event.tags.find(TagKind::Payload) {
let hex_str = tag.content().unwrap_or("");
if hex_str.is_empty() {
return Err(AuthError::Nip98Invalid(
"payload tag is missing its SHA-256 hash".to_string(),
));
}
// Must be exactly 64 lowercase hex chars (valid sha256 digest).
// Use a static diagnostic — attacker-controlled tag content must not
// appear in error messages (length, format code, or unicode boundary
// slicing could panic or leak attacker data).
if hex_str.len() != 64
|| !hex_str.chars().all(|c| c.is_ascii_hexdigit())
|| hex_str.chars().any(|c| c.is_ascii_uppercase())
{
return Err(AuthError::Nip98Invalid(
"payload tag digest must be exactly 64 lowercase hex chars (sha256)".to_string(),
));
}
Some(hex_str)
} else {
None
};

if let (Some(payload_hex), Some(body_bytes)) = (payload_tag, body) {
let computed: [u8; 32] = Sha256::digest(body_bytes).into();
let computed_hex = hex::encode(computed);
if computed_hex != payload_hex {
Expand Down Expand Up @@ -479,6 +515,112 @@ mod tests {
);
}

// ── F1 regression: payload tag present with no or empty digest ─────────
//
// The old code did `.and_then(|t| t.content())` which returned `None` for a
// one-element `["payload"]` tag — silently skipping the body-hash check.
// A client could sign an event with `["payload"]` (no digest), present any
// body, and the verifier would not check the body against the tag.
//
// Fix: a present tag with no content (or empty string) is rejected as
// structurally invalid — the claim is made but no digest is provided.
// An absent tag makes no claim and is accepted. A present tag with content
// must be exactly 64 lowercase hex chars; invalid format rejects the event.
//
// Mutation evidence: removing the format check makes `unwrap_err()` panic.

#[test]
fn payload_tag_malformed_digest_rejected() {
// A payload tag present with a value that is NOT 64 lowercase hex chars
// must be rejected — it is a structurally invalid event.
use nostr::Tag;
let keys = Keys::generate();
let body = b"any body";

// Too short.
let json = make_nip98_event_raw_tags(
&keys,
vec![
Tag::parse(["u", TEST_URL]).unwrap(),
Tag::parse(["method", TEST_METHOD]).unwrap(),
Tag::parse(["payload", "deadbeef"]).unwrap(), // 8 chars, not 64
],
);
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, Some(body));
assert!(
matches!(result, Err(AuthError::Nip98Invalid(_))),
"payload tag with short digest must be rejected: {result:?}"
);

// Uppercase hex (structurally invalid per NIP-98 lowercase-hex contract).
let keys2 = Keys::generate();
let hash: [u8; 32] = Sha256::digest(body).into();
let upper_hex = hex::encode(hash).to_uppercase();
let json2 = make_nip98_event_raw_tags(
&keys2,
vec![
Tag::parse(["u", TEST_URL]).unwrap(),
Tag::parse(["method", TEST_METHOD]).unwrap(),
Tag::parse(["payload", &upper_hex]).unwrap(),
],
);
let result2 = verify_nip98_event(&json2, TEST_URL, TEST_METHOD, Some(body));
assert!(
matches!(result2, Err(AuthError::Nip98Invalid(_))),
"payload tag with uppercase hex must be rejected: {result2:?}"
);

// Non-hex content.
let keys3 = Keys::generate();
let non_hex = "z".repeat(64);
let json3 = make_nip98_event_raw_tags(
&keys3,
vec![
Tag::parse(["u", TEST_URL]).unwrap(),
Tag::parse(["method", TEST_METHOD]).unwrap(),
Tag::parse(["payload", &non_hex]).unwrap(),
],
);
let result3 = verify_nip98_event(&json3, TEST_URL, TEST_METHOD, Some(body));
assert!(
matches!(result3, Err(AuthError::Nip98Invalid(_))),
"payload tag with non-hex content must be rejected: {result3:?}"
);
}

#[test]
fn payload_tag_multibyte_boundary_does_not_panic() {
// Regression for R2 (Thufir round-1): the old code did
// `&hex_str[..hex_str.len().min(80)]`
// on attacker-controlled tag content. 79 ASCII 'a' chars followed by
// 'é' (U+00E9, 2 UTF-8 bytes) produces a 81-byte str; truncating at
// byte 80 splits the multibyte character and panics with a byte-index
// boundary panic.
//
// Mutation evidence: restoring the old byte-slice panic reproduces the
// panic here rather than returning an ordinary `AuthError`.
use nostr::Tag;
let keys = Keys::generate();
// 79 lowercase 'a' chars + 'é' (2 UTF-8 bytes) = 81 bytes, not 64 chars.
let malformed = format!("{}{}", "a".repeat(79), "é");
assert_eq!(malformed.len(), 81, "precondition: 81 UTF-8 bytes");
assert_eq!(malformed.chars().count(), 80, "precondition: 80 chars");
let json = make_nip98_event_raw_tags(
&keys,
vec![
Tag::parse(["u", TEST_URL]).unwrap(),
Tag::parse(["method", TEST_METHOD]).unwrap(),
Tag::parse(["payload", &malformed]).unwrap(),
],
);
// Must return an ordinary AuthError, not panic.
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
assert!(
matches!(result, Err(AuthError::Nip98Invalid(_))),
"multibyte-boundary malformed payload tag must return AuthError, not panic: {result:?}"
);
}

#[test]
fn loopback_aliases_are_distinct_hosts() {
// Under multi-tenant, the `u`-tag host is the row-zero community
Expand Down
25 changes: 25 additions & 0 deletions crates/buzz-auth/src/nip_fi/assertion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,31 @@ impl VerifiedAssertion {
pub const fn revalidation_dependencies(&self) -> &RevalidationDependencies {
&self.revalidation_dependencies
}

/// Test-only constructor: mint a minimal `VerifiedAssertion` for a given
/// `asserted_key`. All other fields are set to safe, arbitrary defaults.
///
/// Used in unit tests that need to supply a `VerifiedAssertion` with a
/// specific `asserted_key` without performing a real JWKS verification.
#[cfg(any(test, feature = "test-utils"))]
pub fn new_for_test(asserted_key: nostr::PublicKey) -> Self {
use chrono::Duration;
Self::seal(
"https://test.issuer.example".to_owned(),
"test-subject".to_owned(),
Some(asserted_key),
CanonicalCapabilities::from_pairs(vec![]),
vec![Utc::now() + Duration::seconds(3600)],
AssertionPolicyId::zero(),
TransportContractId::zero(),
RevalidationDependencies::new(
"test-kid".to_owned(),
1,
Utc::now() + Duration::seconds(3600),
"test.header.sig".to_owned(),
),
)
}
}

impl fmt::Debug for VerifiedAssertion {
Expand Down
12 changes: 12 additions & 0 deletions crates/buzz-auth/src/nip_fi/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ impl AssertionPolicyId {
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.0
}

/// All-zeros sentinel for use in tests only.
#[cfg(any(test, feature = "test-utils"))]
pub fn zero() -> Self {
Self([0u8; 32])
}
}

impl fmt::Debug for AssertionPolicyId {
Expand Down Expand Up @@ -144,6 +150,12 @@ impl TransportContractId {
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.0
}

/// All-zeros sentinel for use in tests only.
#[cfg(any(test, feature = "test-utils"))]
pub fn zero() -> Self {
Self([0u8; 32])
}
}

impl fmt::Debug for TransportContractId {
Expand Down
56 changes: 54 additions & 2 deletions crates/buzz-auth/src/nip_fi/jwks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -546,8 +546,8 @@ impl<F: JwksFetcher> ProductionJwksSource<F> {

/// **Test-only.** Construct with an injectable clock so tests can advance
/// `now` past snapshot hard deadlines without wall-clock sleep.
#[cfg(test)]
pub(crate) fn new_with_clock(
#[cfg(any(test, feature = "test-utils"))]
pub fn new_with_clock(
configs: Vec<IssuerJwksConfig>,
fetcher: F,
now_fn: Arc<dyn Fn() -> DateTime<Utc> + Send + Sync>,
Expand Down Expand Up @@ -736,3 +736,55 @@ impl<F> std::fmt::Debug for ProductionJwksSource<F> {

#[cfg(test)]
mod tests;

/// A scripted JWKS fetcher for integration tests outside this crate.
///
/// Returns pre-queued responses in FIFO order. When the queue is
/// exhausted every subsequent call returns `NetworkError`. The queued
/// values are immediate `Result<String, JwksFetchError>` — latency cannot
/// be added inside the fetcher itself. To simulate nonzero fetch latency,
/// add a `tokio::time::sleep` in the outer callback that wraps the fetcher
/// call (see `composition_nonzero_latency_and_not_due_cache_hit`).
///
/// Sealed for `JwksFetcher` so callers never need to name the sealed trait.
#[cfg(any(test, feature = "test-utils"))]
pub struct ScriptedJwksFetcher {
/// Remaining responses, front = next to return. Thread-safe.
pub responses: std::sync::Arc<
std::sync::Mutex<std::collections::VecDeque<Result<String, JwksFetchError>>>,
>,
/// Incremented on each call regardless of outcome. Thread-safe.
pub call_count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}

#[cfg(any(test, feature = "test-utils"))]
impl ScriptedJwksFetcher {
/// Create a new `ScriptedJwksFetcher` with the given queued responses (FIFO).
pub fn new(responses: impl IntoIterator<Item = Result<String, JwksFetchError>>) -> Self {
Self {
responses: std::sync::Arc::new(std::sync::Mutex::new(responses.into_iter().collect())),
call_count: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
}
}
}

#[cfg(any(test, feature = "test-utils"))]
impl super::verifier::sealed::Sealed for ScriptedJwksFetcher {}

#[cfg(any(test, feature = "test-utils"))]
impl JwksFetcher for ScriptedJwksFetcher {
fn fetch_jwks<'a>(
&'a self,
_uri: &'a str,
) -> impl std::future::Future<Output = Result<String, JwksFetchError>> + Send + 'a {
self.call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let result = self
.responses
.lock()
.unwrap()
.pop_front()
.unwrap_or(Err(JwksFetchError::NetworkError));
async move { result }
}
}
7 changes: 6 additions & 1 deletion crates/buzz-auth/src/nip_fi/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,9 @@ pub use jwks::{
ProductionJwksSource,
};
pub use startup::{validate_nip_fi_config, NipFiMode, NipFiStartupError};
pub use verifier::{AssertionKeySet, FederatedAssertionVerifier, IssuerKeySource, VerifierError};
pub use verifier::{
AssertionKeySet, FederatedAssertionVerifier, IssuerKeySource, VerifierError, VerifyAssertion,
};

#[cfg(any(test, feature = "test-utils"))]
pub use verifier::StaticIssuerKeySource;
Loading
Loading