diff --git a/nip98.ts b/nip98.ts index 5dd195c..5800d65 100644 --- a/nip98.ts +++ b/nip98.ts @@ -1,17 +1,13 @@ -import { bytesToHex } from '@noble/hashes/utils' import { sha256 } from '@noble/hashes/sha256' +import { bytesToHex } from '@noble/hashes/utils' import { base64 } from '@scure/base' + +import { HTTPAuth } from './kinds.ts' import { Event, EventTemplate, verifyEvent } from './pure.ts' import { utf8Decoder, utf8Encoder } from './utils.ts' -import { HTTPAuth } from './kinds.ts' const _authorizationScheme = 'Nostr ' -export function hashPayload(payload: any): string { - const hash = sha256(utf8Encoder.encode(JSON.stringify(payload))) - return bytesToHex(hash) -} - /** * Generate token for NIP-98 flow. * @@ -37,7 +33,7 @@ export async function getToken( } if (payload) { - event.tags.push(['payload', bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))]) + event.tags.push(['payload', hashPayload(payload)]) } const signedEvent = await sign(event) @@ -56,6 +52,7 @@ export async function validateToken(token: string, url: string, method: string): const event = await unpackEventFromToken(token).catch(error => { throw error }) + const valid = await validateEvent(event, url, method).catch(error => { throw error }) @@ -63,10 +60,18 @@ export async function validateToken(token: string, url: string, method: string): return valid } +/** + * Unpacks an event from a token. + * + * @param token - The token to unpack. + * @returns A promise that resolves to the unpacked event. + * @throws {Error} If the token is missing, invalid, or cannot be parsed. + */ export async function unpackEventFromToken(token: string): Promise { if (!token) { throw new Error('Missing token') } + token = token.replace(_authorizationScheme, '') const eventB64 = utf8Decoder.decode(base64.decode(token)) @@ -79,41 +84,121 @@ export async function unpackEventFromToken(token: string): Promise { return event } -export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise { - if (!event) { - throw new Error('Invalid nostr event') +/** + * 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. + */ +export function validateEventTimestamp(event: Event): boolean { + if (!event.created_at) { + return false } + + return Math.round(new Date().getTime() / 1000) - event.created_at < 60 +} + +/** + * Validates the kind of an event. + * @param event The event to validate. + * @returns A boolean indicating whether the event kind is valid. + */ +export function validateEventKind(event: Event): boolean { + return event.kind === HTTPAuth +} + +/** + * Validates if the given URL matches the URL tag of the event. + * @param event - The event object. + * @param url - The URL to validate. + * @returns A boolean indicating whether the URL is valid or not. + */ +export function validateEventUrlTag(event: Event, url: string): boolean { + const urlTag = event.tags.find(t => t[0] === 'u') + + if (!urlTag) { + return false + } + + return urlTag.length > 0 && urlTag[1] === url +} + +/** + * Validates if the given event has a method tag that matches the specified method. + * @param event - The event to validate. + * @param method - The method to match against the method tag. + * @returns A boolean indicating whether the event has a matching method tag. + */ +export function validateEventMethodTag(event: Event, method: string): boolean { + const methodTag = event.tags.find(t => t[0] === 'method') + + if (!methodTag) { + return false + } + + return methodTag.length > 0 && methodTag[1].toLowerCase() === method.toLowerCase() +} + +/** + * Calculates the hash of a payload. + * @param payload - The payload to be hashed. + * @returns The hash value as a string. + */ +export function hashPayload(payload: any): string { + const hash = sha256(utf8Encoder.encode(JSON.stringify(payload))) + return bytesToHex(hash) +} + +/** + * Validates the event payload tag against the provided payload. + * @param event The event object. + * @param payload The payload to validate. + * @returns A boolean indicating whether the payload tag is valid. + */ +export function validateEventPayloadTag(event: Event, payload: any): boolean { + const payloadTag = event.tags.find(t => t[0] === 'payload') + + if (!payloadTag) { + return false + } + + const payloadHash = hashPayload(payload) + return payloadTag.length > 0 && payloadTag[1] === payloadHash +} + +/** + * Validates a Nostr event for the NIP-98 flow. + * + * @param event - The Nostr event to validate. + * @param url - The URL associated with the event. + * @param method - The HTTP method associated with the event. + * @param body - The request body associated with the event (optional). + * @returns A promise that resolves to a boolean indicating whether the event is valid. + * @throws An error if the event is invalid. + */ +export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise { if (!verifyEvent(event)) { throw new Error('Invalid nostr event, signature invalid') } - if (event.kind !== HTTPAuth) { + + if (!validateEventKind(event)) { throw new Error('Invalid nostr event, kind invalid') } - if (!event.created_at) { - throw new Error('Invalid nostr event, created_at invalid') + if (!validateEventTimestamp(event)) { + throw new Error('Invalid nostr event, created_at timestamp invalid') } - // Event must be less than 60 seconds old - if (Math.round(new Date().getTime() / 1000) - event.created_at > 60) { - throw new Error('Invalid nostr event, expired') - } - - const urlTag = event.tags.find(t => t[0] === 'u') - if (urlTag?.length !== 1 && urlTag?.[1] !== url) { + if (!validateEventUrlTag(event, url)) { throw new Error('Invalid nostr event, url tag invalid') } - const methodTag = event.tags.find(t => t[0] === 'method') - if (methodTag?.length !== 1 && methodTag?.[1].toLowerCase() !== method.toLowerCase()) { + if (!validateEventMethodTag(event, method)) { throw new Error('Invalid nostr event, method tag invalid') } - if (Boolean(body) && Object.keys(body).length > 0) { - const payloadTag = event.tags.find(t => t[0] === 'payload') - const payloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(body)))) - if (payloadTag?.[1] !== payloadHash) { - throw new Error('Invalid payload tag hash, does not match request body hash') + if (Boolean(body) && typeof body === 'object' && Object.keys(body).length > 0) { + if (!validateEventPayloadTag(event, body)) { + throw new Error('Invalid nostr event, payload tag does not match request body hash') } }