From c6d4311e0a0472473971c06b386966883bb905bb Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 19 Aug 2026 14:53:43 -0500 Subject: [PATCH] fix(nip98): reject tokens whose created_at is in the future validateEventTimestamp() computed `now - created_at < 60`, which is satisfied by any timestamp ahead of now, since the difference goes negative. A token signed with a `created_at` far in the future therefore validates indefinitely, and the 60 second window -- the only replay defense NIP-98 has, as there is no nonce or seen-token cache -- never closes for it. Compare the absolute difference instead, so the window is 60 seconds on both sides. --- nip98.test.ts | 21 +++++++++++++++++++++ nip98.ts | 6 ++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/nip98.test.ts b/nip98.test.ts index 7018e95..3c2027d 100644 --- a/nip98.test.ts +++ b/nip98.test.ts @@ -136,6 +136,17 @@ describe('validateToken', () => { expect(isTokenValid).rejects.toThrow(Error) }) + test('throws an error for an event timestamp in the future', async () => { + const sk = generateSecretKey() + const invalidToken = await getToken('http://test.com', 'get', e => { + e.created_at = Math.round(Date.now() / 1000) + 3600 + return finalizeEvent(e, sk) + }) + const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get') + + expect(isTokenValid).rejects.toThrow(Error) + }) + test('throws an error for invalid url', async () => { const sk = generateSecretKey() const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk)) @@ -239,6 +250,16 @@ describe('validateEventTimestamp', () => { expect(isEventTimestampValid).toBe(false) }) + + test('returns false for a timestamp in the future', async () => { + const sk = generateSecretKey() + const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true) + const unpackedEvent: Event = await unpackEventFromToken(token) + unpackedEvent.created_at = Math.round(Date.now() / 1000) + 3600 + const isEventTimestampValid = validateEventTimestamp(unpackedEvent) + + expect(isEventTimestampValid).toBe(false) + }) }) describe('validateEventKind', () => { diff --git a/nip98.ts b/nip98.ts index 55baafa..22af04a 100644 --- a/nip98.ts +++ b/nip98.ts @@ -87,14 +87,16 @@ export async function unpackEventFromToken(token: string): Promise { /** * Validates the timestamp of an event. * @param event - The event object to validate. - * @returns A boolean indicating whether the event timestamp is within the last 60 seconds. + * @returns A boolean indicating whether the event timestamp is within 60 seconds of now. */ export function validateEventTimestamp(event: Event): boolean { if (!event.created_at) { return false } - return Math.round(new Date().getTime() / 1000) - event.created_at < 60 + // the comparison has to be absolute: a `created_at` in the future produces a + // negative difference, which would otherwise satisfy the check forever. + return Math.abs(Math.round(new Date().getTime() / 1000) - event.created_at) < 60 } /**