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') + }) })