import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; import type {RelayClaims, RelayLimits} from '../types.ts'; import {check_permissions, check_size, fail, is_record, is_uuid, validate_scope} from './relay_validation.ts'; const IV_LEN = 12; const TAG_LEN = 16; function derive_key(secret: string): Buffer { return createHash('sha256').update(secret).digest(); } export function encode_token(secret: string, data: unknown): string { const key = derive_key(secret); const iv = randomBytes(IV_LEN); const cipher = createCipheriv('aes-256-gcm', key, iv); const plain = Buffer.from(JSON.stringify(data), 'utf8'); const ciphertext = Buffer.concat([cipher.update(plain), cipher.final()]); const tag = cipher.getAuthTag(); return Buffer.concat([iv, tag, ciphertext]).toString('base64url'); } export function decode_token(secret: string, token: string): T { if (typeof token !== 'string' || !/^[A-Za-z0-9_-]+$/.test(token) || token.length % 4 === 1) { throw new Error('invalid token'); } const key = derive_key(secret); const raw = Buffer.from(token, 'base64url'); if (raw.length <= IV_LEN + TAG_LEN || raw.toString('base64url') !== token) throw new Error('invalid token'); const iv = raw.subarray(0, IV_LEN); const tag = raw.subarray(IV_LEN, IV_LEN + TAG_LEN); const ciphertext = raw.subarray(IV_LEN + TAG_LEN); const decipher = createDecipheriv('aes-256-gcm', key, iv); decipher.setAuthTag(tag); const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]); return JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(plain)) as T; } export function verify_token(secret: string, token: string, forward_id: number, limits: RelayLimits, now = Date.now()): RelayClaims { const safe_time = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; if (!safe_time(now) || !safe_time(forward_id) || !safe_time(limits.max_token_bytes) || limits.max_token_bytes === 0 || !safe_time(limits.max_token_age_ms) || limits.max_token_age_ms === 0 || !safe_time(limits.join_window_ms) || limits.join_window_ms === 0 || !safe_time(limits.clock_skew_ms)) { fail('UNAUTHORIZED', 'Invalid token verification limits'); } if (typeof token !== 'string' || token.length > limits.max_token_bytes || !/^[A-Za-z0-9_-]+$/.test(token)) { fail('UNAUTHORIZED', 'Invalid relay token'); } let claims: unknown; try { claims = decode_token(secret, token); check_size(claims, limits.max_token_bytes); if (!is_record(claims)) throw new Error('Invalid claims'); const fields = claims; const required = ['v', 'forward_id', 'forward_session_id', 'node_session_id', 'node_id', 'bot_id', 'role', 'permissions', 'issued_at', 'join_before', 'expires_at', 'jti']; const allowed = [...required, 'user_id', 'grant_id']; if (!required.every(key => Object.hasOwn(fields, key)) || !Object.keys(fields).every(key => allowed.includes(key))) throw new Error('Invalid claims'); validate_scope(claims); check_permissions(claims.permissions); if (claims.v !== 1 || claims.forward_id !== forward_id || !is_uuid(claims.jti) || (claims.role !== 'node' && claims.role !== 'web')) throw new Error('Invalid claims'); if (claims.role === 'web') { if (!safe_time(claims.user_id) || claims.user_id === 0 || !is_uuid(claims.grant_id)) { throw new Error('Invalid web claims'); } } else if (Object.hasOwn(claims, 'user_id') || Object.hasOwn(claims, 'grant_id')) { throw new Error('Invalid node claims'); } if (!safe_time(claims.issued_at) || !safe_time(claims.join_before) || !safe_time(claims.expires_at) || claims.issued_at >= claims.join_before || claims.join_before > claims.expires_at || claims.expires_at - claims.issued_at > limits.max_token_age_ms || claims.join_before - claims.issued_at > limits.join_window_ms || claims.issued_at - now > limits.clock_skew_ms) throw new Error('Invalid token times'); } catch { fail('UNAUTHORIZED', 'Invalid relay token'); } // Persisted session and grant authorization is deliberately checked by the store. const verified = claims as unknown as RelayClaims; if (now - verified.expires_at >= limits.clock_skew_ms || now - verified.join_before >= limits.clock_skew_ms) { fail('TOKEN_EXPIRED', 'Relay token expired'); } return verified; }