From ca9f5f36e6cd8903636a818ea8756756c0b41cba Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 12 Mar 2026 23:44:56 +0000 Subject: [PATCH] =?UTF-8?q?test:=20add=20NIP-98=20edge=20cases=20=E2=80=94?= =?UTF-8?q?=20replay,=20future=20clock=20drift,=20URL=20path=20mismatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents finding: no replay protection in NIP-98 verification. Token replay within 120s window succeeds (mitigated by JWT issuance being idempotent). Co-Authored-By: Claude Opus 4.6 --- server/src/middleware/nip98.test.ts | 40 +++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/server/src/middleware/nip98.test.ts b/server/src/middleware/nip98.test.ts index 5a1ff93..0b8aabf 100644 --- a/server/src/middleware/nip98.test.ts +++ b/server/src/middleware/nip98.test.ts @@ -120,4 +120,44 @@ describe('verifyNip98Token', () => { expect(result.valid).toBe(false) expect(result.error).toContain('Wrong event kind') }) + + it('token replay: same valid token accepted twice within 120s (no replay protection)', () => { + // FINDING: NIP-98 spec doesn't mandate replay protection. + // The server verifies signature and timestamp but does not track seen event IDs. + // A valid token can be replayed within its 120s window. + // Mitigation: JWT is issued on first use, so replay only re-issues JWT for same pubkey. + const sk = generateSecretKey() + const event = createNip98Event(sk, 'https://example.com/api/auth', 'POST') + const header = toAuthHeader(event) + + const result1 = verifyNip98Token(header, '/api/auth', 'POST') + const result2 = verifyNip98Token(header, '/api/auth', 'POST') + expect(result1.valid).toBe(true) + expect(result2.valid).toBe(true) // No replay protection — documenting behavior + }) + + it('rejects clock drift >120s in the future', () => { + const sk = generateSecretKey() + const event = finalizeEvent({ + kind: 27235, + created_at: Math.floor(Date.now() / 1000) + 200, // 200s in the future + tags: [['u', 'https://example.com/api/auth'], ['method', 'POST']], + content: '', + }, sk) + const header = toAuthHeader(event) + + const result = verifyNip98Token(header, '/api/auth', 'POST') + expect(result.valid).toBe(false) + expect(result.error).toContain('expired') + }) + + it('rejects URL path mismatch', () => { + const sk = generateSecretKey() + const event = createNip98Event(sk, 'https://example.com/api/auth/login', 'POST') + const header = toAuthHeader(event) + + const result = verifyNip98Token(header, '/api/auth/session', 'POST') + expect(result.valid).toBe(false) + expect(result.error).toContain('URL path mismatch') + }) })