Initial LisgloSIPS V2 implementation

This commit is contained in:
hectorzhao
2026-06-22 10:56:38 +08:00
commit 5fa1bd35e9
303 changed files with 35644 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import crypto from 'node:crypto';
import {
hashPasswordArgon2id,
randomToken,
sha256Token,
signAccessToken,
verifyAccessToken,
verifyPasswordArgon2id
} from './index.js';
describe('auth primitives', () => {
it('hashes and verifies Argon2id passwords with PHC metadata', async () => {
const secret = crypto.randomUUID();
const hash = await hashPasswordArgon2id(secret, {
salt: Buffer.alloc(16, 7),
memoryKiB: 1024,
passes: 1
});
expect(hash).toMatch(/^\$argon2id\$v=19\$m=1024,t=1,p=1\$/);
await expect(verifyPasswordArgon2id(secret, hash)).resolves.toBe(true);
await expect(verifyPasswordArgon2id(crypto.randomUUID(), hash)).resolves.toBe(false);
});
it('hashes refresh tokens without retaining token material', () => {
const token = randomToken();
const digest = sha256Token(token);
expect(token).not.toContain(digest);
expect(digest).toMatch(/^[a-f0-9]{64}$/);
});
it('signs and verifies expiring access tokens', () => {
const secret = 'test-only-access-token-secret-min-32-bytes';
const token = signAccessToken(
{
sub: 'usr_test',
username: 'operator',
roles: ['admin'],
typ: 'access'
},
{
secret,
issuer: 'lisglosips-api',
audience: 'lisglosips-web',
ttlSeconds: 60,
now: new Date('2026-06-21T00:00:00.000Z')
}
);
expect(
verifyAccessToken(token, {
secret,
issuer: 'lisglosips-api',
audience: 'lisglosips-web',
now: new Date('2026-06-21T00:00:30.000Z')
})?.sub
).toBe('usr_test');
expect(
verifyAccessToken(token, {
secret,
issuer: 'lisglosips-api',
audience: 'lisglosips-web',
now: new Date('2026-06-21T00:02:00.000Z')
})
).toBeNull();
});
});
+276
View File
@@ -0,0 +1,276 @@
export const AUTH_COOKIE_NAME = 'lisglosips_session';
export const REFRESH_COOKIE_NAME = 'lisglosips_refresh';
export const ACCESS_TOKEN_TYPE = 'Bearer';
export const PASSWORD_ALGO_ARGON2ID = 'argon2id';
export type PermissionKey =
| 'dashboard.view'
| 'customers.view'
| 'customers.manage'
| 'customer_gateways.view'
| 'customer_gateways.manage'
| 'vendors.view'
| 'vendors.manage'
| 'vendor_gateways.view'
| 'vendor_gateways.manage'
| 'line_groups.view'
| 'line_groups.manage'
| 'recharges.view'
| 'recharges.manage'
| 'cdr.view'
| 'recordings.play'
| 'quality.view'
| 'quality.manage'
| 'users.view'
| 'users.manage'
| 'roles.view'
| 'roles.manage'
| 'audit.view';
import crypto from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const ARGON2_MEMORY_KIB = 19_456;
const ARGON2_PASSES = 2;
const ARGON2_PARALLELISM = 1;
const ARGON2_TAG_LENGTH = 32;
const ARGON2_SALT_LENGTH = 16;
type NodeArgon2Sync = (
algorithm: 'argon2id',
options: {
message: string | Buffer;
nonce: Buffer;
parallelism: number;
tagLength: number;
memory: number;
passes: number;
}
) => Buffer;
type WasmArgon2idHash = (params: {
password: Uint8Array;
salt: Uint8Array;
parallelism: number;
passes: number;
memorySize: number;
tagLength: number;
}) => Uint8Array;
type WasmImports = Record<string, Record<string, unknown>>;
type WasmInstantiatedSource = {
instance: {
exports: Record<string, unknown>;
};
};
type WasmGlobal = {
WebAssembly: {
instantiate(bytes: Buffer, imports: WasmImports): Promise<WasmInstantiatedSource>;
};
};
let wasmArgon2id: Promise<WasmArgon2idHash> | null = null;
function b64url(input: Buffer | string): string {
return Buffer.from(input).toString('base64url');
}
function fromB64url(input: string): Buffer {
return Buffer.from(input, 'base64url');
}
export interface Argon2idPasswordHashOptions {
memoryKiB?: number;
passes?: number;
parallelism?: number;
tagLength?: number;
salt?: Buffer;
}
async function loadWasmArgon2id(): Promise<WasmArgon2idHash> {
if (!wasmArgon2id) {
wasmArgon2id = (async () => {
const baseDir = dirname(fileURLToPath(import.meta.url));
const setupModule = (await import(pathToFileURL(join(baseDir, '../vendor/argon2id/lib/setup.js')).href)) as {
default: (
getSIMD: (imports: WasmImports) => Promise<WasmInstantiatedSource>,
getNonSIMD: (imports: WasmImports) => Promise<WasmInstantiatedSource>
) => Promise<WasmArgon2idHash>;
};
const wasm = (globalThis as unknown as WasmGlobal).WebAssembly;
const instantiate = async (fileName: string, imports: WasmImports): Promise<WasmInstantiatedSource> =>
wasm.instantiate(await readFile(join(baseDir, '../vendor/argon2id/dist', fileName)), imports);
return setupModule.default(
(imports) => instantiate('simd.wasm', imports),
(imports) => instantiate('no-simd.wasm', imports)
);
})();
}
return wasmArgon2id;
}
async function computeArgon2idDigest(params: {
password: string;
salt: Buffer;
parallelism: number;
tagLength: number;
memory: number;
passes: number;
}): Promise<Buffer> {
const nodeArgon2 = (crypto as unknown as { argon2Sync?: NodeArgon2Sync }).argon2Sync;
if (typeof nodeArgon2 === 'function') {
return nodeArgon2('argon2id', {
message: params.password,
nonce: params.salt,
parallelism: params.parallelism,
tagLength: params.tagLength,
memory: params.memory,
passes: params.passes
});
}
const wasmHash = await loadWasmArgon2id();
return Buffer.from(
wasmHash({
password: Buffer.from(params.password, 'utf8'),
salt: params.salt,
parallelism: params.parallelism,
tagLength: params.tagLength,
memorySize: params.memory,
passes: params.passes
})
);
}
export async function hashPasswordArgon2id(password: string, options: Argon2idPasswordHashOptions = {}): Promise<string> {
if (password.length === 0) {
throw new Error('Password must not be empty.');
}
const memory = options.memoryKiB ?? ARGON2_MEMORY_KIB;
const passes = options.passes ?? ARGON2_PASSES;
const parallelism = options.parallelism ?? ARGON2_PARALLELISM;
const tagLength = options.tagLength ?? ARGON2_TAG_LENGTH;
const salt = options.salt ?? crypto.randomBytes(ARGON2_SALT_LENGTH);
const digest = await computeArgon2idDigest({
password,
salt,
parallelism,
tagLength,
memory,
passes
});
return `$argon2id$v=19$m=${memory},t=${passes},p=${parallelism}$${b64url(salt)}$${b64url(digest)}`;
}
export async function verifyPasswordArgon2id(password: string, storedHash: string): Promise<boolean> {
const parts = storedHash.split('$');
if (parts.length !== 6 || parts[1] !== 'argon2id' || parts[2] !== 'v=19') {
return false;
}
const params = Object.fromEntries(
parts[3].split(',').map((pair) => {
const [key, value] = pair.split('=');
return [key, Number(value)];
})
);
if (!params.m || !params.t || !params.p) {
return false;
}
const salt = fromB64url(parts[4]);
const expected = fromB64url(parts[5]);
const actual = await computeArgon2idDigest({
password,
salt,
parallelism: params.p,
tagLength: expected.length,
memory: params.m,
passes: params.t
});
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
}
export function randomToken(byteLength = 48): string {
return crypto.randomBytes(byteLength).toString('base64url');
}
export function sha256Token(token: string): string {
return crypto.createHash('sha256').update(token, 'utf8').digest('hex');
}
export interface AccessTokenPayload {
sub: string;
username: string;
roles: string[];
typ: 'access';
}
export interface SignedAccessTokenPayload extends AccessTokenPayload {
iss: string;
aud: string;
iat: number;
exp: number;
}
export function signAccessToken(
payload: AccessTokenPayload,
options: {
secret: string;
issuer: string;
audience: string;
ttlSeconds: number;
now?: Date;
}
): string {
const nowSeconds = Math.floor((options.now ?? new Date()).getTime() / 1000);
const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const body = b64url(
JSON.stringify({
...payload,
iss: options.issuer,
aud: options.audience,
iat: nowSeconds,
exp: nowSeconds + options.ttlSeconds
} satisfies SignedAccessTokenPayload)
);
const signature = crypto.createHmac('sha256', options.secret).update(`${header}.${body}`).digest('base64url');
return `${header}.${body}.${signature}`;
}
export function verifyAccessToken(
token: string,
options: {
secret: string;
issuer: string;
audience: string;
now?: Date;
}
): SignedAccessTokenPayload | null {
const [header, body, signature] = token.split('.');
if (!header || !body || !signature) {
return null;
}
const expected = crypto.createHmac('sha256', options.secret).update(`${header}.${body}`).digest('base64url');
if (expected.length !== signature.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
return null;
}
const payload = JSON.parse(fromB64url(body).toString('utf8')) as SignedAccessTokenPayload;
const nowSeconds = Math.floor((options.now ?? new Date()).getTime() / 1000);
if (payload.typ !== 'access' || payload.iss !== options.issuer || payload.aud !== options.audience || payload.exp <= nowSeconds) {
return null;
}
return payload;
}