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 <noreply@anthropic.com>
164 lines
5.6 KiB
TypeScript
164 lines
5.6 KiB
TypeScript
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<string, unknown> = {},
|
|
) {
|
|
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')
|
|
})
|
|
})
|