@@ -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