Skip to content

Commit ec7414f

Browse files
Haytwpfleger96
andcommitted
fix(nip-fi-http): T1-IMP1 guard performs full offline assertion verification
The guard now verifies JWT signature, issuer, expiry, and claims before forwarding — not just token transport shape. A forgotten-gate handler (one that omits check_nip_fi_http_on_state) can only be reached with a cryptographically verified assertion; it still lacks the key-pairing and deny-map step, which per-handler calls provide on top. Changes: - nip_fi_assertion_guard: after extract_bearer_token (step 1), call verifier.verify_assertion(token) (step 2). No verifier (startup race) → 503; invalid sig/claims → 403 EvidenceRejected. - buzz_auth: add VerifyAssertion trait (object-safe wrapper over FederatedAssertionVerifier<S>) so AppState.nip_fi_verifier uses dyn VerifyAssertion rather than a concrete ProductionJwksSource type. - buzz_auth/test-utils: expose StaticIssuerKeySource and AssertionKeySet::new_for_test so integration tests in buzz-relay can build verifiers without a live JWKS endpoint. - check_nip_fi_http: updated to accept dyn VerifyAssertion (removes the S: IssuerKeySource generic, aligns with dyn dispatch at the AppState boundary). - Production-router forgotten-gate test: sends a structurally valid but cryptographically invalid assertion (bad sig) + no NIP-98 header to POST /events in Enforce mode with a real StaticIssuerKeySource verifier. Asserts 403 (guard denies bad sig before handler fires). Falsifying mutation: remove verifier.verify_assertion from the guard → guard forwards → handler NIP-98 check fires → 401 ≠ 403 → test fails. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
1 parent 67fd0df commit ec7414f

8 files changed

Lines changed: 384 additions & 77 deletions

File tree

crates/buzz-auth/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ pub use nip_fi::{
5252
IssuerJwksConfig, IssuerKeySource, IssuerPolicy, IssuerPolicyError, IssuerRegistry,
5353
JwksFetchError, JwksFetcher, JwksSourceContract, NipFiMode, NipFiStartupError,
5454
ProductionJwksSource, RevalidationDependencies, SubjectClass, SubjectClassContract, TokenClass,
55-
TransportContractId, VerifiedAssertion, VerifierError, CLIENT_ATTACHED_HEADER,
55+
TransportContractId, VerifiedAssertion, VerifierError, VerifyAssertion, CLIENT_ATTACHED_HEADER,
5656
NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM,
5757
};
5858

@@ -61,6 +61,8 @@ pub use access::MockAccessChecker;
6161
#[cfg(any(test, feature = "test-utils"))]
6262
pub use nip98_replay::AlwaysFreshReplayGuard;
6363
#[cfg(any(test, feature = "test-utils"))]
64+
pub use nip_fi::StaticIssuerKeySource;
65+
#[cfg(any(test, feature = "test-utils"))]
6466
pub use rate_limit::AlwaysAllowRateLimiter;
6567

6668
/// How the connection was authenticated.

crates/buzz-auth/src/nip_fi/mod.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,9 @@ pub use jwks::{
3434
ProductionJwksSource,
3535
};
3636
pub use startup::{validate_nip_fi_config, NipFiMode, NipFiStartupError};
37-
pub use verifier::{AssertionKeySet, FederatedAssertionVerifier, IssuerKeySource, VerifierError};
37+
pub use verifier::{
38+
AssertionKeySet, FederatedAssertionVerifier, IssuerKeySource, VerifierError, VerifyAssertion,
39+
};
40+
41+
#[cfg(any(test, feature = "test-utils"))]
42+
pub use verifier::StaticIssuerKeySource;

crates/buzz-auth/src/nip_fi/verifier.rs

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,20 @@ impl AssertionKeySet {
127127
})
128128
}
129129

130+
/// Test-utils / test-only constructor: same validation as the crate-private
131+
/// `new`, exposed under the `test-utils` Cargo feature and `cfg(test)` so
132+
/// integration tests in dependent crates (e.g., `buzz-relay`) can build
133+
/// snapshots for `StaticIssuerKeySource` without requiring a live JWKS fetch.
134+
#[cfg(any(test, feature = "test-utils"))]
135+
pub fn new_for_test(
136+
issuer: String,
137+
generation: u64,
138+
jwks: JwkSet,
139+
hard_deadline: DateTime<Utc>,
140+
) -> Option<Self> {
141+
Self::new(issuer, generation, jwks, hard_deadline)
142+
}
143+
130144
/// The exact `iss` this snapshot authenticates.
131145
pub fn issuer(&self) -> &str {
132146
&self.issuer
@@ -209,20 +223,20 @@ impl<S: IssuerKeySource> IssuerKeySource for std::sync::Arc<S> {
209223
/// reconstruct the authority. An honest source returns only the snapshot bound
210224
/// to the exact issuer requested, the invariant the real runtime source
211225
/// guarantees.
212-
#[cfg(test)]
226+
#[cfg(any(test, feature = "test-utils"))]
213227
#[derive(Clone, Default)]
214-
pub(crate) struct StaticIssuerKeySource {
228+
pub struct StaticIssuerKeySource {
215229
snapshots: std::collections::HashMap<String, AssertionKeySet>,
216230
/// When set, returned for every requested issuer regardless of its binding,
217231
/// to exercise the verifier's defensive issuer re-check.
218232
misbound: Option<AssertionKeySet>,
219233
}
220234

221-
#[cfg(test)]
235+
#[cfg(any(test, feature = "test-utils"))]
222236
impl StaticIssuerKeySource {
223237
/// Build an honest source from a set of snapshots, keyed by each snapshot's
224238
/// issuer.
225-
pub(crate) fn new(snapshots: impl IntoIterator<Item = AssertionKeySet>) -> Self {
239+
pub fn new(snapshots: impl IntoIterator<Item = AssertionKeySet>) -> Self {
226240
Self {
227241
snapshots: snapshots
228242
.into_iter()
@@ -235,18 +249,18 @@ impl StaticIssuerKeySource {
235249
/// A hostile/buggy source that returns the given snapshot — bound to a
236250
/// different issuer than requested — for every lookup, to exercise the
237251
/// verifier's defensive issuer re-check.
238-
pub(crate) fn misbinding(snapshot: AssertionKeySet) -> Self {
252+
pub fn misbinding(snapshot: AssertionKeySet) -> Self {
239253
Self {
240254
snapshots: std::collections::HashMap::new(),
241255
misbound: Some(snapshot),
242256
}
243257
}
244258
}
245259

246-
#[cfg(test)]
260+
#[cfg(any(test, feature = "test-utils"))]
247261
impl sealed::Sealed for StaticIssuerKeySource {}
248262

249-
#[cfg(test)]
263+
#[cfg(any(test, feature = "test-utils"))]
250264
impl IssuerKeySource for StaticIssuerKeySource {
251265
fn key_set(&self, issuer: &str) -> Option<AssertionKeySet> {
252266
self.misbound
@@ -255,6 +269,24 @@ impl IssuerKeySource for StaticIssuerKeySource {
255269
}
256270
}
257271

272+
/// Object-safe wrapper for assertion verification, allowing type-erased storage
273+
/// in `AppState` and test injection of `StaticIssuerKeySource`-backed verifiers.
274+
///
275+
/// `FederatedAssertionVerifier<S>` implements this for any `S: IssuerKeySource`.
276+
/// The sealed `IssuerKeySource` trait still constrains who can build a real
277+
/// verifier — this trait only erases the `S` type parameter at the storage boundary.
278+
pub trait VerifyAssertion: Send + Sync {
279+
/// Verify one compact JWS assertion. Semantics identical to
280+
/// [`FederatedAssertionVerifier::verify`].
281+
fn verify_assertion(&self, token: &str) -> Result<VerifiedAssertion, VerifierError>;
282+
}
283+
284+
impl<S: IssuerKeySource + Send + Sync> VerifyAssertion for FederatedAssertionVerifier<S> {
285+
fn verify_assertion(&self, token: &str) -> Result<VerifiedAssertion, VerifierError> {
286+
self.verify(token)
287+
}
288+
}
289+
258290
/// The provider-neutral assertion verifier over a closed multi-issuer registry
259291
/// and a trusted [`IssuerKeySource`].
260292
#[derive(Debug, Clone)]

crates/buzz-relay/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag
9797
buzz-test-client = { path = "../buzz-test-client" }
9898
ed25519-dalek = "=3.0.0-rc.0"
9999
buzz-core = { workspace = true, features = ["test-utils"] }
100-
buzz-auth = { workspace = true, features = ["dev"] }
100+
buzz-auth = { workspace = true, features = ["dev", "test-utils"] }
101101
reqwest = { workspace = true }
102102
tokio-tungstenite = { workspace = true }
103103
futures = "0.3"

crates/buzz-relay/src/api/bridge.rs

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5148,4 +5148,244 @@ mod postgres_tests {
51485148
removed or mode was changed"
51495149
);
51505150
}
5151+
5152+
// ── T1-IMP1 (final): guard performs crypto verification, not just transport ──
5153+
//
5154+
// ## What this proves
5155+
//
5156+
// `nip_fi_assertion_guard` now performs the full offline assertion
5157+
// verification — not just transport-level shape validation. A structurally
5158+
// valid but cryptographically invalid assertion (wrong signature) MUST be
5159+
// denied by the guard with 403 `evidence_rejected`, before the handler fires.
5160+
//
5161+
// ## Why the test distinguishes guard vs per-handler
5162+
//
5163+
// The request carries a bad-sig assertion token but NO NIP-98
5164+
// `Authorization: Nostr ...` header. With `require_auth_token = true`:
5165+
//
5166+
// • Guard intact: `verifier.verify_assertion(bad_token)` → EvidenceRejected
5167+
// → 403 (guard denies before handler fires).
5168+
//
5169+
// • Guard mutated (step 2 removed): guard forwards. Handler's NIP-98
5170+
// auth layer fires first → missing auth → 401.
5171+
//
5172+
// 403 ≠ 401, so the mutation turns this test RED.
5173+
//
5174+
// ## What "mandatory wiring" means
5175+
//
5176+
// The removed wiring in the falsifying mutation is the
5177+
// `verifier.verify_assertion(token)` call in `nip_fi_assertion_guard`
5178+
// (`router.rs`). Removing it restores the old transport-only guard, which
5179+
// forwards any structurally valid token to the handler. That is the
5180+
// "forgotten-gate" failure class: a handler that omits
5181+
// `check_nip_fi_http_on_state` would admit with an invalidly-signed
5182+
// assertion if the guard doesn't verify.
5183+
//
5184+
// ## Verifier construction
5185+
//
5186+
// To get a distinguishable outcome, this test injects a real
5187+
// `StaticIssuerKeySource`-backed verifier into the state (rather than
5188+
// `nip_fi_verifier = None`), so that a bad-sig token produces a definite
5189+
// 403 (not a startup-race 503 that a handler check would also produce).
5190+
#[test]
5191+
#[ignore = "requires Postgres"]
5192+
fn nip_fi_guard_rejects_crypto_invalid_assertion_before_handler_fires() {
5193+
use buzz_auth::{
5194+
AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy,
5195+
IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion,
5196+
};
5197+
use jsonwebtoken::{jwk::JwkSet, Algorithm};
5198+
5199+
let rt = tokio::runtime::Builder::new_current_thread()
5200+
.enable_all()
5201+
.build()
5202+
.expect("current_thread runtime");
5203+
5204+
// ── 1. Build the test state with a real injected verifier ─────────────
5205+
5206+
let Some(mut state) = rt.block_on(async {
5207+
// Clone nip_fi_enforce_test_state setup, but return the state
5208+
// before Arc-wrapping so we can inject the verifier.
5209+
let mut config = crate::config::Config::from_env().ok()?;
5210+
config.database_url = crate::test_support::database_url();
5211+
config.redis_url =
5212+
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
5213+
config.relay_url = "wss://nip-fi-test.local".to_string();
5214+
config.require_auth_token = true;
5215+
config.require_relay_membership = false;
5216+
config.nip_fi.mode = buzz_auth::NipFiMode::Enforce;
5217+
5218+
let pool = sqlx::PgPool::connect(&crate::test_support::database_url())
5219+
.await
5220+
.ok()?;
5221+
let db = buzz_db::Db::from_pool(pool.clone());
5222+
let redis_pool = deadpool_redis::Config::from_url(&config.redis_url)
5223+
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
5224+
.ok()?;
5225+
let pubsub = Arc::new(
5226+
buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone())
5227+
.await
5228+
.ok()?,
5229+
);
5230+
let audit = buzz_audit::AuditService::new(pool.clone());
5231+
let auth = buzz_auth::AuthService::new(config.auth.clone());
5232+
let search = buzz_search::SearchService::new(pool.clone());
5233+
let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new(
5234+
db.clone(),
5235+
buzz_workflow::WorkflowConfig::default(),
5236+
));
5237+
let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?;
5238+
5239+
let (mut state, _) = crate::state::AppState::new(
5240+
config,
5241+
db,
5242+
redis_pool,
5243+
audit,
5244+
pubsub,
5245+
auth,
5246+
search,
5247+
workflow_engine,
5248+
nostr::Keys::generate(),
5249+
media_storage,
5250+
);
5251+
state.nip98_replay = Arc::new(AlwaysFreshReplayGuard);
5252+
Some(state)
5253+
}) else {
5254+
panic!("local Postgres not reachable");
5255+
};
5256+
5257+
// ── 2. Build the verifier with StaticIssuerKeySource + test key ───────
5258+
//
5259+
// The verifier is seeded with a known P-256 public key. Tokens that
5260+
// claim `iss=https://issuer.test` will be verified against this key.
5261+
// A token with an all-zero signature will fail `InvalidSignatureOrClaims`
5262+
// → DenialClass::EvidenceRejected → 403.
5263+
//
5264+
// Key constants match the canonical test key in buzz-auth
5265+
// (verifier/tests.rs): TEST_JWK_X / TEST_JWK_Y / TEST_KID / ISSUER.
5266+
const TEST_ISSUER: &str = "https://issuer.example";
5267+
const TEST_AUDIENCE: &str = "https://relay.example";
5268+
const TEST_KID: &str = "test-key-1";
5269+
5270+
let jwks: JwkSet = serde_json::from_value(serde_json::json!({
5271+
"keys": [{
5272+
"kty": "EC",
5273+
"crv": "P-256",
5274+
"use": "sig",
5275+
"alg": "ES256",
5276+
"kid": TEST_KID,
5277+
"x": "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI",
5278+
"y": "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"
5279+
}]
5280+
}))
5281+
.expect("valid test JWKS");
5282+
5283+
let hard_deadline = chrono::Utc::now() + chrono::Duration::seconds(3600);
5284+
let key_set = AssertionKeySet::new_for_test(TEST_ISSUER.to_owned(), 1, jwks, hard_deadline)
5285+
.expect("valid test key set");
5286+
5287+
let jwks_contract = buzz_auth::JwksSourceContract::new(
5288+
format!("{TEST_ISSUER}/.well-known/jwks.json"),
5289+
300,
5290+
3600,
5291+
)
5292+
.expect("valid jwks contract");
5293+
5294+
let policy = IssuerPolicy::new(
5295+
TEST_ISSUER.to_owned(),
5296+
vec![TEST_AUDIENCE.to_owned()],
5297+
TokenClass::DedicatedNipFi,
5298+
FreshnessClass::OfflineJwt,
5299+
vec![Algorithm::ES256],
5300+
60, // skew_seconds
5301+
3600, // max_assertion_age_seconds
5302+
None,
5303+
jwks_contract,
5304+
)
5305+
.expect("valid issuer policy");
5306+
5307+
let mut registry = IssuerRegistry::new();
5308+
registry.insert(policy);
5309+
5310+
let verifier: Arc<dyn VerifyAssertion> = Arc::new(FederatedAssertionVerifier::new(
5311+
registry,
5312+
StaticIssuerKeySource::new([key_set]),
5313+
));
5314+
5315+
state.nip_fi_verifier = Some(verifier);
5316+
let state = Arc::new(state);
5317+
5318+
let host = format!("nip-fi-seam-{}.local", uuid::Uuid::new_v4().simple());
5319+
rt.block_on(state.db.ensure_configured_community(&host))
5320+
.expect("ensure community");
5321+
5322+
// ── 3. Build a structurally valid but cryptographically invalid token ─
5323+
//
5324+
// Header and claims match the verifier's expectations (correct issuer,
5325+
// audience, exp, nostr_pubkey). The signature is 64 zero bytes —
5326+
// structurally valid base64url for an ES256 DER signature, but
5327+
// cryptographically invalid. The verifier will parse through to the
5328+
// signature check and fail with EvidenceRejected (403).
5329+
const BAD_SIG_TOKEN: &str = concat!(
5330+
// Header: {"alg":"ES256","kid":"test-key-1"}
5331+
"eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5LTEifQ",
5332+
".",
5333+
// Claims: {"iss":"https://issuer.example","aud":"https://relay.example",
5334+
// "iat":1700000000,"exp":9999999999,
5335+
// "nostr_pubkey":"1234...cdef","sub":"test-subject"}
5336+
"eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiYXVkIjoiaHR0cHM6Ly9yZWxheS5leGFtcGxlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjk5OTk5OTk5OTksIm5vc3RyX3B1YmtleSI6IjEyMzQ1Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVmMTIzNDU2Nzg5MGFiY2RlZjEyMzQ1Njc4OTBhYmNkZWYiLCJzdWIiOiJ0ZXN0LXN1YmplY3QifQ",
5337+
".",
5338+
// Signature: 64 zero bytes (invalid)
5339+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
5340+
);
5341+
5342+
// Verify the token is structurally valid (3 dots, valid base64url segments)
5343+
// but is actually rejected by the verifier:
5344+
let verifier_check = state
5345+
.nip_fi_verifier
5346+
.as_deref()
5347+
.expect("verifier injected")
5348+
.verify_assertion(BAD_SIG_TOKEN);
5349+
assert!(
5350+
verifier_check.is_err(),
5351+
"pre-condition: the bad-sig token MUST be rejected by the verifier; \
5352+
if it passes, the test cannot distinguish guard-deny from handler-deny"
5353+
);
5354+
5355+
// ── 4. Send the request through the production router ─────────────────
5356+
//
5357+
// The request carries:
5358+
// • Nostr-Federated-Identity: Bearer <bad-sig token> (structurally valid, bad sig)
5359+
// • NO Authorization: Nostr ... (no NIP-98)
5360+
//
5361+
// Expected with guard verifying (current code):
5362+
// Guard calls verifier.verify_assertion(bad_token) → EvidenceRejected
5363+
// → 403 evidence_rejected before handler fires.
5364+
//
5365+
// Falsifying mutation (remove verifier.verify_assertion from guard):
5366+
// Guard forwards (step 2 removed) → handler's NIP-98 auth fires first
5367+
// → missing NIP-98 → 401. 403 ≠ 401 → test fails.
5368+
let mut headers = axum::http::HeaderMap::new();
5369+
headers.insert(
5370+
buzz_auth::CLIENT_ATTACHED_HEADER,
5371+
format!("Bearer {BAD_SIG_TOKEN}")
5372+
.parse()
5373+
.expect("valid header"),
5374+
);
5375+
// Deliberately NO Authorization header (no NIP-98).
5376+
5377+
let status = rt.block_on(oneshot_request(
5378+
state, "POST", "/events", &host, headers, b"{}",
5379+
));
5380+
5381+
assert_eq!(
5382+
status,
5383+
axum::http::StatusCode::FORBIDDEN,
5384+
"NIP-FI enforce mode: POST /events with cryptographically invalid assertion \
5385+
(bad sig) MUST deny 403 evidence_rejected from the guard before the handler \
5386+
fires [FI-TRACE-AUTHORITY-UNIFORM, T1-IMP1]. \
5387+
Falsifying mutation: remove verifier.verify_assertion from nip_fi_assertion_guard \
5388+
→ guard forwards → missing NIP-98 → 401 ≠ 403 → test fails."
5389+
);
5390+
}
51515391
}

0 commit comments

Comments
 (0)