import { describe, it, expect, vi, afterEach } from 'vitest' import { verifyNip98Token } from './nip98.js' import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools' function createNip98Event( sk: Uint8Array, url: string, method: string, overrides: Record = {}, ) { const event = { kind: 27235, created_at: Math.floor(Date.now() / 1000), tags: [ ['u', url], ['method', method], ], content: '', ...overrides, } return finalizeEvent(event, sk) } function toAuthHeader(event: object): string { return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}` } describe('verifyNip98Token', () => { afterEach(() => { vi.useRealTimers() }) it('accepts a valid NIP-98 token', () => { const sk = generateSecretKey() const pk = getPublicKey(sk) const event = createNip98Event(sk, 'https://example.com/api/auth/nostr/session', 'POST') const header = toAuthHeader(event) const result = verifyNip98Token(header, '/api/auth/nostr/session', 'POST') expect(result.valid).toBe(true) expect(result.pubkey).toBe(pk) }) it('rejects expired token (>120s)', () => { const sk = generateSecretKey() const event = createNip98Event(sk, 'https://example.com/api/auth', 'POST', { created_at: Math.floor(Date.now() / 1000) - 200, }) // Need to re-finalize with the old created_at // Actually createNip98Event's overrides merge before finalize, but created_at gets set by finalizeEvent // Let me build manually const rawEvent = { kind: 27235, created_at: Math.floor(Date.now() / 1000) - 200, tags: [['u', 'https://example.com/api/auth'], ['method', 'POST']], content: '', } const signed = finalizeEvent(rawEvent, sk) const header = toAuthHeader(signed) const result = verifyNip98Token(header, '/api/auth', 'POST') expect(result.valid).toBe(false) expect(result.error).toContain('expired') }) it('rejects wrong method', () => { const sk = generateSecretKey() const event = createNip98Event(sk, 'https://example.com/api/auth', 'POST') const header = toAuthHeader(event) const result = verifyNip98Token(header, '/api/auth', 'GET') expect(result.valid).toBe(false) expect(result.error).toContain('Method mismatch') }) it('rejects invalid signature (tampered event)', () => { const sk = generateSecretKey() const event = createNip98Event(sk, 'https://example.com/api/auth', 'POST') // Tamper with pubkey const tampered = { ...event, pubkey: '0'.repeat(64) } const header = toAuthHeader(tampered) const result = verifyNip98Token(header, '/api/auth', 'POST') expect(result.valid).toBe(false) expect(result.error).toContain('Invalid signature') }) it('rejects missing URL tag', () => { const sk = generateSecretKey() const event = finalizeEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), tags: [['method', 'POST']], content: '', }, sk) const header = toAuthHeader(event) const result = verifyNip98Token(header, '/api/auth', 'POST') expect(result.valid).toBe(false) expect(result.error).toContain('Missing URL tag') }) it('rejects invalid auth header format', () => { const result = verifyNip98Token('Bearer xyz123', '/api/auth', 'POST') expect(result.valid).toBe(false) expect(result.error).toContain('Invalid auth header format') }) it('rejects wrong event kind', () => { const sk = generateSecretKey() const event = finalizeEvent({ kind: 1, // Regular note, not 27235 created_at: Math.floor(Date.now() / 1000), 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('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') }) })