Files
forward_server/utils/token_utils.ts
T
2026-10-22 17:07:08 +08:00

90 lines
4.6 KiB
TypeScript

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<T = unknown>(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;
}