diff --git a/.changeset/cool-ways-brake.md b/.changeset/cool-ways-brake.md new file mode 100644 index 00000000..98ff1968 --- /dev/null +++ b/.changeset/cool-ways-brake.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +Replace admin password login with NIP-98 HTTP auth kind-27235 `Authorization: Nostr` headers verified against an `admin.pubkeys` allowlist \ No newline at end of file diff --git a/README.md b/README.md index e15823cd..0387a41f 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ NIPs with a relay-specific implementation are listed here. - [x] NIP-45: Event Counts - [x] NIP-62: Request to Vanish - [x] NIP-65: Relay List Metadata +- [x] NIP-98: HTTP Auth (admin console login) ## Requirements diff --git a/package.json b/package.json index df8dac50..66aa80c5 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ 44, 45, 50, - 65 + 65, + 98 ], "supportedNipExtensions": [], "supportedMips": [ diff --git a/resources/admin-dashboard.html b/resources/admin-dashboard.html new file mode 100644 index 00000000..c13649d6 --- /dev/null +++ b/resources/admin-dashboard.html @@ -0,0 +1,74 @@ + + + + + + Admin - {{name}} + + + + +
+
+
+

{{name}}

+
+
+
+
+

Relay Administration

+
+
+ +
+
+ +
+
+
Admin Console
+

You are signed in with NIP-98.

+

+ +
+
+ + + +
+
+
+ + + diff --git a/resources/admin-login.html b/resources/admin-login.html new file mode 100644 index 00000000..d5b8e03c --- /dev/null +++ b/resources/admin-login.html @@ -0,0 +1,117 @@ + + + + + + Admin Login - {{name}} + + + + +
+
+
+

{{name}}

+
+
+
+
+

Relay Administration

+
+
+ +
+
+ +
+
+
Admin Login
+

+ Sign in with your Nostr identity. Your signer extension or connected + wallet (NIP-07) will be asked to sign a NIP-98 authentication event. +

+ +

+ +
+
+ + + +
+
+
+ + + diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 300e2f38..1b793cca 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -250,3 +250,4 @@ limits: admin: enabled: false sessionTtlSeconds: 86400 + pubkeys: [] diff --git a/src/@types/admin.ts b/src/@types/admin.ts index 955e185f..7d9f5d91 100644 --- a/src/@types/admin.ts +++ b/src/@types/admin.ts @@ -2,6 +2,14 @@ import { Request, Response } from 'express' export interface IAdminAuthProvider { handleLogin(request: Request, response: Response): Promise - isRequestAuthenticated(request: Request): boolean + isRequestAuthenticated(request: Request): Promise getSessionExpiresAt(request: Request): number | undefined } + +export interface INip98ReplayGuard { + /** + * Returns true when eventId was not seen before (and is now registered), + * false when the event id is being replayed. + */ + registerEventId(eventId: string, ttlSeconds: number): Promise +} diff --git a/src/@types/settings.ts b/src/@types/settings.ts index ce111cfe..adcb5435 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -281,8 +281,17 @@ export interface Nip05Settings { export interface AdminSettings { enabled: boolean - passwordHash?: string + /** + * NIP-98 admin allowlist: 64-char hex pubkeys or npub1... entries. + * Empty or unset means no one can authenticate. + */ + pubkeys?: string[] sessionTtlSeconds?: number + /** + * Maximum allowed |now - created_at| for NIP-98 auth events, in seconds. + * Defaults to 60 when unset. + */ + authTimestampToleranceSeconds?: number } export interface WoTSettings { enabled: boolean diff --git a/src/admin/nip98-admin-auth-provider.ts b/src/admin/nip98-admin-auth-provider.ts new file mode 100644 index 00000000..8fed7ad6 --- /dev/null +++ b/src/admin/nip98-admin-auth-provider.ts @@ -0,0 +1,150 @@ +import { createHash } from 'crypto' +import { Request, Response } from 'express' + +import { IAdminAuthProvider, INip98ReplayGuard } from '../@types/admin' +import { Settings } from '../@types/settings' +import { + buildAdminSessionCookieHeader, + createAdminSessionToken, + getAdminSessionTokenFromRequest, + isValidAdminSessionToken, + parseAdminSessionToken, + resolveAdminSessionTtlSeconds, +} from '../utils/admin-session' +import { getPublicRequestUrl } from '../utils/http' +import { + DEFAULT_NIP98_TIMESTAMP_TOLERANCE_SECONDS, + Nip98RequestContext, + verifyNip98AuthorizationHeader, +} from '../utils/nip98' +import { fromBech32 } from '../utils/transform' + +const HEX_PUBKEY_REGEX = /^[0-9a-f]{64}$/i +const NOSTR_SCHEME_PREFIX_REGEX = /^nostr\s/i + +export class Nip98AdminAuthProvider implements IAdminAuthProvider { + public constructor( + private readonly settings: () => Settings, + private readonly replayGuard: INip98ReplayGuard, + ) {} + + public async handleLogin(request: Request, response: Response): Promise { + const auth = await this.authenticateNostrHeader(request) + if (!auth) { + response.status(401).setHeader('content-type', 'application/json').send({ error: 'Unauthorized' }) + return + } + + const currentSettings = this.settings() + const sessionTtlSeconds = resolveAdminSessionTtlSeconds(currentSettings.admin?.sessionTtlSeconds) + const expiresAt = Math.floor(Date.now() / 1000) + sessionTtlSeconds + + try { + const token = createAdminSessionToken(expiresAt) + + response + .status(200) + .setHeader('content-type', 'application/json') + .setHeader('Set-Cookie', buildAdminSessionCookieHeader(request, currentSettings, token, sessionTtlSeconds)) + .send({ authenticated: true, expiresAt, pubkey: auth.pubkey }) + } catch { + response.status(500).setHeader('content-type', 'application/json').send({ error: 'Internal Server Error' }) + } + } + + public async isRequestAuthenticated(request: Request): Promise { + const token = this.getToken(request) + if (token && isValidAdminSessionToken(token)) { + return true + } + + const authorization = request.headers.authorization + if (typeof authorization === 'string' && NOSTR_SCHEME_PREFIX_REGEX.test(authorization.trim())) { + return (await this.authenticateNostrHeader(request)) !== undefined + } + + return false + } + + public getSessionExpiresAt(request: Request): number | undefined { + const token = this.getToken(request) + return token ? parseAdminSessionToken(token)?.expiresAt : undefined + } + + private getToken(request: Request): string | undefined { + return getAdminSessionTokenFromRequest(request.headers.authorization, request.headers.cookie) + } + + private async authenticateNostrHeader(request: Request): Promise<{ pubkey: string } | undefined> { + const context = this.buildContext(request) + if (!context) { + return undefined + } + + const toleranceSeconds = this.getToleranceSeconds() + const result = await verifyNip98AuthorizationHeader(request.headers.authorization, context, { + timestampToleranceSeconds: toleranceSeconds, + }) + if (!result.ok) { + return undefined + } + + if (!this.getAllowedPubkeys().has(result.event.pubkey.toLowerCase())) { + return undefined + } + + if (!(await this.replayGuard.registerEventId(result.event.id, toleranceSeconds * 2))) { + return undefined + } + + return { pubkey: result.event.pubkey } + } + + private buildContext(request: Request): Nip98RequestContext | undefined { + const url = getPublicRequestUrl(request, this.settings()) + if (!url) { + return undefined + } + + const bodySha256Hex = Buffer.isBuffer(request.body) && request.body.length > 0 + ? createHash('sha256').update(request.body).digest('hex') + : undefined + + return { url, method: request.method, bodySha256Hex } + } + + private getToleranceSeconds(): number { + const configured = this.settings().admin?.authTimestampToleranceSeconds + if (typeof configured === 'number' && Number.isFinite(configured) && configured > 0) { + return Math.floor(configured) + } + + return DEFAULT_NIP98_TIMESTAMP_TOLERANCE_SECONDS + } + + private getAllowedPubkeys(): Set { + const entries = this.settings().admin?.pubkeys ?? [] + const allowed = new Set() + + for (const entry of entries) { + if (typeof entry !== 'string') { + continue + } + + if (HEX_PUBKEY_REGEX.test(entry)) { + allowed.add(entry.toLowerCase()) + continue + } + + if (entry.toLowerCase().startsWith('npub1')) { + try { + allowed.add(fromBech32(entry).toLowerCase()) + } catch { + // invalid npub entries are skipped + } + } + } + + return allowed + } +} diff --git a/src/admin/password-admin-auth-provider.ts b/src/admin/password-admin-auth-provider.ts deleted file mode 100644 index 3b6f9dfc..00000000 --- a/src/admin/password-admin-auth-provider.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Request, Response } from 'express' - -import { IAdminAuthProvider } from '../@types/admin' -import { Settings } from '../@types/settings' -import { adminLoginBodySchema } from '../schemas/admin-login-schema' -import { verifyAdminPasswordHash, verifyPlaintextPassword } from '../utils/admin-password' -import { - buildAdminSessionCookieHeader, - createAdminSessionToken, - getAdminSessionTokenFromRequest, - isValidAdminSessionToken, - parseAdminSessionToken, - resolveAdminSessionTtlSeconds, -} from '../utils/admin-session' -import { validateSchema } from '../utils/validation' - -export class PasswordAdminAuthProvider implements IAdminAuthProvider { - public constructor(private readonly settings: () => Settings) {} - - public async handleLogin(request: Request, response: Response): Promise { - const validation = validateSchema(adminLoginBodySchema)(request.body) - if (validation.error) { - response.status(400).setHeader('content-type', 'application/json').send({ error: 'Invalid request' }) - return - } - - if (!this.verifyPassword(validation.value.password)) { - response.status(401).setHeader('content-type', 'application/json').send({ error: 'Unauthorized' }) - return - } - - const currentSettings = this.settings() - const sessionTtlSeconds = resolveAdminSessionTtlSeconds(currentSettings.admin?.sessionTtlSeconds) - const expiresAt = Math.floor(Date.now() / 1000) + sessionTtlSeconds - - try { - const token = createAdminSessionToken(expiresAt) - - response - .status(200) - .setHeader('content-type', 'application/json') - .setHeader('Set-Cookie', buildAdminSessionCookieHeader(request, currentSettings, token, sessionTtlSeconds)) - .send({ authenticated: true, expiresAt }) - } catch { - response.status(500).setHeader('content-type', 'application/json').send({ error: 'Internal Server Error' }) - } - } - - public isRequestAuthenticated(request: Request): boolean { - const token = this.getToken(request) - return token ? isValidAdminSessionToken(token) : false - } - - public getSessionExpiresAt(request: Request): number | undefined { - const token = this.getToken(request) - return token ? parseAdminSessionToken(token)?.expiresAt : undefined - } - - private getToken(request: Request): string | undefined { - return getAdminSessionTokenFromRequest(request.headers.authorization, request.headers.cookie) - } - - private verifyPassword(password: string): boolean { - const envPassword = process.env.ADMIN_PASSWORD - if (typeof envPassword === 'string' && envPassword.length > 0) { - return verifyPlaintextPassword(password, envPassword) - } - - const passwordHash = this.settings().admin?.passwordHash - if (!passwordHash) { - return false - } - - return verifyAdminPasswordHash(password, passwordHash) - } -} diff --git a/src/admin/redis-nip98-replay-guard.ts b/src/admin/redis-nip98-replay-guard.ts new file mode 100644 index 00000000..2575df56 --- /dev/null +++ b/src/admin/redis-nip98-replay-guard.ts @@ -0,0 +1,17 @@ +import { INip98ReplayGuard } from '../@types/admin' +import { CacheClient } from '../@types/cache' + +export class RedisNip98ReplayGuard implements INip98ReplayGuard { + public constructor(private readonly getClient: () => CacheClient) {} + + public async registerEventId(eventId: string, ttlSeconds: number): Promise { + const client = this.getClient() + if (!client.isOpen) { + await client.connect() + } + + const result = await client.set(`nip98:seen:${eventId}`, '1', { NX: true, EX: ttlSeconds }) + + return result === 'OK' + } +} diff --git a/src/constants/base.ts b/src/constants/base.ts index 7935f52a..a3e173d4 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -54,6 +54,8 @@ export enum EventKinds { EPHEMERAL_FIRST = 20000, // NIP-42: Client Authentication AUTH = 22242, + // NIP-98: HTTP Auth + HTTP_AUTH = 27235, // NIP-43: Ephemeral access request kinds NIP43_JOIN_REQUEST = 28934, NIP43_INVITE_REQUEST = 28935, @@ -95,6 +97,10 @@ export enum EventTags { // NIP-43: Relay Access Metadata Member = 'member', Claim = 'claim', + // NIP-98: HTTP Auth tags + Url = 'u', + Method = 'method', + Payload = 'payload', } export const ALL_RELAYS = 'ALL_RELAYS' diff --git a/src/factories/admin-auth-provider-factory.ts b/src/factories/admin-auth-provider-factory.ts index 16267023..bb3107db 100644 --- a/src/factories/admin-auth-provider-factory.ts +++ b/src/factories/admin-auth-provider-factory.ts @@ -1,7 +1,9 @@ -import { PasswordAdminAuthProvider } from '../admin/password-admin-auth-provider' +import { Nip98AdminAuthProvider } from '../admin/nip98-admin-auth-provider' +import { RedisNip98ReplayGuard } from '../admin/redis-nip98-replay-guard' import { IAdminAuthProvider } from '../@types/admin' +import { getCacheClient } from '../cache/client' import { createSettings } from './settings-factory' export const createAdminAuthProvider = (): IAdminAuthProvider => { - return new PasswordAdminAuthProvider(createSettings) + return new Nip98AdminAuthProvider(createSettings, new RedisNip98ReplayGuard(getCacheClient)) } diff --git a/src/handlers/request-handlers/admin-auth-middleware.ts b/src/handlers/request-handlers/admin-auth-middleware.ts index 99c4bd64..a07a8ce5 100644 --- a/src/handlers/request-handlers/admin-auth-middleware.ts +++ b/src/handlers/request-handlers/admin-auth-middleware.ts @@ -1,12 +1,29 @@ import { NextFunction, Request, Response } from 'express' import { createAdminAuthProvider } from '../../factories/admin-auth-provider-factory' +import { createSettings } from '../../factories/settings-factory' +import { getPublicPathPrefix } from '../../utils/http' const adminAuthProvider = createAdminAuthProvider() -export const adminAuthMiddleware = (request: Request, response: Response, next: NextFunction) => { +const isHtmlNavigation = (request: Request): boolean => { + if (request.method !== 'GET' && request.method !== 'HEAD') { + return false + } + + const accept = request.headers.accept + + return typeof accept === 'string' && accept.includes('text/html') +} + +export const adminAuthMiddleware = async (request: Request, response: Response, next: NextFunction) => { try { - if (!adminAuthProvider.isRequestAuthenticated(request)) { + if (!(await adminAuthProvider.isRequestAuthenticated(request))) { + if (isHtmlNavigation(request)) { + response.redirect(302, `${getPublicPathPrefix(request, createSettings())}/admin/login`) + return + } + response.status(401).setHeader('content-type', 'application/json').send({ error: 'Unauthorized' }) return } diff --git a/src/handlers/request-handlers/get-admin-dashboard-page-handler.ts b/src/handlers/request-handlers/get-admin-dashboard-page-handler.ts new file mode 100644 index 00000000..9e11d00b --- /dev/null +++ b/src/handlers/request-handlers/get-admin-dashboard-page-handler.ts @@ -0,0 +1,23 @@ +import { NextFunction, Request, Response } from 'express' + +import { createSettings as settings } from '../../factories/settings-factory' +import { escapeHtml } from '../../utils/html' +import { getPublicPathPrefix } from '../../utils/http' +import { getTemplate } from '../../utils/template-cache' + +export const getAdminDashboardPageHandler = (req: Request, res: Response, next: NextFunction) => { + const currentSettings = settings() + + let page: string + try { + page = getTemplate('./resources/admin-dashboard.html') + .replaceAll('{{name}}', escapeHtml(currentSettings.info.name)) + .replaceAll('{{path_prefix}}', escapeHtml(getPublicPathPrefix(req, currentSettings))) + .replaceAll('{{nonce}}', res.locals.nonce ?? '') + } catch (err) { + next(err) + return + } + + res.status(200).setHeader('content-type', 'text/html; charset=utf8').send(page) +} diff --git a/src/handlers/request-handlers/get-admin-login-page-handler.ts b/src/handlers/request-handlers/get-admin-login-page-handler.ts new file mode 100644 index 00000000..8ffe7e51 --- /dev/null +++ b/src/handlers/request-handlers/get-admin-login-page-handler.ts @@ -0,0 +1,23 @@ +import { NextFunction, Request, Response } from 'express' + +import { createSettings as settings } from '../../factories/settings-factory' +import { escapeHtml } from '../../utils/html' +import { getPublicPathPrefix } from '../../utils/http' +import { getTemplate } from '../../utils/template-cache' + +export const getAdminLoginPageHandler = (req: Request, res: Response, next: NextFunction) => { + const currentSettings = settings() + + let page: string + try { + page = getTemplate('./resources/admin-login.html') + .replaceAll('{{name}}', escapeHtml(currentSettings.info.name)) + .replaceAll('{{path_prefix}}', escapeHtml(getPublicPathPrefix(req, currentSettings))) + .replaceAll('{{nonce}}', res.locals.nonce ?? '') + } catch (err) { + next(err) + return + } + + res.status(200).setHeader('content-type', 'text/html; charset=utf8').send(page) +} diff --git a/src/handlers/request-handlers/post-admin-logout-handler.ts b/src/handlers/request-handlers/post-admin-logout-handler.ts new file mode 100644 index 00000000..a884d3ef --- /dev/null +++ b/src/handlers/request-handlers/post-admin-logout-handler.ts @@ -0,0 +1,12 @@ +import { Request, Response } from 'express' + +import { createSettings } from '../../factories/settings-factory' +import { buildAdminSessionCookieHeader } from '../../utils/admin-session' + +export const postAdminLogoutHandler = (request: Request, response: Response) => { + response + .status(200) + .setHeader('content-type', 'application/json') + .setHeader('Set-Cookie', buildAdminSessionCookieHeader(request, createSettings(), '', 0)) + .send({ authenticated: false }) +} diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index 0c879219..97ac07b4 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -1,4 +1,4 @@ -import { json, Router } from 'express' +import { raw, Router } from 'express' import { createGetAdminHealthController } from '../../factories/controllers/get-admin-health-controller-factory' import { createGetAdminSessionController } from '../../factories/controllers/get-admin-session-controller-factory' @@ -9,6 +9,9 @@ import { adminLoginRateLimitMiddleware, adminRateLimitMiddleware, } from '../../handlers/request-handlers/admin-rate-limit-middleware' +import { getAdminDashboardPageHandler } from '../../handlers/request-handlers/get-admin-dashboard-page-handler' +import { getAdminLoginPageHandler } from '../../handlers/request-handlers/get-admin-login-page-handler' +import { postAdminLogoutHandler } from '../../handlers/request-handlers/post-admin-logout-handler' import { rateLimiterMiddleware } from '../../handlers/request-handlers/rate-limiter-middleware' import { withAdminController } from '../../handlers/request-handlers/with-admin-controller-request-handler' @@ -18,8 +21,14 @@ const router: Router = Router() router.use(rateLimiterMiddleware) // codeql[js/missing-rate-limiting] - feature gate only, not authentication router.use(adminEnabledMiddleware) -router.post('/login', adminLoginRateLimitMiddleware, json(), withAdminController(createPostAdminLoginController)) -router.get('/session', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminSessionController)) -router.get('/health', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminHealthController)) +router.get('/login', adminLoginRateLimitMiddleware, getAdminLoginPageHandler) +router.post('/login', adminLoginRateLimitMiddleware, raw({ type: () => true, limit: '64kb' }), withAdminController(createPostAdminLoginController)) +// Everything below requires NIP-98 authentication; unauthenticated browser +// navigations are redirected to the login page, other requests receive 401. +router.use(adminRateLimitMiddleware, adminAuthMiddleware) +router.get('/', getAdminDashboardPageHandler) +router.get('/session', withAdminController(createGetAdminSessionController)) +router.get('/health', withAdminController(createGetAdminHealthController)) +router.post('/logout', postAdminLogoutHandler) export default router diff --git a/src/schemas/admin-login-schema.ts b/src/schemas/admin-login-schema.ts deleted file mode 100644 index aff2ebe8..00000000 --- a/src/schemas/admin-login-schema.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from 'zod' - -export const adminLoginBodySchema = z - .object({ - password: z.string().min(1), - }) - .strict() diff --git a/src/utils/admin-password.ts b/src/utils/admin-password.ts deleted file mode 100644 index e38282c1..00000000 --- a/src/utils/admin-password.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { randomBytes, scryptSync, timingSafeEqual } from 'crypto' - -const SCRYPT_PREFIX = 'scrypt' - -export const hashAdminPassword = (password: string): string => { - const salt = randomBytes(16) - const hash = scryptSync(password, salt, 64) - return `${SCRYPT_PREFIX}:${salt.toString('base64')}:${hash.toString('base64')}` -} - -export const verifyAdminPasswordHash = (password: string, storedHash: string): boolean => { - const parts = storedHash.split(':') - if (parts.length !== 3 || parts[0] !== SCRYPT_PREFIX) { - return false - } - - const [, saltB64, hashB64] = parts - const salt = Buffer.from(saltB64, 'base64') - const expected = Buffer.from(hashB64, 'base64') - const actual = scryptSync(password, salt, expected.length) - - if (expected.length !== actual.length) { - return false - } - - return timingSafeEqual(expected, actual) -} - -export const verifyPlaintextPassword = (password: string, expectedPassword: string): boolean => { - const expected = Buffer.from(expectedPassword, 'utf8') - const actual = Buffer.from(password, 'utf8') - - if (expected.length !== actual.length) { - return false - } - - return timingSafeEqual(expected, actual) -} diff --git a/src/utils/http.ts b/src/utils/http.ts index 47d18da0..cd3ba635 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -134,3 +134,37 @@ export const joinPathPrefix = (prefix: string, path: string): string => { return `${normalizedPrefix}${normalizedPath}` } + +const getTrustedForwardedHost = (request: IncomingMessage, settings: Settings): string | undefined => { + const socketAddress = request.socket?.remoteAddress + if (typeof socketAddress !== 'string' || !isTrustedProxy(socketAddress, settings)) { + return undefined + } + + const rawHeader = request.headers?.['x-forwarded-host'] + const rawHost = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader + const host = typeof rawHost === 'string' ? rawHost.split(',')[0].trim() : '' + + return host.length > 0 ? host : undefined +} + +/** + * Reconstructs the absolute URL the client used to reach this request, + * honoring trusted-proxy forwarded headers and any public path prefix. + * NIP-98 clients must sign this exact URL (including the query string). + */ +export const getPublicRequestUrl = ( + request: IncomingMessage & { originalUrl?: string }, + settings: Settings, +): string | undefined => { + const host = getTrustedForwardedHost(request, settings) ?? request.headers?.host + if (typeof host !== 'string' || host.length === 0) { + return undefined + } + + const proto = isSecureRequest(request, settings) ? 'https' : 'http' + const prefix = getPublicPathPrefix(request, settings) + const path = request.originalUrl ?? request.url ?? '/' + + return `${proto}://${host}${joinPathPrefix(prefix, path)}` +} diff --git a/src/utils/nip98.ts b/src/utils/nip98.ts new file mode 100644 index 00000000..2234870f --- /dev/null +++ b/src/utils/nip98.ts @@ -0,0 +1,133 @@ +import { Event } from '../@types/event' +import { EventKinds, EventTags } from '../constants/base' +import { eventSchema } from '../schemas/event-schema' +import { isEventIdValid, isEventSignatureValid } from './event' +import { validateSchema } from './validation' + +export const DEFAULT_NIP98_TIMESTAMP_TOLERANCE_SECONDS = 60 + +const NOSTR_SCHEME_REGEX = /^nostr\s+(.+)$/i +const BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/ + +export interface Nip98RequestContext { + url: string + method: string + bodySha256Hex?: string +} + +export interface Nip98VerifyOptions { + timestampToleranceSeconds?: number + now?: number +} + +export type Nip98Result = + | { ok: true; event: Event } + | { ok: false; reason: string } + +const failure = (reason: string): Nip98Result => ({ ok: false, reason }) + +const findTagValue = (event: Event, name: string): string | undefined => { + const tag = event.tags.find((entry) => entry.length >= 2 && entry[0] === name) + + return tag?.[1] +} + +export const parseNip98AuthorizationHeader = (header: string | undefined): Nip98Result => { + if (typeof header !== 'string') { + return failure('missing authorization header') + } + + const match = NOSTR_SCHEME_REGEX.exec(header.trim()) + if (!match) { + return failure('authorization scheme is not Nostr') + } + + const token = match[1].trim() + if (!BASE64_REGEX.test(token) || token.length % 4 !== 0) { + return failure('token is not valid base64') + } + + let parsed: unknown + try { + parsed = JSON.parse(Buffer.from(token, 'base64').toString('utf8')) + } catch { + return failure('token does not contain valid JSON') + } + + const validation = validateSchema(eventSchema)(parsed) + if (validation.error) { + return failure('token does not contain a valid event') + } + + return { ok: true, event: validation.value as Event } +} + +export const isNip98UrlMatch = (tagUrl: string, requestUrl: string): boolean => { + try { + const expected = new URL(requestUrl) + const actual = new URL(tagUrl) + + return actual.origin === expected.origin + && actual.pathname === expected.pathname + && actual.search === expected.search + } catch { + return false + } +} + +export const verifyNip98Event = async ( + event: Event, + context: Nip98RequestContext, + options: Nip98VerifyOptions = {}, +): Promise => { + if (event.kind !== EventKinds.HTTP_AUTH) { + return failure('invalid event kind') + } + + if (!(await isEventIdValid(event))) { + return failure('invalid event id') + } + + if (!(await isEventSignatureValid(event))) { + return failure('invalid event signature') + } + + const tolerance = options.timestampToleranceSeconds ?? DEFAULT_NIP98_TIMESTAMP_TOLERANCE_SECONDS + const now = options.now ?? Math.floor(Date.now() / 1000) + if (Math.abs(now - event.created_at) > tolerance) { + return failure('event timestamp is out of tolerance') + } + + const url = findTagValue(event, EventTags.Url) + if (typeof url !== 'string' || !isNip98UrlMatch(url, context.url)) { + return failure('u tag does not match request URL') + } + + const method = findTagValue(event, EventTags.Method) + if (typeof method !== 'string' || method.toUpperCase() !== context.method.toUpperCase()) { + return failure('method tag does not match request method') + } + + const payload = findTagValue(event, EventTags.Payload) + if (typeof payload === 'string') { + if (typeof context.bodySha256Hex !== 'string' + || payload.toLowerCase() !== context.bodySha256Hex.toLowerCase()) { + return failure('payload tag does not match request body hash') + } + } + + return { ok: true, event } +} + +export const verifyNip98AuthorizationHeader = async ( + header: string | undefined, + context: Nip98RequestContext, + options: Nip98VerifyOptions = {}, +): Promise => { + const parsed = parseNip98AuthorizationHeader(header) + if (!parsed.ok) { + return parsed + } + + return verifyNip98Event(parsed.event, context, options) +} diff --git a/test/unit/routes/admin.spec.ts b/test/unit/routes/admin.spec.ts index c7c852ce..057dede5 100644 --- a/test/unit/routes/admin.spec.ts +++ b/test/unit/routes/admin.spec.ts @@ -1,31 +1,52 @@ +import { createHash } from 'crypto' import axios from 'axios' import { expect } from 'chai' import express from 'express' import Sinon from 'sinon' import * as getAdminHealthControllerFactory from '../../../src/factories/controllers/get-admin-health-controller-factory' -import { hashAdminPassword } from '../../../src/utils/admin-password' import * as adminRateLimitMiddleware from '../../../src/handlers/request-handlers/admin-rate-limit-middleware' +import * as cacheClientModule from '../../../src/cache/client' import * as rateLimiterMiddleware from '../../../src/handlers/request-handlers/rate-limiter-middleware' import * as settingsFactory from '../../../src/factories/settings-factory' +import { EventKinds, EventTags } from '../../../src/constants/base' +import { getPublicKey, identifyEvent, signEvent } from '../../../src/utils/event' +import { Event } from '../../../src/@types/event' +import { Tag } from '../../../src/@types/base' +import { toBech32 } from '../../../src/utils/transform' describe('admin router', () => { const originalSecret = process.env.SECRET - const originalAdminPassword = process.env.ADMIN_PASSWORD + const privkey = 'a'.repeat(64) + const pubkey = getPublicKey(privkey) let createGetAdminHealthControllerStub: Sinon.SinonStub let createSettingsStub: Sinon.SinonStub + let getCacheClientStub: Sinon.SinonStub let rateLimiterMiddlewareStub: Sinon.SinonStub let adminRateLimitMiddlewareStub: Sinon.SinonStub let adminLoginRateLimitMiddlewareStub: Sinon.SinonStub let server: any + let seenReplayKeys: Set + + // The auth middleware builds its provider at module load, capturing the + // active createSettings and getCacheClient stubs, so these modules must be + // re-required after each round of stubbing. + const adminModulePaths = [ + '../../../src/routes/admin/index', + '../../../src/handlers/request-handlers/admin-auth-middleware', + '../../../src/factories/admin-auth-provider-factory', + ] + + const bustAdminModules = () => { + for (const modulePath of adminModulePaths) { + delete require.cache[require.resolve(modulePath)] + } + } const loadAdminRouter = () => { + bustAdminModules() // eslint-disable-next-line @typescript-eslint/no-var-requires - delete require.cache[require.resolve('../../../src/routes/admin/index')] - // eslint-disable-next-line @typescript-eslint/no-var-requires - delete require.cache[require.resolve('../../../src/routes/admin')] - // eslint-disable-next-line @typescript-eslint/no-var-requires - return require('../../../src/routes/admin').default + return require('../../../src/routes/admin/index').default } const startServer = async (settings: Record) => { @@ -49,6 +70,17 @@ describe('admin router', () => { }, } as any) createSettingsStub = Sinon.stub(settingsFactory, 'createSettings').returns(settings as any) + seenReplayKeys = new Set() + getCacheClientStub = Sinon.stub(cacheClientModule, 'getCacheClient').returns({ + isOpen: true, + set: async (key: string, _value: string, _options: unknown) => { + if (seenReplayKeys.has(key)) { + return null + } + seenReplayKeys.add(key) + return 'OK' + }, + } as any) const passthrough = async (_request: any, _response: any, next: any) => { next() } @@ -73,11 +105,11 @@ describe('admin router', () => { const stopServer = async () => { createGetAdminHealthControllerStub?.restore() createSettingsStub?.restore() + getCacheClientStub?.restore() rateLimiterMiddlewareStub?.restore() adminRateLimitMiddlewareStub?.restore() adminLoginRateLimitMiddlewareStub?.restore() - delete require.cache[require.resolve('../../../src/routes/admin/index')] - delete require.cache[require.resolve('../../../src/routes/admin')] + bustAdminModules() if (server) { await new Promise((resolve, reject) => { @@ -94,6 +126,40 @@ describe('admin router', () => { } } + const createHttpAuthEvent = async (overrides: { + kind?: number + url?: string + method?: string + payload?: string + created_at?: number + privkey?: string + } = {}): Promise => { + const signingKey = overrides.privkey ?? privkey + const tags: Tag[] = [ + [EventTags.Url, overrides.url ?? ''] as Tag, + [EventTags.Method, overrides.method ?? 'POST'] as Tag, + ] + if (typeof overrides.payload === 'string') { + tags.push([EventTags.Payload, overrides.payload] as Tag) + } + + const identified = await identifyEvent({ + pubkey: getPublicKey(signingKey), + created_at: overrides.created_at ?? Math.floor(Date.now() / 1000), + kind: overrides.kind ?? EventKinds.HTTP_AUTH, + tags, + content: '', + }) + + return signEvent(signingKey)(identified) + } + + const toAuthorizationHeader = (event: Event): string => + `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}` + + const loginHeader = async (baseUrl: string, overrides: Parameters[0] = {}) => + toAuthorizationHeader(await createHttpAuthEvent({ url: `${baseUrl}/login`, method: 'POST', ...overrides })) + before(() => { process.env.SECRET = 'test-admin-secret-value' }) @@ -104,16 +170,9 @@ describe('admin router', () => { } else { process.env.SECRET = originalSecret } - - if (originalAdminPassword === undefined) { - delete process.env.ADMIN_PASSWORD - } else { - process.env.ADMIN_PASSWORD = originalAdminPassword - } }) afterEach(async () => { - delete process.env.ADMIN_PASSWORD await stopServer() }) @@ -127,8 +186,8 @@ describe('admin router', () => { expect(rateLimiterMiddlewareStub.calledOnce).to.be.true }) - it('returns 401 for protected routes without a session', async () => { - const baseUrl = await startServer({ admin: { enabled: true } }) + it('returns 401 for protected routes without credentials', async () => { + const baseUrl = await startServer({ admin: { enabled: true, pubkeys: [pubkey] } }) const sessionResponse = await axios.get(`${baseUrl}/session`, { validateStatus: () => true }) const healthResponse = await axios.get(`${baseUrl}/health`, { validateStatus: () => true }) @@ -138,37 +197,68 @@ describe('admin router', () => { expect(rateLimiterMiddlewareStub.calledTwice).to.be.true }) - it('rejects invalid login credentials', async () => { - process.env.ADMIN_PASSWORD = 'correct-password' - const baseUrl = await startServer({ admin: { enabled: true } }) + it('rejects a login without an authorization header', async () => { + const baseUrl = await startServer({ admin: { enabled: true, pubkeys: [pubkey] } }) - const response = await axios.post( - `${baseUrl}/login`, - { password: 'wrong-password' }, - { - headers: { 'content-type': 'application/json' }, - validateStatus: () => true, - }, - ) + const response = await axios.post(`${baseUrl}/login`, undefined, { validateStatus: () => true }) expect(response.status).to.equal(401) - expect(rateLimiterMiddlewareStub.calledOnce).to.be.true + expect(response.data).to.deep.equal({ error: 'Unauthorized' }) expect(adminLoginRateLimitMiddlewareStub.calledOnce).to.be.true }) + it('rejects a login signed by a non-allowlisted pubkey', async () => { + const baseUrl = await startServer({ admin: { enabled: true, pubkeys: [pubkey] } }) + + const response = await axios.post(`${baseUrl}/login`, undefined, { + headers: { Authorization: await loginHeader(baseUrl, { privkey: 'b'.repeat(64) }) }, + validateStatus: () => true, + }) + + expect(response.status).to.equal(401) + }) + + it('rejects a login when the u tag does not match the request URL', async () => { + const baseUrl = await startServer({ admin: { enabled: true, pubkeys: [pubkey] } }) + + const response = await axios.post(`${baseUrl}/login`, undefined, { + headers: { Authorization: await loginHeader(baseUrl, { url: `${baseUrl}/health` }) }, + validateStatus: () => true, + }) + + expect(response.status).to.equal(401) + }) + + it('rejects a login when the method tag does not match', async () => { + const baseUrl = await startServer({ admin: { enabled: true, pubkeys: [pubkey] } }) + + const response = await axios.post(`${baseUrl}/login`, undefined, { + headers: { Authorization: await loginHeader(baseUrl, { method: 'GET' }) }, + validateStatus: () => true, + }) + + expect(response.status).to.equal(401) + }) + + it('rejects a login with a stale timestamp', async () => { + const baseUrl = await startServer({ admin: { enabled: true, pubkeys: [pubkey] } }) + + const response = await axios.post(`${baseUrl}/login`, undefined, { + headers: { Authorization: await loginHeader(baseUrl, { created_at: Math.floor(Date.now() / 1000) - 120 }) }, + validateStatus: () => true, + }) + + expect(response.status).to.equal(401) + }) + it('returns 500 when SECRET is missing during login', async () => { delete process.env.SECRET - process.env.ADMIN_PASSWORD = 'correct-password' - const baseUrl = await startServer({ admin: { enabled: true } }) - - const response = await axios.post( - `${baseUrl}/login`, - { password: 'correct-password' }, - { - headers: { 'content-type': 'application/json' }, - validateStatus: () => true, - }, - ) + const baseUrl = await startServer({ admin: { enabled: true, pubkeys: [pubkey] } }) + + const response = await axios.post(`${baseUrl}/login`, undefined, { + headers: { Authorization: await loginHeader(baseUrl) }, + validateStatus: () => true, + }) expect(response.status).to.equal(500) expect(response.data).to.deep.equal({ error: 'Internal Server Error' }) @@ -180,7 +270,7 @@ describe('admin router', () => { it('returns 500 when SECRET is missing during session validation', async () => { delete process.env.SECRET - const baseUrl = await startServer({ admin: { enabled: true } }) + const baseUrl = await startServer({ admin: { enabled: true, pubkeys: [pubkey] } }) const response = await axios.get(`${baseUrl}/session`, { headers: { cookie: 'admin_session=9999999999.deadbeef' }, @@ -195,22 +285,18 @@ describe('admin router', () => { expect(adminRateLimitMiddlewareStub.calledOnce).to.be.true }) - it('authenticates with ADMIN_PASSWORD and exposes session and health', async () => { - process.env.ADMIN_PASSWORD = 'correct-password' - const baseUrl = await startServer({ admin: { enabled: true, sessionTtlSeconds: 3600 } }) + it('authenticates with a NIP-98 login and exposes session and health', async () => { + const baseUrl = await startServer({ admin: { enabled: true, pubkeys: [pubkey], sessionTtlSeconds: 3600 } }) - const loginResponse = await axios.post( - `${baseUrl}/login`, - { password: 'correct-password' }, - { - headers: { 'content-type': 'application/json' }, - validateStatus: () => true, - }, - ) + const loginResponse = await axios.post(`${baseUrl}/login`, undefined, { + headers: { Authorization: await loginHeader(baseUrl) }, + validateStatus: () => true, + }) expect(loginResponse.status).to.equal(200) expect(loginResponse.data.authenticated).to.equal(true) expect(loginResponse.data.expiresAt).to.be.a('number') + expect(loginResponse.data.pubkey).to.equal(pubkey) expect(loginResponse.headers['set-cookie']?.[0]).to.include('admin_session=') const cookie = loginResponse.headers['set-cookie']?.[0]?.split(';')[0] @@ -234,20 +320,13 @@ describe('admin router', () => { expect(adminRateLimitMiddlewareStub.calledTwice).to.be.true }) - it('authenticates with passwordHash from settings', async () => { - const passwordHash = hashAdminPassword('settings-password') - const baseUrl = await startServer({ - admin: { enabled: true, passwordHash, sessionTtlSeconds: 3600 }, - }) + it('accepts the session token as a bearer token', async () => { + const baseUrl = await startServer({ admin: { enabled: true, pubkeys: [pubkey], sessionTtlSeconds: 3600 } }) - const loginResponse = await axios.post( - `${baseUrl}/login`, - { password: 'settings-password' }, - { - headers: { 'content-type': 'application/json' }, - validateStatus: () => true, - }, - ) + const loginResponse = await axios.post(`${baseUrl}/login`, undefined, { + headers: { Authorization: await loginHeader(baseUrl) }, + validateStatus: () => true, + }) expect(loginResponse.status).to.equal(200) @@ -258,9 +337,173 @@ describe('admin router', () => { }) expect(sessionResponse.status).to.equal(200) + }) - expect(rateLimiterMiddlewareStub.calledTwice).to.be.true - expect(adminLoginRateLimitMiddlewareStub.calledOnce).to.be.true - expect(adminRateLimitMiddlewareStub.calledOnce).to.be.true + it('authenticates protected routes with a per-request NIP-98 header', async () => { + const baseUrl = await startServer({ admin: { enabled: true, pubkeys: [pubkey] } }) + + const event = await createHttpAuthEvent({ url: `${baseUrl}/health`, method: 'GET' }) + const response = await axios.get(`${baseUrl}/health`, { + headers: { Authorization: toAuthorizationHeader(event) }, + validateStatus: () => true, + }) + + expect(response.status).to.equal(200) + expect(response.data).to.include.keys('status', 'uptimeSeconds', 'worker', 'database', 'redis') + }) + + it('rejects a replayed authorization header', async () => { + const baseUrl = await startServer({ admin: { enabled: true, pubkeys: [pubkey] } }) + + const header = await loginHeader(baseUrl) + + const firstResponse = await axios.post(`${baseUrl}/login`, undefined, { + headers: { Authorization: header }, + validateStatus: () => true, + }) + const secondResponse = await axios.post(`${baseUrl}/login`, undefined, { + headers: { Authorization: header }, + validateStatus: () => true, + }) + + expect(firstResponse.status).to.equal(200) + expect(secondResponse.status).to.equal(401) + }) + + it('verifies the payload tag against the raw request body', async () => { + const baseUrl = await startServer({ admin: { enabled: true, pubkeys: [pubkey] } }) + + const body = JSON.stringify({ hello: 'world' }) + const payload = createHash('sha256').update(body).digest('hex') + + const response = await axios.post(`${baseUrl}/login`, body, { + headers: { + Authorization: await loginHeader(baseUrl, { payload }), + 'content-type': 'application/json', + }, + validateStatus: () => true, + }) + + expect(response.status).to.equal(200) + + const mismatchResponse = await axios.post(`${baseUrl}/login`, JSON.stringify({ hello: 'tampered' }), { + headers: { + Authorization: await loginHeader(baseUrl, { payload }), + 'content-type': 'application/json', + }, + validateStatus: () => true, + }) + + expect(mismatchResponse.status).to.equal(401) + }) + + it('authenticates pubkeys allowlisted as npub', async () => { + const npub = toBech32('npub')(pubkey) + const baseUrl = await startServer({ admin: { enabled: true, pubkeys: [npub], sessionTtlSeconds: 3600 } }) + + const response = await axios.post(`${baseUrl}/login`, undefined, { + headers: { Authorization: await loginHeader(baseUrl) }, + validateStatus: () => true, + }) + + expect(response.status).to.equal(200) + expect(response.data.pubkey).to.equal(pubkey) + }) + + it('serves the login page without authentication', async () => { + const baseUrl = await startServer({ + admin: { enabled: true, pubkeys: [pubkey] }, + info: { name: 'Test Relay' }, + }) + + const response = await axios.get(`${baseUrl}/login`, { + headers: { Accept: 'text/html' }, + validateStatus: () => true, + }) + + expect(response.status).to.equal(200) + expect(response.headers['content-type']).to.include('text/html') + expect(response.data).to.include('Test Relay') + expect(response.data).to.include('window.nostr') + expect(response.data).to.include('Sign in with Nostr') + }) + + it('redirects unauthenticated browser navigations to the login page', async () => { + const baseUrl = await startServer({ admin: { enabled: true, pubkeys: [pubkey] } }) + + const rootResponse = await axios.get(`${baseUrl}/`, { + headers: { Accept: 'text/html,application/xhtml+xml' }, + maxRedirects: 0, + validateStatus: () => true, + }) + const unknownPathResponse = await axios.get(`${baseUrl}/some/unknown/page`, { + headers: { Accept: 'text/html,application/xhtml+xml' }, + maxRedirects: 0, + validateStatus: () => true, + }) + + expect(rootResponse.status).to.equal(302) + expect(rootResponse.headers.location).to.equal('/admin/login') + expect(unknownPathResponse.status).to.equal(302) + expect(unknownPathResponse.headers.location).to.equal('/admin/login') + }) + + it('returns 401 for unauthenticated non-browser requests to any admin path', async () => { + const baseUrl = await startServer({ admin: { enabled: true, pubkeys: [pubkey] } }) + + const response = await axios.get(`${baseUrl}/some/unknown/path`, { validateStatus: () => true }) + + expect(response.status).to.equal(401) + expect(response.data).to.deep.equal({ error: 'Unauthorized' }) + }) + + it('serves the dashboard to authenticated browsers and 404s unknown paths', async () => { + const baseUrl = await startServer({ + admin: { enabled: true, pubkeys: [pubkey], sessionTtlSeconds: 3600 }, + info: { name: 'Test Relay' }, + }) + + const loginResponse = await axios.post(`${baseUrl}/login`, undefined, { + headers: { Authorization: await loginHeader(baseUrl) }, + validateStatus: () => true, + }) + expect(loginResponse.status).to.equal(200) + const cookie = loginResponse.headers['set-cookie']?.[0]?.split(';')[0] + + const dashboardResponse = await axios.get(`${baseUrl}/`, { + headers: { Accept: 'text/html', cookie }, + validateStatus: () => true, + }) + expect(dashboardResponse.status).to.equal(200) + expect(dashboardResponse.headers['content-type']).to.include('text/html') + expect(dashboardResponse.data).to.include('Admin Console') + + const unknownResponse = await axios.get(`${baseUrl}/some/unknown/page`, { + headers: { Accept: 'text/html', cookie }, + validateStatus: () => true, + }) + expect(unknownResponse.status).to.equal(404) + }) + + it('clears the session cookie on logout', async () => { + const baseUrl = await startServer({ admin: { enabled: true, pubkeys: [pubkey], sessionTtlSeconds: 3600 } }) + + const loginResponse = await axios.post(`${baseUrl}/login`, undefined, { + headers: { Authorization: await loginHeader(baseUrl) }, + validateStatus: () => true, + }) + expect(loginResponse.status).to.equal(200) + const cookie = loginResponse.headers['set-cookie']?.[0]?.split(';')[0] + + const logoutResponse = await axios.post(`${baseUrl}/logout`, undefined, { + headers: { cookie }, + validateStatus: () => true, + }) + + expect(logoutResponse.status).to.equal(200) + expect(logoutResponse.data).to.deep.equal({ authenticated: false }) + const logoutCookie = logoutResponse.headers['set-cookie']?.[0] + expect(logoutCookie).to.include('admin_session=;') + expect(logoutCookie).to.include('Max-Age=0') }) }) diff --git a/test/unit/utils/nip98.spec.ts b/test/unit/utils/nip98.spec.ts new file mode 100644 index 00000000..c0cc1cd1 --- /dev/null +++ b/test/unit/utils/nip98.spec.ts @@ -0,0 +1,308 @@ +import { createHash } from 'crypto' +import { expect } from 'chai' + +import { Event } from '../../../src/@types/event' +import { EventKinds, EventTags } from '../../../src/constants/base' +import { getPublicKey, identifyEvent, signEvent } from '../../../src/utils/event' +import { + isNip98UrlMatch, + parseNip98AuthorizationHeader, + verifyNip98AuthorizationHeader, + verifyNip98Event, +} from '../../../src/utils/nip98' +import { Tag } from '../../../src/@types/base' + +describe('NIP-98', () => { + const privkey = 'a'.repeat(64) + const pubkey = getPublicKey(privkey) + const requestUrl = 'https://relay.example.com/admin/login' + const requestMethod = 'POST' + const now = Math.floor(Date.now() / 1000) + + async function createHttpAuthEvent(overrides: { + kind?: number + url?: string + method?: string + payload?: string + created_at?: number + omitUrlTag?: boolean + omitMethodTag?: boolean + invalidId?: boolean + invalidSig?: boolean + } = {}): Promise { + const tags: Tag[] = [] + if (!overrides.omitUrlTag) { + tags.push([EventTags.Url, overrides.url ?? requestUrl] as Tag) + } + if (!overrides.omitMethodTag) { + tags.push([EventTags.Method, overrides.method ?? requestMethod] as Tag) + } + if (typeof overrides.payload === 'string') { + tags.push([EventTags.Payload, overrides.payload] as Tag) + } + + const identified = await identifyEvent({ + pubkey, + created_at: overrides.created_at ?? now, + kind: overrides.kind ?? EventKinds.HTTP_AUTH, + tags, + content: '', + }) + + if (overrides.invalidId) { + identified.id = 'f'.repeat(64) + } + + if (overrides.invalidSig) { + return { ...identified, sig: '0'.repeat(128) } as Event + } + + return signEvent(privkey)(identified) + } + + const toHeader = (event: Event, scheme = 'Nostr'): string => + `${scheme} ${Buffer.from(JSON.stringify(event)).toString('base64')}` + + const context = { url: requestUrl, method: requestMethod } + + describe('parseNip98AuthorizationHeader', () => { + it('rejects a missing header', () => { + const result = parseNip98AuthorizationHeader(undefined) + + expect(result.ok).to.be.false + }) + + it('rejects non-Nostr schemes', () => { + const result = parseNip98AuthorizationHeader('Bearer some-token') + + expect(result.ok).to.be.false + }) + + it('rejects tokens with invalid base64 characters', () => { + const result = parseNip98AuthorizationHeader('Nostr $$$$') + + expect(result.ok).to.be.false + }) + + it('rejects tokens with invalid base64 length', () => { + const result = parseNip98AuthorizationHeader('Nostr abc') + + expect(result.ok).to.be.false + }) + + it('rejects tokens that do not decode to JSON', () => { + const token = Buffer.from('not json', 'utf8').toString('base64') + const result = parseNip98AuthorizationHeader(`Nostr ${token}`) + + expect(result.ok).to.be.false + }) + + it('rejects events that fail schema validation', async () => { + const event = await createHttpAuthEvent() + const token = Buffer.from(JSON.stringify({ ...event, extra: 'key' })).toString('base64') + const result = parseNip98AuthorizationHeader(`Nostr ${token}`) + + expect(result.ok).to.be.false + }) + + it('accepts a case-insensitive scheme', async () => { + const event = await createHttpAuthEvent() + const result = parseNip98AuthorizationHeader(toHeader(event, 'nostr')) + + expect(result.ok).to.be.true + }) + + it('parses a valid header', async () => { + const event = await createHttpAuthEvent() + const result = parseNip98AuthorizationHeader(toHeader(event)) + + expect(result.ok).to.be.true + if (result.ok) { + expect(result.event.pubkey).to.equal(pubkey) + expect(result.event.kind).to.equal(EventKinds.HTTP_AUTH) + } + }) + }) + + describe('isNip98UrlMatch', () => { + it('accepts an exact match', () => { + expect(isNip98UrlMatch(requestUrl, requestUrl)).to.be.true + }) + + it('accepts host case differences', () => { + expect(isNip98UrlMatch('https://RELAY.EXAMPLE.COM/admin/login', requestUrl)).to.be.true + }) + + it('accepts default port normalization', () => { + expect(isNip98UrlMatch('https://relay.example.com:443/admin/login', requestUrl)).to.be.true + }) + + it('rejects path mismatches', () => { + expect(isNip98UrlMatch('https://relay.example.com/admin/health', requestUrl)).to.be.false + }) + + it('rejects trailing slash differences', () => { + expect(isNip98UrlMatch('https://relay.example.com/admin/login/', requestUrl)).to.be.false + }) + + it('rejects scheme mismatches', () => { + expect(isNip98UrlMatch('http://relay.example.com/admin/login', requestUrl)).to.be.false + }) + + it('rejects query string mismatches', () => { + expect(isNip98UrlMatch(`${requestUrl}?a=1`, requestUrl)).to.be.false + expect(isNip98UrlMatch(`${requestUrl}?a=1&b=2`, `${requestUrl}?b=2&a=1`)).to.be.false + }) + + it('accepts matching query strings', () => { + expect(isNip98UrlMatch(`${requestUrl}?a=1&b=2`, `${requestUrl}?a=1&b=2`)).to.be.true + }) + + it('rejects unparseable URLs', () => { + expect(isNip98UrlMatch('not a url', requestUrl)).to.be.false + }) + }) + + describe('verifyNip98Event', () => { + it('accepts a valid event', async () => { + const event = await createHttpAuthEvent() + const result = await verifyNip98Event(event, context, { now }) + + expect(result.ok).to.be.true + if (result.ok) { + expect(result.event.pubkey).to.equal(pubkey) + } + }) + + it('rejects wrong event kinds', async () => { + const event = await createHttpAuthEvent({ kind: EventKinds.AUTH }) + const result = await verifyNip98Event(event, context, { now }) + + expect(result.ok).to.be.false + }) + + it('rejects tampered event ids', async () => { + const event = await createHttpAuthEvent({ invalidId: true }) + const result = await verifyNip98Event(event, context, { now }) + + expect(result.ok).to.be.false + }) + + it('rejects invalid signatures', async () => { + const event = await createHttpAuthEvent({ invalidSig: true }) + const result = await verifyNip98Event(event, context, { now }) + + expect(result.ok).to.be.false + }) + + it('rejects stale timestamps', async () => { + const event = await createHttpAuthEvent({ created_at: now - 61 }) + const result = await verifyNip98Event(event, context, { now }) + + expect(result.ok).to.be.false + }) + + it('rejects future timestamps', async () => { + const event = await createHttpAuthEvent({ created_at: now + 61 }) + const result = await verifyNip98Event(event, context, { now }) + + expect(result.ok).to.be.false + }) + + it('accepts timestamps within tolerance', async () => { + const event = await createHttpAuthEvent({ created_at: now - 59 }) + const result = await verifyNip98Event(event, context, { now }) + + expect(result.ok).to.be.true + }) + + it('honors a custom timestamp tolerance', async () => { + const event = await createHttpAuthEvent({ created_at: now - 120 }) + const result = await verifyNip98Event(event, context, { now, timestampToleranceSeconds: 300 }) + + expect(result.ok).to.be.true + }) + + it('rejects events without a u tag', async () => { + const event = await createHttpAuthEvent({ omitUrlTag: true }) + const result = await verifyNip98Event(event, context, { now }) + + expect(result.ok).to.be.false + }) + + it('rejects u tag mismatches', async () => { + const event = await createHttpAuthEvent({ url: 'https://relay.example.com/admin/health' }) + const result = await verifyNip98Event(event, context, { now }) + + expect(result.ok).to.be.false + }) + + it('rejects events without a method tag', async () => { + const event = await createHttpAuthEvent({ omitMethodTag: true }) + const result = await verifyNip98Event(event, context, { now }) + + expect(result.ok).to.be.false + }) + + it('rejects method tag mismatches', async () => { + const event = await createHttpAuthEvent({ method: 'GET' }) + const result = await verifyNip98Event(event, context, { now }) + + expect(result.ok).to.be.false + }) + + it('accepts case-insensitive method tags', async () => { + const event = await createHttpAuthEvent({ method: 'post' }) + const result = await verifyNip98Event(event, context, { now }) + + expect(result.ok).to.be.true + }) + + it('accepts a matching payload tag', async () => { + const body = 'request body' + const bodySha256Hex = createHash('sha256').update(body).digest('hex') + const event = await createHttpAuthEvent({ payload: bodySha256Hex }) + const result = await verifyNip98Event(event, { ...context, bodySha256Hex }, { now }) + + expect(result.ok).to.be.true + }) + + it('rejects payload tag mismatches', async () => { + const event = await createHttpAuthEvent({ payload: 'a'.repeat(64) }) + const bodySha256Hex = createHash('sha256').update('other body').digest('hex') + const result = await verifyNip98Event(event, { ...context, bodySha256Hex }, { now }) + + expect(result.ok).to.be.false + }) + + it('rejects a payload tag when no body hash is available', async () => { + const event = await createHttpAuthEvent({ payload: 'a'.repeat(64) }) + const result = await verifyNip98Event(event, context, { now }) + + expect(result.ok).to.be.false + }) + + it('accepts a body without a payload tag', async () => { + const bodySha256Hex = createHash('sha256').update('ignored body').digest('hex') + const event = await createHttpAuthEvent() + const result = await verifyNip98Event(event, { ...context, bodySha256Hex }, { now }) + + expect(result.ok).to.be.true + }) + }) + + describe('verifyNip98AuthorizationHeader', () => { + it('verifies a valid header end to end', async () => { + const event = await createHttpAuthEvent() + const result = await verifyNip98AuthorizationHeader(toHeader(event), context, { now }) + + expect(result.ok).to.be.true + }) + + it('propagates parse failures', async () => { + const result = await verifyNip98AuthorizationHeader('Bearer token', context, { now }) + + expect(result.ok).to.be.false + }) + }) +})