chore: initial commit
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user