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
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@lisglosips/auth",
"version": "0.2.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json"
}
}
+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;
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
Copyright (c) 2022, Proton AG
Copyright (c) 2017, Emil Bay github@tixz.dk (for original blake2b code from https://github.com/emilbayes/blake2b)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+73
View File
@@ -0,0 +1,73 @@
# Argon2id
Fast, lightweight Argon2id implementation for both browser and Node:
- optimized for bundle size (< 7KB minified and gizipped, with wasm inlined as base64)
- SIMD support, with automatic fallback to non-SIMD binary if not supported (e.g. in Safari)
- performance is comparable to or better than [argon2-browser](https://github.com/antelle/argon2-browser).
We initially tried implementing a solution in pure JS (no Wasm) but the running time was unacceptable.
We resorted to implement part of the module in Wasm, to take advantage of 64-bit multiplications and SIMD instructions. The Wasm binary remains small thanks to the fact that the memory is fully managed by the JS side, hence no memory management function gets included in the Wasm binary.
## Install
Install from npm (compiled wasm files included):
```sh
npm i argon2id
```
## Usage
With bundlers like Rollup (through [plugin-wasm](https://www.npmjs.com/package/@rollup/plugin-wasm)) or Webpack (through [wasm-loader](https://www.npmjs.com/package/wasm-loader)), that automatically translate import statements like `import wasmModule from '*.wasm'` to a loader of type `wasmModule: (instanceOptions) => WebAssembly.WebAssemblyInstantiatedSource` (either sync or async), you can simply use the default export like so:
```js
import loadArgon2idWasm from 'argon2id';
const argon2id = await loadArgon2idWasm();
const hash = argon2id({
password: new Uint8Array(...),
salt: crypto.getRandomValues(new Uint8Array(32)),
parallelism: 4,
passes: 3,
memorySize: 2**16
});
```
Refer to the [Argon2 RFC](https://www.rfc-editor.org/rfc/rfc9106.html#name-parameter-choice) for details about how to pick the parameters.
**Note about memory usage:** every call to `loadArgon2idWasm` will instantiate and run a separate Wasm instance, with separate memory.
The used Wasm memory is cleared after each call to `argon2id`, but it isn't deallocated (this is due to Wasm limitations).
Re-loading the Wasm module is thus recommended in order to free the memory if multiple `argon2id` hashes are computed and some of them require considerably more memory than the rest.
### Custom Wasm loaders
The library does not require a particular toolchain. If the aforementioned bundlers are not an option, you can manually take care of setting up the Wasm modules.
For instance, **in Node, the library can be used without bundlers**. You will need to pass two functions that instantiate the Wasm modules to `setupWasm` (first function is expected to take care of the SIMD binary, second one the non-SIMD one):
```js
import fs from 'fs';
import setupWasm from 'argon2id/lib/setup.js';
// point to compiled binaries
const SIMD_FILENAME = 'argon2id/dist/simd.wasm';
const NON_SIMD_FILENAME = 'argon2id/dist/no-simd.wasm';
const argon2id = await setupWasm(
(importObject) => WebAssembly.instantiate(fs.readFileSync(SIMD_FILENAME), importObject),
(importObject) => WebAssembly.instantiate(fs.readFileSync(NON_SIMD_FILENAME), importObject),
);
```
Using the same principle, for browsers you can use bundlers with simple base-64 file loaders.
## Compiling
**The npm package already includes the compiled binaries.**<br>
If you fork the repo, you'll have to manually compile wasm (Docker required):
```sh
npm run build
```
The resulting binaries will be under `dist/`.
If you do not want to use docker, you can look into installing [emscripten](https://emscripten.org/); you'll find the compilation commands to use in `build_wasm.sh`.
+16
View File
@@ -0,0 +1,16 @@
# Vendored argon2id
- Package: `argon2id`
- Version: `1.0.1`
- Source: `https://www.npmjs.com/package/argon2id`
- Repository: `https://github.com/openpgpjs/argon2id`
- License: MIT, see `LICENSE`.
This package is vendored because the local npm registry path was unavailable during S09, while Server B runs Node.js 22 and does not expose built-in `crypto.argon2Sync`. The project code first uses Node's native Argon2id when present and falls back to this WASM implementation.
WASM SHA-256:
```text
dist/no-simd.wasm 1F16D8DE5A6D8A3A4FAD5885C4784F57713D3626FD7D55F852FD79ECADBC4F8E
dist/simd.wasm 50647E10FC8E0ADBA4F60726F3F0D77DB0E3C73516B05A83AD10390B3A8B4B0E
```
+13
View File
@@ -0,0 +1,13 @@
import type { computeHash, Argon2idParams } from "./lib/setup";
/**
* Setup an `argon2id` instance, by loading the Wasm module that is used under the hood. The SIMD version is used as long as the platform supports it.
* The loaded module is then cached across `argon2id` calls.
* NB: the used Wasm memory is cleared across runs, but not de-allocated.
* Re-loading is thus recommended in order to free the memory if multiple `argon2id` hashes are computed
* and some of them require considerably more memory than the rest.
* @returns argon2id function
*/
export function loadWasm(): Promise<computeHash>;
export default loadWasm;
export type { Argon2idParams, computeHash };
+10
View File
@@ -0,0 +1,10 @@
import setupWasm from './lib/setup.js';
import wasmSIMD from './dist/simd.wasm';
import wasmNonSIMD from './dist/no-simd.wasm';
const loadWasm = async () => setupWasm(
(instanceObject) => wasmSIMD(instanceObject),
(instanceObject) => wasmNonSIMD(instanceObject),
);
export default loadWasm;
+354
View File
@@ -0,0 +1,354 @@
import blake2b from "./blake2b.js"
const TYPE = 2; // Argon2id
const VERSION = 0x13;
const TAGBYTES_MAX = 0xFFFFFFFF; // Math.pow(2, 32) - 1;
const TAGBYTES_MIN = 4; // Math.pow(2, 32) - 1;
const SALTBYTES_MAX = 0xFFFFFFFF; // Math.pow(2, 32) - 1;
const SALTBYTES_MIN = 8;
const passwordBYTES_MAX = 0xFFFFFFFF;// Math.pow(2, 32) - 1;
const passwordBYTES_MIN = 8;
const MEMBYTES_MAX = 0xFFFFFFFF;// Math.pow(2, 32) - 1;
const ADBYTES_MAX = 0xFFFFFFFF; // Math.pow(2, 32) - 1; // associated data (optional)
const SECRETBYTES_MAX = 32; // key (optional)
const ARGON2_BLOCK_SIZE = 1024;
const ARGON2_PREHASH_DIGEST_LENGTH = 64;
const isLittleEndian = new Uint8Array(new Uint16Array([0xabcd]).buffer)[0] === 0xcd;
// store n as a little-endian 32-bit Uint8Array inside buf (at buf[i:i+3])
function LE32(buf, n, i) {
buf[i+0] = n;
buf[i+1] = n >> 8;
buf[i+2] = n >> 16;
buf[i+3] = n >> 24;
return buf;
}
/**
* Store n as a 64-bit LE number in the given buffer (from buf[i] to buf[i+7])
* @param {Uint8Array} buf
* @param {Number} n
* @param {Number} i
*/
function LE64(buf, n, i) {
if (n > Number.MAX_SAFE_INTEGER) throw new Error("LE64: large numbers unsupported");
// ECMAScript standard has engines convert numbers to 32-bit integers for bitwise operations
// shifting by 32 or more bits is not supported (https://stackoverflow.com/questions/6729122/javascript-bit-shift-number-wraps)
// so we manually extract each byte
let remainder = n;
for (let offset = i; offset < i+7; offset++) { // last byte can be ignored as it would overflow MAX_SAFE_INTEGER
buf[offset] = remainder; // implicit & 0xff
remainder = (remainder - buf[offset]) / 256;
}
return buf;
}
/**
* Variable-Length Hash Function H'
* @param {Number} outlen - T
* @param {Uint8Array} X - value to hash
* @param {Uint8Array} res - output buffer, of length `outlength` or larger
*/
function H_(outlen, X, res) {
const V = new Uint8Array(64); // no need to keep around all V_i
const V1_in = new Uint8Array(4 + X.length);
LE32(V1_in, outlen, 0);
V1_in.set(X, 4);
if (outlen <= 64) {
// H'^T(A) = H^T(LE32(T)||A)
blake2b(outlen).update(V1_in).digest(res);
return res
}
const r = Math.ceil(outlen / 32) - 2;
// Let V_i be a 64-byte block and W_i be its first 32 bytes.
// V_1 = H^(64)(LE32(T)||A)
// V_2 = H^(64)(V_1)
// ...
// V_r = H^(64)(V_{r-1})
// V_{r+1} = H^(T-32*r)(V_{r})
// H'^T(X) = W_1 || W_2 || ... || W_r || V_{r+1}
for (let i = 0; i < r; i++) {
blake2b(64).update(i === 0 ? V1_in : V).digest(V);
// store W_i in result buffer already
res.set(V.subarray(0, 32), i*32)
}
// V_{r+1}
const V_r1 = new Uint8Array(blake2b(outlen - 32*r).update(V).digest());
res.set(V_r1, r*32);
return res;
}
// compute buf = xs ^ ys
function XOR(wasmContext, buf, xs, ys) {
wasmContext.fn.XOR(
buf.byteOffset,
xs.byteOffset,
ys.byteOffset,
);
return buf
}
/**
* @param {Uint8Array} X (read-only)
* @param {Uint8Array} Y (read-only)
* @param {Uint8Array} R - output buffer
* @returns
*/
function G(wasmContext, X, Y, R) {
wasmContext.fn.G(
X.byteOffset,
Y.byteOffset,
R.byteOffset,
wasmContext.refs.gZ.byteOffset
);
return R;
}
function G2(wasmContext, X, Y, R) {
wasmContext.fn.G2(
X.byteOffset,
Y.byteOffset,
R.byteOffset,
wasmContext.refs.gZ.byteOffset
);
return R;
}
// Generator for data-independent J1, J2. Each `next()` invocation returns a new pair of values.
function* makePRNG(wasmContext, pass, lane, slice, m_, totalPasses, segmentLength, segmentOffset) {
// For each segment, we do the following. First, we compute the value Z as:
// Z= ( LE64(r) || LE64(l) || LE64(sl) || LE64(m') || LE64(t) || LE64(y) )
wasmContext.refs.prngTmp.fill(0);
const Z = wasmContext.refs.prngTmp.subarray(0, 6 * 8);
LE64(Z, pass, 0);
LE64(Z, lane, 8);
LE64(Z, slice, 16);
LE64(Z, m_, 24);
LE64(Z, totalPasses, 32);
LE64(Z, TYPE, 40);
// Then we compute q/(128*SL) 1024-byte values
// G( ZERO(1024),
// G( ZERO(1024), Z || LE64(1) || ZERO(968) ) ),
// ...,
// G( ZERO(1024),
// G( ZERO(1024), Z || LE64(q/(128*SL)) || ZERO(968) )),
for(let i = 1; i <= segmentLength; i++) {
// tmp.set(Z); // no need to re-copy
LE64(wasmContext.refs.prngTmp, i, Z.length); // tmp.set(ZER0968) not necessary, memory already zeroed
const g2 = G2(wasmContext, wasmContext.refs.ZERO1024, wasmContext.refs.prngTmp, wasmContext.refs.prngR );
// each invocation of G^2 outputs 1024 bytes that are to be partitioned into 8-bytes values, take as X1 || X2
// NB: the first generated pair must be used for the first block of the segment, and so on.
// Hence, if some blocks are skipped (e.g. during the first pass), the corresponding J1J2 are discarded based on the given segmentOffset.
for(let k = i === 1 ? segmentOffset*8 : 0; k < g2.length; k += 8) {
yield g2.subarray(k, k+8);
}
}
return [];
}
function validateParams({ type, version, tagLength, password, salt, ad, secret, parallelism, memorySize, passes }) {
const assertLength = (name, value, min, max) => {
if (value < min || value > max) { throw new Error(`${name} size should be between ${min} and ${max} bytes`); }
}
if (type !== TYPE || version !== VERSION) throw new Error('Unsupported type or version');
assertLength('password', password, passwordBYTES_MIN, passwordBYTES_MAX);
assertLength('salt', salt, SALTBYTES_MIN, SALTBYTES_MAX);
assertLength('tag', tagLength, TAGBYTES_MIN, TAGBYTES_MAX);
assertLength('memory', memorySize, 8*parallelism, MEMBYTES_MAX);
// optional fields
ad && assertLength('associated data', ad, 0, ADBYTES_MAX);
secret && assertLength('secret', secret, 0, SECRETBYTES_MAX);
return { type, version, tagLength, password, salt, ad, secret, lanes: parallelism, memorySize, passes };
}
const KB = 1024;
const WASM_PAGE_SIZE = 64 * KB;
export default function argon2id(params, { memory, instance: wasmInstance }) {
if (!isLittleEndian) throw new Error('BigEndian system not supported'); // optmisations assume LE system
const ctx = validateParams({ type: TYPE, version: VERSION, ...params });
const { G:wasmG, G2:wasmG2, xor:wasmXOR, getLZ:wasmLZ } = wasmInstance.exports;
const wasmRefs = {};
const wasmFn = {};
wasmFn.G = wasmG;
wasmFn.G2 = wasmG2;
wasmFn.XOR = wasmXOR;
// The actual number of blocks is m', which is m rounded down to the nearest multiple of 4*p.
const m_ = 4 * ctx.lanes * Math.floor(ctx.memorySize / (4 * ctx.lanes));
const requiredMemory = m_ * ARGON2_BLOCK_SIZE + 10 * KB; // Additional KBs for utility references
if (memory.buffer.byteLength < requiredMemory) {
const missing = Math.ceil((requiredMemory - memory.buffer.byteLength) / WASM_PAGE_SIZE)
// If enough memory is available, the `memory.buffer` is internally detached and the reference updated.
// Otherwise, the operation fails, and the original memory can still be used.
memory.grow(missing)
}
let offset = 0;
// Init wasm memory needed in other functions
wasmRefs.gZ = new Uint8Array(memory.buffer, offset, ARGON2_BLOCK_SIZE); offset+= wasmRefs.gZ.length;
wasmRefs.prngR = new Uint8Array(memory.buffer, offset, ARGON2_BLOCK_SIZE); offset+=wasmRefs.prngR.length;
wasmRefs.prngTmp = new Uint8Array(memory.buffer, offset, ARGON2_BLOCK_SIZE); offset+=wasmRefs.prngTmp.length;
wasmRefs.ZERO1024 = new Uint8Array(memory.buffer, offset, 1024); offset+=wasmRefs.ZERO1024.length;
// Init wasm memory needed locally
const lz = new Uint32Array(memory.buffer, offset, 2); offset+=lz.length * Uint32Array.BYTES_PER_ELEMENT;
const wasmContext = { fn: wasmFn, refs: wasmRefs };
const newBlock = new Uint8Array(memory.buffer, offset, ARGON2_BLOCK_SIZE); offset+=newBlock.length;
const blockMemory = new Uint8Array(memory.buffer, offset, ctx.memorySize * ARGON2_BLOCK_SIZE);
const allocatedMemory = new Uint8Array(memory.buffer, 0, offset);
// 1. Establish H_0
const H0 = getH0(ctx);
// 2. Allocate the memory as m' 1024-byte blocks
// For p lanes, the memory is organized in a matrix B[i][j] of blocks with p rows (lanes) and q = m' / p columns.
const q = m_ / ctx.lanes;
const B = new Array(ctx.lanes).fill(null).map(() => new Array(q));
const initBlock = (i, j) => {
B[i][j] = blockMemory.subarray(i*q*1024 + j*1024, (i*q*1024 + j*1024) + ARGON2_BLOCK_SIZE);
return B[i][j];
}
for (let i = 0; i < ctx.lanes; i++) {
// const LEi = LE0; // since p = 1 for us
const tmp = new Uint8Array(H0.length + 8);
// 3. Compute B[i][0] for all i ranging from (and including) 0 to (not including) p
// B[i][0] = H'^(1024)(H_0 || LE32(0) || LE32(i))
tmp.set(H0); LE32(tmp, 0, H0.length); LE32(tmp, i, H0.length + 4);
H_(ARGON2_BLOCK_SIZE, tmp, initBlock(i, 0));
// 4. Compute B[i][1] for all i ranging from (and including) 0 to (not including) p
// B[i][1] = H'^(1024)(H_0 || LE32(1) || LE32(i))
LE32(tmp, 1, H0.length);
H_(ARGON2_BLOCK_SIZE, tmp, initBlock(i, 1));
}
// 5. Compute B[i][j] for all i ranging from (and including) 0 to (not including) p and for all j ranging from (and including) 2
// to (not including) q. The computation MUST proceed slicewise (Section 3.4) : first, blocks from slice 0 are computed for all lanes
// (in an arbitrary order of lanes), then blocks from slice 1 are computed, etc.
const SL = 4; // vertical slices
const segmentLength = q / SL;
for (let pass = 0; pass < ctx.passes; pass++) {
// The intersection of a slice and a lane is called a segment, which has a length of q/SL. Segments of the same slice can be computed in parallel
for (let sl = 0; sl < SL; sl++) {
const isDataIndependent = pass === 0 && sl <= 1;
for (let i = 0; i < ctx.lanes; i++) { // lane
// On the first slice of the first pass, blocks 0 and 1 are already filled
let segmentOffset = sl === 0 && pass === 0 ? 2 : 0;
// no need to generate all J1J2s, use iterator/generator that creates the value on the fly (to save memory)
const PRNG = isDataIndependent ? makePRNG(wasmContext, pass, i, sl, m_, ctx.passes, segmentLength, segmentOffset) : null;
for (segmentOffset; segmentOffset < segmentLength; segmentOffset++) {
const j = sl * segmentLength + segmentOffset;
const prevBlock = j > 0 ? B[i][j-1] : B[i][q-1]; // B[i][(j-1) mod q]
// we can assume the PRNG is never done
const J1J2 = isDataIndependent ? PRNG.next().value : prevBlock; // .subarray(0, 8) not required since we only pass the byteOffset to wasm
// The block indices l and z are determined for each i, j differently for Argon2d, Argon2i, and Argon2id.
wasmLZ(lz.byteOffset, J1J2.byteOffset, i, ctx.lanes, pass, sl, segmentOffset, SL, segmentLength)
const l = lz[0]; const z = lz[1];
// for (let i = 0; i < p; i++ )
// B[i][j] = G(B[i][j-1], B[l][z])
// The block indices l and z are determined for each i, j differently for Argon2d, Argon2i, and Argon2id.
if (pass === 0) initBlock(i, j);
G(wasmContext, prevBlock, B[l][z], pass > 0 ? newBlock : B[i][j]);
// 6. If the number of passes t is larger than 1, we repeat step 5. However, blocks are computed differently as the old value is XORed with the new one
if (pass > 0) XOR(wasmContext, B[i][j], newBlock, B[i][j])
}
}
}
}
// 7. After t steps have been iterated, the final block C is computed as the XOR of the last column:
// C = B[0][q-1] XOR B[1][q-1] XOR ... XOR B[p-1][q-1]
const C = B[0][q-1];
for(let i = 1; i < ctx.lanes; i++) {
XOR(wasmContext, C, C, B[i][q-1])
}
const tag = H_(ctx.tagLength, C, new Uint8Array(ctx.tagLength));
// clear memory since the module might be cached
allocatedMemory.fill(0) // clear sensitive contents
memory.grow(0) // allow deallocation
// 8. The output tag is computed as H'^T(C).
return tag;
}
function getH0(ctx) {
const H = blake2b(ARGON2_PREHASH_DIGEST_LENGTH);
const ZERO32 = new Uint8Array(4);
const params = new Uint8Array(24);
LE32(params, ctx.lanes, 0);
LE32(params, ctx.tagLength, 4);
LE32(params, ctx.memorySize, 8);
LE32(params, ctx.passes, 12);
LE32(params, ctx.version, 16);
LE32(params, ctx.type, 20);
const toHash = [params];
if (ctx.password) {
toHash.push(LE32(new Uint8Array(4), ctx.password.length, 0))
toHash.push(ctx.password)
} else {
toHash.push(ZERO32) // context.password.length
}
if (ctx.salt) {
toHash.push(LE32(new Uint8Array(4), ctx.salt.length, 0))
toHash.push(ctx.salt)
} else {
toHash.push(ZERO32) // context.salt.length
}
if (ctx.secret) {
toHash.push(LE32(new Uint8Array(4), ctx.secret.length, 0))
toHash.push(ctx.secret)
// todo clear secret?
} else {
toHash.push(ZERO32) // context.secret.length
}
if (ctx.ad) {
toHash.push(LE32(new Uint8Array(4), ctx.ad.length, 0))
toHash.push(ctx.ad)
} else {
toHash.push(ZERO32) // context.ad.length
}
H.update(concatArrays(toHash))
const outputBuffer = H.digest();
return new Uint8Array(outputBuffer);
}
function concatArrays(arrays) {
if (arrays.length === 1) return arrays[0];
let totalLength = 0;
for (let i = 0; i < arrays.length; i++) {
if (!(arrays[i] instanceof Uint8Array)) {
throw new Error('concatArrays: Data must be in the form of a Uint8Array');
}
totalLength += arrays[i].length;
}
const result = new Uint8Array(totalLength);
let pos = 0;
arrays.forEach((element) => {
result.set(element, pos);
pos += element.length;
});
return result;
}
+256
View File
@@ -0,0 +1,256 @@
// Adapted from the reference implementation in RFC7693
// Initial port to Javascript by https://github.com/dcposch and https://github.com/emilbayes
// Uint64 values are represented using two Uint32s, stored as little endian
// NB: Uint32Arrays endianness depends on the underlying system, so for interoperability, conversions between Uint8Array and Uint32Arrays
// need to be manually handled
// 64-bit unsigned addition (little endian, in place)
// Sets a[i,i+1] += b[j,j+1]
// `a` and `b` must be Uint32Array(2)
function ADD64 (a, i, b, j) {
a[i] += b[j];
a[i+1] += b[j+1] + (a[i] < b[j]); // add carry
}
// Increment 64-bit little-endian unsigned value by `c` (in place)
// `a` must be Uint32Array(2)
function INC64 (a, c) {
a[0] += c;
a[1] += (a[0] < c);
}
// G Mixing function
// The ROTRs are inlined for speed
function G (v, m, a, b, c, d, ix, iy) {
ADD64(v, a, v, b) // v[a,a+1] += v[b,b+1]
ADD64(v, a, m, ix) // v[a, a+1] += x ... x0
// v[d,d+1] = (v[d,d+1] xor v[a,a+1]) rotated to the right by 32 bits
let xor0 = v[d] ^ v[a]
let xor1 = v[d + 1] ^ v[a + 1]
v[d] = xor1
v[d + 1] = xor0
ADD64(v, c, v, d)
// v[b,b+1] = (v[b,b+1] xor v[c,c+1]) rotated right by 24 bits
xor0 = v[b] ^ v[c]
xor1 = v[b + 1] ^ v[c + 1]
v[b] = (xor0 >>> 24) ^ (xor1 << 8)
v[b + 1] = (xor1 >>> 24) ^ (xor0 << 8)
ADD64(v, a, v, b)
ADD64(v, a, m, iy)
// v[d,d+1] = (v[d,d+1] xor v[a,a+1]) rotated right by 16 bits
xor0 = v[d] ^ v[a]
xor1 = v[d + 1] ^ v[a + 1]
v[d] = (xor0 >>> 16) ^ (xor1 << 16)
v[d + 1] = (xor1 >>> 16) ^ (xor0 << 16)
ADD64(v, c, v, d)
// v[b,b+1] = (v[b,b+1] xor v[c,c+1]) rotated right by 63 bits
xor0 = v[b] ^ v[c]
xor1 = v[b + 1] ^ v[c + 1]
v[b] = (xor1 >>> 31) ^ (xor0 << 1)
v[b + 1] = (xor0 >>> 31) ^ (xor1 << 1)
}
// Initialization Vector
const BLAKE2B_IV32 = new Uint32Array([
0xF3BCC908, 0x6A09E667, 0x84CAA73B, 0xBB67AE85,
0xFE94F82B, 0x3C6EF372, 0x5F1D36F1, 0xA54FF53A,
0xADE682D1, 0x510E527F, 0x2B3E6C1F, 0x9B05688C,
0xFB41BD6B, 0x1F83D9AB, 0x137E2179, 0x5BE0CD19
])
// These are offsets into a Uint64 buffer.
// Multiply them all by 2 to make them offsets into a Uint32 buffer
const SIGMA = new Uint8Array([
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3,
11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4,
7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8,
9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13,
2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9,
12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11,
13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10,
6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5,
10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3
].map(x => x * 2))
// Compression function. 'last' flag indicates last block.
// Note: we're representing 16 uint64s as 32 uint32s
function compress(S, last) {
const v = new Uint32Array(32)
const m = new Uint32Array(S.b.buffer, S.b.byteOffset, 32)
// init work variables
for (let i = 0; i < 16; i++) {
v[i] = S.h[i]
v[i + 16] = BLAKE2B_IV32[i]
}
// low 64 bits of offset
v[24] ^= S.t0[0]
v[25] ^= S.t0[1]
// high 64 bits not supported (`t1`), offset may not be higher than 2**53-1
// if last block
const f0 = last ? 0xFFFFFFFF : 0;
v[28] ^= f0;
v[29] ^= f0;
// twelve rounds of mixing
for (let i = 0; i < 12; i++) {
// ROUND(r)
const i16 = i << 4;
G(v, m, 0, 8, 16, 24, SIGMA[i16 + 0], SIGMA[i16 + 1])
G(v, m, 2, 10, 18, 26, SIGMA[i16 + 2], SIGMA[i16 + 3])
G(v, m, 4, 12, 20, 28, SIGMA[i16 + 4], SIGMA[i16 + 5])
G(v, m, 6, 14, 22, 30, SIGMA[i16 + 6], SIGMA[i16 + 7])
G(v, m, 0, 10, 20, 30, SIGMA[i16 + 8], SIGMA[i16 + 9])
G(v, m, 2, 12, 22, 24, SIGMA[i16 + 10], SIGMA[i16 + 11])
G(v, m, 4, 14, 16, 26, SIGMA[i16 + 12], SIGMA[i16 + 13])
G(v, m, 6, 8, 18, 28, SIGMA[i16 + 14], SIGMA[i16 + 15])
}
for (let i = 0; i < 16; i++) {
S.h[i] ^= v[i] ^ v[i + 16]
}
}
// Creates a BLAKE2b hashing context
// Requires an output length between 1 and 64 bytes
// Takes an optional Uint8Array key
class Blake2b {
constructor(outlen, key, salt, personal) {
const params = new Uint8Array(64)
// 0: outlen, keylen, fanout, depth
// 4: leaf length, sequential mode
// 8: node offset
// 12: node offset
// 16: node depth, inner length, rfu
// 20: rfu
// 24: rfu
// 28: rfu
// 32: salt
// 36: salt
// 40: salt
// 44: salt
// 48: personal
// 52: personal
// 56: personal
// 60: personal
// init internal state
this.S = {
b: new Uint8Array(BLOCKBYTES),
h: new Uint32Array(OUTBYTES_MAX / 4),
t0: new Uint32Array(2), // input counter `t`, lower 64-bits only
c: 0, // `fill`, pointer within buffer, up to `BLOCKBYTES`
outlen // output length in bytes
}
// init parameter block
params[0] = outlen
if (key) params[1] = key.length
params[2] = 1 // fanout
params[3] = 1 // depth
if (salt) params.set(salt, 32)
if (personal) params.set(personal, 48)
const params32 = new Uint32Array(params.buffer, params.byteOffset, params.length / Uint32Array.BYTES_PER_ELEMENT);
// initialize hash state
for (let i = 0; i < 16; i++) {
this.S.h[i] = BLAKE2B_IV32[i] ^ params32[i];
}
// key the hash, if applicable
if (key) {
const block = new Uint8Array(BLOCKBYTES)
block.set(key)
this.update(block)
}
}
// Updates a BLAKE2b streaming hash
// Requires Uint8Array (byte array)
update(input) {
if (!(input instanceof Uint8Array)) throw new Error('Input must be Uint8Array or Buffer')
// for (let i = 0; i < input.length; i++) {
// if (this.S.c === BLOCKBYTES) { // buffer full
// INC64(this.S.t0, this.S.c) // add counters
// compress(this.S, false)
// this.S.c = 0 // empty buffer
// }
// this.S.b[this.S.c++] = input[i]
// }
let i = 0
while(i < input.length) {
if (this.S.c === BLOCKBYTES) { // buffer full
INC64(this.S.t0, this.S.c) // add counters
compress(this.S, false)
this.S.c = 0 // empty buffer
}
let left = BLOCKBYTES - this.S.c
this.S.b.set(input.subarray(i, i + left), this.S.c) // end index can be out of bounds
const fill = Math.min(left, input.length - i)
this.S.c += fill
i += fill
}
return this
}
/**
* Return a BLAKE2b hash, either filling the given Uint8Array or allocating a new one
* @param {Uint8Array} [prealloc] - optional preallocated buffer
* @returns {ArrayBuffer} message digest
*/
digest(prealloc) {
INC64(this.S.t0, this.S.c) // mark last block offset
// final block, padded
this.S.b.fill(0, this.S.c);
this.S.c = BLOCKBYTES;
compress(this.S, true)
const out = prealloc || new Uint8Array(this.S.outlen);
for (let i = 0; i < this.S.outlen; i++) {
// must be loaded individually since default Uint32 endianness is platform dependant
out[i] = this.S.h[i >> 2] >> (8 * (i & 3))
}
this.S.h = null; // prevent calling `update` after `digest`
return out.buffer;
}
}
export default function createHash(outlen, key, salt, personal) {
if (outlen > OUTBYTES_MAX) throw new Error(`outlen must be at most ${OUTBYTES_MAX} (given: ${outlen})`)
if (key) {
if (!(key instanceof Uint8Array)) throw new Error('key must be Uint8Array or Buffer')
if (key.length > KEYBYTES_MAX) throw new Error(`key size must be at most ${KEYBYTES_MAX} (given: ${key.length})`)
}
if (salt) {
if (!(salt instanceof Uint8Array)) throw new Error('salt must be Uint8Array or Buffer')
if (salt.length !== SALTBYTES) throw new Error(`salt must be exactly ${SALTBYTES} (given: ${salt.length}`)
}
if (personal) {
if (!(personal instanceof Uint8Array)) throw new Error('personal must be Uint8Array or Buffer')
if (personal.length !== PERSONALBYTES) throw new Error(`salt must be exactly ${PERSONALBYTES} (given: ${personal.length}`)
}
return new Blake2b(outlen, key, salt, personal)
}
const OUTBYTES_MAX = 64;
const KEYBYTES_MAX = 64;
const SALTBYTES = 16;
const PERSONALBYTES = 16;
const BLOCKBYTES = 128;
+35
View File
@@ -0,0 +1,35 @@
export interface Argon2idParams {
password: Uint8Array;
salt: Uint8Array;
/** Degree of parallelism (number of lanes) */
parallelism: number;
/** Number of iterations */
passes: number;
/** Memory cost in kibibytes */
memorySize: number;
/** Output tag length */
tagLength: number;
/** Associated Data */
ad?: Uint8Array;
/** Secret Data */
secret?: Uint8Array;
}
declare function argon2id(params: Argon2idParams): Uint8Array;
export type computeHash = typeof argon2id;
type MaybePromise<T> = T | Promise<T>;
declare function customInstanceLoader(importObject: WebAssembly.Imports): MaybePromise<WebAssembly.WebAssemblyInstantiatedSource>;
/**
* Load Wasm module and return argon2id wrapper.
* It is platform-independent and it relies on the two functions given in input to instatiate the Wasm instances.
* @param getSIMD - function instantiating and returning the SIMD Wasm instance
* @param getNonSIMD - function instantiating and returning the non-SIMD Wasm instance
* @returns {computeHash}
*/
export default function setupWasm(
getSIMD: typeof customInstanceLoader,
getNonSIMD: typeof customInstanceLoader,
): Promise<computeHash>;
+46
View File
@@ -0,0 +1,46 @@
import argon2id from "./argon2id.js";
let isSIMDSupported;
async function wasmLoader(memory, getSIMD, getNonSIMD) {
const importObject = { env: { memory } };
if (isSIMDSupported === undefined) {
try {
const loaded = await getSIMD(importObject);
isSIMDSupported = true;
return loaded;
} catch(e) {
isSIMDSupported = false;
}
}
const loader = isSIMDSupported ? getSIMD : getNonSIMD;
return loader(importObject);
}
export default async function setupWasm(getSIMD, getNonSIMD) {
const memory = new WebAssembly.Memory({
// in pages of 64KiB each
// these values need to be compatible with those declared when building in `build-wasm`
initial: 1040, // 65MB
maximum: 65536, // 4GB
});
const wasmModule = await wasmLoader(memory, getSIMD, getNonSIMD);
/**
* Argon2id hash function
* @callback computeHash
* @param {Object} params
* @param {Uint8Array} params.password - password
* @param {Uint8Array} params.salt - salt
* @param {Integer} params.parallelism
* @param {Integer} params.passes
* @param {Integer} params.memorySize - in kibibytes
* @param {Integer} params.tagLength - output tag length
* @param {Uint8Array} [params.ad] - associated data (optional)
* @param {Uint8Array} [params.secret] - secret data (optional)
* @return {Uint8Array} argon2id hash
*/
const computeHash = (params) => argon2id(params, { instance: wasmModule.instance, memory });
return computeHash;
}
+308
View File
@@ -0,0 +1,308 @@
/**
* Vectorised code mostly taken from: Argon2 reference C implementations (www.github.com/P-H-C/phc-winner-argon2)
* Copyright 2015 Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves
* Licence: CC0 1.0 Universal (https://creativecommons.org/publicdomain/zero/1.0)
*/
#include <stdint.h>
// #include <stdio.h>
#undef EMSCRIPTEN_KEEPALIVE
#define EMSCRIPTEN_KEEPALIVE __attribute__((used)) __attribute__((retain))
#if defined(__SSSE3__) || defined(__SSE2__)
#include <emmintrin.h>
#if defined(__SSSE3__)
#include <tmmintrin.h>
#define r16 \
(_mm_setr_epi8(2, 3, 4, 5, 6, 7, 0, 1, 10, 11, 12, 13, 14, 15, 8, 9))
#define r24 \
(_mm_setr_epi8(3, 4, 5, 6, 7, 0, 1, 2, 11, 12, 13, 14, 15, 8, 9, 10))
#define _mm_roti_epi64(x, c) \
(-(c) == 32) \
? _mm_shuffle_epi32((x), _MM_SHUFFLE(2, 3, 0, 1)) \
: (-(c) == 24) \
? _mm_shuffle_epi8((x), r24) \
: (-(c) == 16) \
? _mm_shuffle_epi8((x), r16) \
: (-(c) == 63) \
? _mm_xor_si128(_mm_srli_epi64((x), -(c)), \
_mm_add_epi64((x), (x))) \
: _mm_xor_si128(_mm_srli_epi64((x), -(c)), \
_mm_slli_epi64((x), 64 - (-(c))))
#else /* SSE2 */
#define _mm_roti_epi64(r, c) \
_mm_xor_si128(_mm_srli_epi64((r), -(c)), _mm_slli_epi64((r), 64 - (-(c))))
#endif
static __m128i fBlaMka(__m128i x, __m128i y) {
const __m128i z = _mm_mul_epu32(x, y);
return _mm_add_epi64(_mm_add_epi64(x, y), _mm_add_epi64(z, z));
}
#define GB1(A0, B0, C0, D0, A1, B1, C1, D1) \
do { \
A0 = fBlaMka(A0, B0); \
A1 = fBlaMka(A1, B1); \
\
D0 = _mm_xor_si128(D0, A0); \
D1 = _mm_xor_si128(D1, A1); \
\
D0 = _mm_roti_epi64(D0, -32); \
D1 = _mm_roti_epi64(D1, -32); \
\
C0 = fBlaMka(C0, D0); \
C1 = fBlaMka(C1, D1); \
\
B0 = _mm_xor_si128(B0, C0); \
B1 = _mm_xor_si128(B1, C1); \
\
B0 = _mm_roti_epi64(B0, -24); \
B1 = _mm_roti_epi64(B1, -24); \
} while ((void)0, 0)
#define GB2(A0, B0, C0, D0, A1, B1, C1, D1) \
do { \
A0 = fBlaMka(A0, B0); \
A1 = fBlaMka(A1, B1); \
\
D0 = _mm_xor_si128(D0, A0); \
D1 = _mm_xor_si128(D1, A1); \
\
D0 = _mm_roti_epi64(D0, -16); \
D1 = _mm_roti_epi64(D1, -16); \
\
C0 = fBlaMka(C0, D0); \
C1 = fBlaMka(C1, D1); \
\
B0 = _mm_xor_si128(B0, C0); \
B1 = _mm_xor_si128(B1, C1); \
\
B0 = _mm_roti_epi64(B0, -63); \
B1 = _mm_roti_epi64(B1, -63); \
} while ((void)0, 0)
#if defined(__SSSE3__)
#define DIAGONALIZE(A0, B0, C0, D0, A1, B1, C1, D1) \
do { \
__m128i t0 = _mm_alignr_epi8(B1, B0, 8); \
__m128i t1 = _mm_alignr_epi8(B0, B1, 8); \
B0 = t0; \
B1 = t1; \
\
t0 = C0; \
C0 = C1; \
C1 = t0; \
\
t0 = _mm_alignr_epi8(D1, D0, 8); \
t1 = _mm_alignr_epi8(D0, D1, 8); \
D0 = t1; \
D1 = t0; \
} while ((void)0, 0)
#define UNDIAGONALIZE(A0, B0, C0, D0, A1, B1, C1, D1) \
do { \
__m128i t0 = _mm_alignr_epi8(B0, B1, 8); \
__m128i t1 = _mm_alignr_epi8(B1, B0, 8); \
B0 = t0; \
B1 = t1; \
\
t0 = C0; \
C0 = C1; \
C1 = t0; \
\
t0 = _mm_alignr_epi8(D0, D1, 8); \
t1 = _mm_alignr_epi8(D1, D0, 8); \
D0 = t1; \
D1 = t0; \
} while ((void)0, 0)
#else /* SSE2 */
#define DIAGONALIZE(A0, B0, C0, D0, A1, B1, C1, D1) \
do { \
__m128i t0 = D0; \
__m128i t1 = B0; \
D0 = C0; \
C0 = C1; \
C1 = D0; \
D0 = _mm_unpackhi_epi64(D1, _mm_unpacklo_epi64(t0, t0)); \
D1 = _mm_unpackhi_epi64(t0, _mm_unpacklo_epi64(D1, D1)); \
B0 = _mm_unpackhi_epi64(B0, _mm_unpacklo_epi64(B1, B1)); \
B1 = _mm_unpackhi_epi64(B1, _mm_unpacklo_epi64(t1, t1)); \
} while ((void)0, 0)
#define UNDIAGONALIZE(A0, B0, C0, D0, A1, B1, C1, D1) \
do { \
__m128i t0, t1; \
t0 = C0; \
C0 = C1; \
C1 = t0; \
t0 = B0; \
t1 = D0; \
B0 = _mm_unpackhi_epi64(B1, _mm_unpacklo_epi64(B0, B0)); \
B1 = _mm_unpackhi_epi64(t0, _mm_unpacklo_epi64(B1, B1)); \
D0 = _mm_unpackhi_epi64(D0, _mm_unpacklo_epi64(D1, D1)); \
D1 = _mm_unpackhi_epi64(D1, _mm_unpacklo_epi64(t1, t1)); \
} while ((void)0, 0)
#endif
// BLAKE2_ROUND in reference code
#define P(A0, A1, B0, B1, C0, C1, D0, D1) \
do { \
GB1(A0, B0, C0, D0, A1, B1, C1, D1); \
GB2(A0, B0, C0, D0, A1, B1, C1, D1); \
\
DIAGONALIZE(A0, B0, C0, D0, A1, B1, C1, D1); \
\
GB1(A0, B0, C0, D0, A1, B1, C1, D1); \
GB2(A0, B0, C0, D0, A1, B1, C1, D1); \
\
UNDIAGONALIZE(A0, B0, C0, D0, A1, B1, C1, D1); \
} while ((void)0, 0)
EMSCRIPTEN_KEEPALIVE void xor(__m128i* out, __m128i* x, __m128i* y){
for(uint8_t i = 0; i < 64; i++) { // ARGON2_BLOCK_SIZE (1024) / 16 bytes (128bits) = 64
out[i] = _mm_xor_si128(x[i], y[i]);
}
}
// G will be given uint64_t* values by JS, which can be automatically casted to _m128i*:
// see https://stackoverflow.com/questions/11034302/sse-difference-between-mm-load-store-vs-using-direct-pointer-access
EMSCRIPTEN_KEEPALIVE void G(__m128i* X, __m128i* Y, __m128i* R, __m128i* Z) {
for (uint8_t i = 0; i < 64; i++) { // inlined `xor` to set both R and Z
R[i] = Z[i] = _mm_xor_si128(X[i], Y[i]);
}
for (uint8_t i = 0; i < 8; ++i) {
P(Z[8 * i + 0], Z[8 * i + 1], Z[8 * i + 2],
Z[8 * i + 3], Z[8 * i + 4], Z[8 * i + 5],
Z[8 * i + 6], Z[8 * i + 7]);
}
for (uint8_t i = 0; i < 8; ++i) {
P(Z[8 * 0 + i], Z[8 * 1 + i], Z[8 * 2 + i],
Z[8 * 3 + i], Z[8 * 4 + i], Z[8 * 5 + i],
Z[8 * 6 + i], Z[8 * 7 + i]);
}
xor(R, R, Z);
}
// G^2
EMSCRIPTEN_KEEPALIVE void G2(__m128i* X, __m128i* Y, __m128i* R, __m128i* Z) {
G( X, Y, R, Z );
G( X, R, R, Z );
}
#else // no vectorization
uint64_t rotr64(uint64_t x, uint64_t n) { return (x >> n) ^ (x << (64 - n)); }
#define LSB(x) ((x) & 0xffffffff)
EMSCRIPTEN_KEEPALIVE void xor(uint64_t* out, uint64_t* x, uint64_t* y){for(uint8_t i = 0; i < 128; i++) out[i] = x[i] ^ y[i];}
void GB(uint64_t* v, int a, int b, int c, int d) {
// a = (a + b + 2 * trunc(a) * trunc(b)) mod 2^(64)
v[a] += v[b] + 2 * LSB(v[a]) * LSB(v[b]);
// d = (d XOR a) >>> 32, where >>> is a rotation
v[d] = rotr64(v[d] ^ v[a], 32);
// c = (c + d + 2 * trunc(c) * trunc(d)) mod 2^(64)
v[c] += v[d] + 2 * LSB(v[c]) * LSB(v[d]);
// b = (b XOR c) >>> 24
v[b] = rotr64(v[b] ^ v[c], 24);
// a = (a + b + 2 * trunc(a) * trunc(b)) mod 2^(64)
v[a] += v[b] + 2 * LSB(v[a]) * LSB(v[b]);
// d = (d XOR a) >>> 16
v[d] = rotr64(v[d] ^ v[a], 16);
// c = (c + d + 2 * trunc(c) * trunc(d)) mod 2^(64)
v[c] += v[d] + 2 * LSB(v[c]) * LSB(v[d]);
// b = (b XOR c) >>> 63
v[b] = rotr64(v[b] ^ v[c], 63);
}
void P(uint64_t* v, uint16_t i0,uint16_t i1,uint16_t i2,uint16_t i3,uint16_t i4,uint16_t i5,uint16_t i6,uint16_t i7, uint16_t i8,uint16_t i9,uint16_t i10,uint16_t i11,uint16_t i12,uint16_t i13,uint16_t i14,uint16_t i15) {
// v stores 16 64-bit values
GB(v, i0, i4, i8, i12);
GB(v, i1, i5, i9, i13);
GB(v, i2, i6, i10, i14);
GB(v, i3, i7, i11, i15);
GB(v, i0, i5, i10, i15);
GB(v, i1, i6, i11, i12);
GB(v, i2, i7, i8, i13);
GB(v, i3, i4, i9, i14);
}
// given a copy of R, compute Z (in-place)
EMSCRIPTEN_KEEPALIVE void G(uint64_t* X, uint64_t* Y, uint64_t* R, uint64_t* Z) {
xor(R, X, Y);
// // we need to store S_i = (v_{2*i+1} || v_{2*i}), for v[i] of 64 bits
// // S[0] = R[8:15] || R[0:7]
for(uint8_t i = 0; i < 128; i+=16) {
Z[i+0] = R[i+0]; Z[i+1] = R[i+1]; Z[i+2] = R[i+2]; Z[i+3] = R[i+3];
Z[i+4] = R[i+4]; Z[i+5] = R[i+5]; Z[i+6] = R[i+6]; Z[i+7] = R[i+7];
Z[i+8] = R[i+8]; Z[i+9] = R[i+9]; Z[i+10] = R[i+10]; Z[i+11] = R[i+11];
Z[i+12] = R[i+12]; Z[i+13] = R[i+13]; Z[i+14] = R[i+14]; Z[i+15] = R[i+15];
// const ids = [0, 1, 2, 3, 4, 5, 6, 7].map(j => i*128 + j*16); // 0, 16, .. 112 | 128, 144...
// ( Q_0, Q_1, Q_2, ... , Q_7) <- P( R_0, R_1, R_2, ... , R_7) of 16-bytes each
P(Z,i+0, i+1, i+2, i+3,
i+4, i+5, i+6, i+7,
i+8, i+9, i+10, i+11,
i+12, i+13, i+14, i+15);
}
for(uint8_t i = 0; i < 16; i+=2) {
// Q_0 = Q[8:15] || Q[0:7]
// const ids = [0, 1, 2, 3, 4, 5, 6, 7].map(j => i*16 + j*128); // 128 .. 896 | 16, 144 .. 912 | ..
// ( Z_0, Z_8, Z_16, ... , Z_56) <- P( Q_0, Q_8, Q_16, ... , Q_56) of 16-bytes each
// ( Z_1, Z_9, Z_17, ... , Z_57) <- P( Q_1, Q_9, Q_17, ... , Q_57) ...
P(Z, i+0, i+1, i+16, i+17,
i+32, i+33, i+48, i+49,
i+64, i+65, i+80, i+81,
i+96, i+97, i+112, i+113); // store one column of Z at a time
}
xor(R, R, Z);
}
// G^2
EMSCRIPTEN_KEEPALIVE void G2(uint64_t* X, uint64_t* Y, uint64_t* R, uint64_t* Z) {
G( X, Y, R, Z );
G( X, R, R, Z );
}
#endif
// Returns out = [l, z]
EMSCRIPTEN_KEEPALIVE uint32_t* getLZ(uint32_t* out, uint32_t* J1J2, uint32_t currentLane, uint32_t p, uint32_t pass, uint32_t slice, uint32_t segmentOffset, uint32_t SL, uint32_t segmentLength) {
// For the first pass (r=0) and the first slice (sl=0), the block is taken from the current lane.
uint32_t l = (pass == 0 && slice == 0) ? currentLane : J1J2[1] % p;
// W includes the indices of all blocks in the last SL - 1 = 3 segments computed and finished (possibly from previous pass, if any).
// Plus, if `l` is on the current lane, we can also reference the finished blocks in the current segment (up to 'offset')
uint32_t offset = l == currentLane
? segmentOffset - 1
: segmentOffset == 0 ? -1 : 0; // If B[i][j] is the first block of a segment, then the very last index from W is excluded.
uint32_t segmentCount = pass == 0 ? slice : SL-1;
uint64_t W_area = segmentCount * segmentLength + offset;
// cast to uint64_t since we don't want the multiplication to be in uint32_t space
uint32_t x = ((uint64_t)J1J2[0] * J1J2[0]) >> 32;
uint32_t y = (W_area * x) >> 32;
uint32_t zz = W_area - 1 - y;
uint32_t startPos = pass == 0 ? 0 : (slice + 1) * segmentLength; // next segment (except for first pass)
// TODO (?) possible optimisation: zz < 2 * (SL * segmentLength) so we can use an if instead of %
uint32_t z = (startPos + zz) % (SL * segmentLength);
out[0] = l;
out[1] = z;
return out;
}
+55
View File
@@ -0,0 +1,55 @@
{
"name": "argon2id",
"version": "1.0.1",
"description": "Argon2id implementation in pure Javascript",
"main": "index.js",
"type": "module",
"types": "index.d.ts",
"files": [
"dist/",
"lib/",
"index.d.ts"
],
"scripts": {
"test": "mocha --loader=ts-node/esm test/blake2b.spec.js test/argon2id.spec.ts",
"lint": "eslint index.js lib test",
"build": "rm -rf dist && mkdir dist && ./build-wasm.sh",
"test-browser": "karma start karma.config.cjs",
"preversion": "npm run build && npm test"
},
"repository": {
"type": "git",
"url": "git+https://github.com/openpgpjs/argon2id.git"
},
"keywords": [
"argon2",
"argon2id",
"rfc9106"
],
"license": "MIT",
"devDependencies": {
"@types/chai": "^4.3.4",
"@types/mocha": "^10.0.1",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.51.0",
"chai": "^4.3.7",
"eslint": "^8.32.0",
"eslint-plugin-import": "^2.27.5",
"karma": "^6.4.1",
"karma-browserstack-launcher": "^1.6.0",
"karma-chrome-launcher": "^3.1.1",
"karma-firefox-launcher": "^2.1.2",
"karma-mocha": "^2.0.1",
"karma-mocha-reporter": "^2.2.5",
"karma-webkit-launcher": "^2.1.0",
"karma-webpack": "^5.0.0",
"mocha": "^10.2.0",
"playwright": "^1.30.0",
"string-replace-loader": "^3.1.0",
"ts-loader": "^9.4.2",
"ts-node": "^10.9.1",
"wasm-loader": "^1.3.0",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.1"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@lisglosips/contracts",
"version": "0.2.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json"
}
}
+10
View File
@@ -0,0 +1,10 @@
export const API_PREFIX = '/api/v2';
export type HealthStatus = 'ok' | 'degraded' | 'down';
export interface HealthCheckResponse {
status: HealthStatus;
service: string;
timestamp: string;
checks: Record<string, HealthStatus>;
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@lisglosips/database",
"version": "0.2.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"@prisma/client": "6.19.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
export { Prisma, PrismaClient } from '@prisma/client';
export const DATABASE_PROVIDER = 'mysql';
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const schema = readFileSync(join(process.cwd(), 'prisma/schema.prisma'), 'utf8');
const migration = readFileSync(join(process.cwd(), 'prisma/migrations/20260621090000_init_v2_schema/migration.sql'), 'utf8');
const authMigration = readFileSync(join(process.cwd(), 'prisma/migrations/20260621093000_auth_sessions/migration.sql'), 'utf8');
describe('Prisma schema contract', () => {
it('defines every V2 core business table', () => {
const tables = [
'customers',
'customer_gateways',
'customer_gateway_policies',
'customer_recharges',
'vendors',
'vendor_recharges',
'vendor_gateways',
'vendor_gateway_forbidden_periods',
'vendor_gateway_codecs',
'vendor_gateway_prefix_rules',
'landing_line_groups',
'landing_line_group_items',
'raw_cdrs',
'rated_cdrs',
'recordings',
'quality_sampling_rules',
'quality_reviews',
'users',
'roles',
'permissions',
'user_roles',
'user_sessions',
'role_permissions',
'audit_logs',
'outbox_events',
'idempotency_keys'
];
for (const table of tables) {
expect(schema).toContain(`@@map("${table}")`);
expect(`${migration}\n${authMigration}`).toContain(`CREATE TABLE \`${table}\``);
}
});
it('keeps money and idempotent facts constrained', () => {
expect(migration).toContain('DECIMAL(20, 6)');
expect(migration).toContain('UNIQUE INDEX `raw_cdrs_event_id_key`');
expect(migration).toContain('UNIQUE INDEX `raw_cdrs_call_id_ended_at_key`');
expect(migration).toContain('UNIQUE INDEX `customer_recharges_idempotency_key_key`');
expect(migration).toContain('UNIQUE INDEX `vendor_recharges_idempotency_key_key`');
expect(migration).toContain('UNIQUE INDEX `idempotency_keys_key_key`');
});
it('models audit columns and outbox publishing state', () => {
expect(schema).toContain('createdAt');
expect(schema).toContain('updatedAt');
expect(schema).toContain('version');
expect(schema).toContain('model OutboxEvent');
expect(schema).toContain('enum OutboxStatus');
});
it('stores refresh sessions as revocable token digests', () => {
expect(schema).toContain('model UserSession');
expect(authMigration).toContain('`refresh_token_hash` CHAR(64) NOT NULL');
expect(authMigration).toContain('UNIQUE INDEX `user_sessions_refresh_token_hash_key`');
expect(authMigration).toContain('`revoked_at` DATETIME(3) NULL');
expect(authMigration).toContain('`rotated_from_id` VARCHAR(40) NULL');
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@lisglosips/domain",
"version": "0.2.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json"
}
}
+12
View File
@@ -0,0 +1,12 @@
export const MONEY_SCALE = 6;
export type EntityStatus = 'enabled' | 'disabled';
export interface AuditedEntity {
id: string;
createdAt: Date;
updatedAt: Date;
createdBy: string | null;
updatedBy: string | null;
version: number;
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@lisglosips/observability",
"version": "0.2.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"pino": "9.14.0"
}
}
+29
View File
@@ -0,0 +1,29 @@
import pino, { type Logger, type LoggerOptions } from 'pino';
export const LOG_REDACT_PATHS = [
'req.headers.authorization',
'req.headers.cookie',
'res.headers["set-cookie"]',
'*.password',
'*.sipPassword',
'*.sipHa1',
'*.token',
'*.refreshToken'
];
export function createLogger(service: string, level = 'info'): Logger {
const options: LoggerOptions = {
name: service,
level,
redact: {
paths: LOG_REDACT_PATHS,
censor: '[REDACTED]'
},
base: {
service
},
timestamp: pino.stdTimeFunctions.isoTime
};
return pino(options);
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@lisglosips/redis",
"version": "0.2.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"ioredis": "5.8.2"
}
}
+273
View File
@@ -0,0 +1,273 @@
import { describe, expect, it } from 'vitest';
import {
CDR_CONSUMER_GROUP,
CDR_DEADLETTER_STREAM,
CDR_STREAM,
ensureCdrConsumerGroup,
parseCdrStreamEvent,
processCdrBatch,
processPendingCdrBatch,
publishCdrEvent,
CdrRetryableError,
type CdrStreamPublishInput,
type CdrRedisCommands,
type RedisAutoClaimResponse,
type RedisStreamEntry,
type RedisStreamReadResponse
} from './index.js';
class FakeRedis implements CdrRedisCommands {
readonly streams = new Map<string, RedisStreamEntry[]>();
readonly groups = new Map<string, { delivered: number; pending: Map<string, RedisStreamEntry> }>();
readonly locks = new Set<string>();
async xadd(stream: string, id: string, ...fieldValues: string[]): Promise<string> {
const entries = this.streams.get(stream) ?? [];
const redisId = id === '*' ? `${entries.length + 1}-0` : id;
entries.push([redisId, fieldValues]);
this.streams.set(stream, entries);
return redisId;
}
async xack(stream: string, group: string, ...ids: string[]): Promise<number> {
const state = this.group(stream, group);
let count = 0;
for (const id of ids) {
if (state.pending.delete(id)) {
count += 1;
}
}
return count;
}
async xgroup(...args: string[]): Promise<string> {
const [command, stream, group] = args;
if (command !== 'CREATE' || !stream || !group) {
throw new Error(`Unsupported xgroup call: ${args.join(' ')}`);
}
const key = this.groupKey(stream, group);
if (this.groups.has(key)) {
throw new Error('BUSYGROUP Consumer Group name already exists');
}
this.groups.set(key, { delivered: 0, pending: new Map() });
if (!this.streams.has(stream)) {
this.streams.set(stream, []);
}
return 'OK';
}
async xreadgroup(...args: Array<string | number>): Promise<RedisStreamReadResponse | null> {
const group = String(args[1]);
const consumer = String(args[2]);
const countIndex = args.indexOf('COUNT');
const streamIndex = args.indexOf('STREAMS');
const count = countIndex === -1 ? 10 : Number(args[countIndex + 1]);
const stream = String(args[streamIndex + 1]);
const state = this.group(stream, group);
const entries = this.streams.get(stream) ?? [];
const selected = entries.slice(state.delivered, state.delivered + count);
if (selected.length === 0) {
return null;
}
state.delivered += selected.length;
for (const entry of selected) {
state.pending.set(entry[0], entry);
}
void consumer;
return [[stream, selected]];
}
async xautoclaim(...args: Array<string | number>): Promise<RedisAutoClaimResponse> {
const stream = String(args[0]);
const group = String(args[1]);
const consumer = String(args[2]);
const countIndex = args.indexOf('COUNT');
const count = countIndex === -1 ? 10 : Number(args[countIndex + 1]);
const state = this.group(stream, group);
const entries = Array.from(state.pending.values()).slice(0, count);
for (const entry of entries) {
state.pending.set(entry[0], entry);
}
void consumer;
return ['0-0', entries];
}
async set(key: string, _value: string, mode: 'NX', _expireMode: 'EX', _ttlSeconds: number): Promise<'OK' | null> {
if (mode !== 'NX') {
throw new Error('FakeRedis only supports SET NX');
}
if (this.locks.has(key)) {
return null;
}
this.locks.add(key);
return 'OK';
}
async del(...keys: string[]): Promise<number> {
let deleted = 0;
for (const key of keys) {
if (this.locks.delete(key)) {
deleted += 1;
}
}
return deleted;
}
pendingSize(stream = CDR_STREAM, group = CDR_CONSUMER_GROUP): number {
return this.group(stream, group).pending.size;
}
private group(stream: string, group: string): { delivered: number; pending: Map<string, RedisStreamEntry> } {
const state = this.groups.get(this.groupKey(stream, group));
if (!state) {
throw new Error('NOGROUP No such key or consumer group');
}
return state;
}
private groupKey(stream: string, group: string): string {
return `${stream}:${group}`;
}
}
function sampleEvent(overrides: Partial<CdrStreamPublishInput> = {}): CdrStreamPublishInput {
return {
eventId: 'evt-001',
callId: 'call-001',
nodeId: 'a1',
opensipsInstance: 'opensips-a1',
ingressAIp: '100.90.90.90',
rtpengineNode: 'a1',
sourceIp: '100.93.185.30',
caller: 's21-ip-1001',
callee: '13800138000',
sipCode: 503,
hangupReason: 'CONFIG_MISSING',
configVersion: '0',
createdAt: '2026-06-21T07:30:00.000Z',
...overrides
};
}
describe('CDR Redis Stream contract', () => {
it('publishes multi-A-aware CDR fields and parses typed values', async () => {
const redis = new FakeRedis();
const redisId = await publishCdrEvent(redis, sampleEvent());
const entry = redis.streams.get(CDR_STREAM)?.find(([id]) => id === redisId);
expect(entry).toBeDefined();
const parsed = parseCdrStreamEvent(entry?.[1] ?? []);
expect(parsed.event_id).toBe('evt-001');
expect(parsed.node_id).toBe('a1');
expect(parsed.opensips_instance).toBe('opensips-a1');
expect(parsed.rtpengine_node).toBe('a1');
expect(parsed.sipCode).toBe(503);
});
it('creates the consumer group idempotently', async () => {
const redis = new FakeRedis();
await ensureCdrConsumerGroup(redis);
await ensureCdrConsumerGroup(redis);
expect(redis.groups.size).toBe(1);
});
it('reads with XREADGROUP and ACKs processed entries', async () => {
const redis = new FakeRedis();
await ensureCdrConsumerGroup(redis);
await publishCdrEvent(redis, sampleEvent());
const summary = await processCdrBatch(redis, async () => 'processed', {
consumer: 'test-consumer',
blockMs: 1
});
expect(summary.processed).toBe(1);
expect(summary.ackedIds).toEqual(['1-0']);
expect(redis.pendingSize()).toBe(0);
});
it('ACKs duplicate event_id without invoking downstream processing again', async () => {
const redis = new FakeRedis();
await ensureCdrConsumerGroup(redis);
await publishCdrEvent(redis, sampleEvent({ callId: 'call-001' }));
await publishCdrEvent(redis, sampleEvent({ callId: 'call-duplicate' }));
let handled = 0;
const summary = await processCdrBatch(
redis,
async () => {
handled += 1;
return 'processed';
},
{ consumer: 'test-consumer', count: 2, blockMs: 1 }
);
expect(handled).toBe(1);
expect(summary.processed).toBe(1);
expect(summary.duplicates).toBe(1);
expect(summary.ackedIds).toEqual(['1-0', '2-0']);
});
it('moves invalid payloads to the deadletter stream and ACKs them', async () => {
const redis = new FakeRedis();
await ensureCdrConsumerGroup(redis);
await redis.xadd(CDR_STREAM, '*', 'event_id', 'evt-bad');
const summary = await processCdrBatch(redis, async () => 'processed', {
consumer: 'test-consumer',
blockMs: 1
});
expect(summary.deadlettered).toBe(1);
expect(redis.pendingSize()).toBe(0);
expect(redis.streams.get(CDR_DEADLETTER_STREAM)?.[0]?.[1]).toContain('evt-bad');
});
it('leaves retryable handler failures pending and releases the idempotency lock', async () => {
const redis = new FakeRedis();
await ensureCdrConsumerGroup(redis);
await publishCdrEvent(redis, sampleEvent());
const summary = await processCdrBatch(
redis,
async () => {
throw new CdrRetryableError('database is temporarily unavailable');
},
{ consumer: 'test-consumer', blockMs: 1 }
);
expect(summary).toMatchObject({ processed: 0, duplicates: 0, deadlettered: 0, pendingLeft: 1 });
expect(summary.ackedIds).toEqual([]);
expect(redis.pendingSize()).toBe(1);
expect(redis.streams.get(CDR_DEADLETTER_STREAM)).toBeUndefined();
const retry = await processPendingCdrBatch(redis, async () => 'processed', {
consumer: 'retry-consumer',
minIdleMs: 1
});
expect(retry.processed).toBe(1);
expect(retry.ackedIds).toEqual(['1-0']);
expect(redis.pendingSize()).toBe(0);
});
it('reclaims pending entries with XAUTOCLAIM and ACKs after retry', async () => {
const redis = new FakeRedis();
await ensureCdrConsumerGroup(redis);
await publishCdrEvent(redis, sampleEvent());
await redis.xreadgroup('GROUP', CDR_CONSUMER_GROUP, 'stalled-consumer', 'COUNT', 1, 'STREAMS', CDR_STREAM, '>');
expect(redis.pendingSize()).toBe(1);
const summary = await processPendingCdrBatch(redis, async () => 'processed', {
consumer: 'retry-consumer',
minIdleMs: 1
});
expect(summary.processed).toBe(1);
expect(summary.ackedIds).toEqual(['1-0']);
expect(redis.pendingSize()).toBe(0);
});
});
+408
View File
@@ -0,0 +1,408 @@
import { randomUUID } from 'node:crypto';
import { CDR_CONSUMER_GROUP, CDR_DEADLETTER_STREAM, CDR_STREAM } from './index.js';
export const CDR_EVENT_SCHEMA_VERSION = '1';
export const CDR_IDEMPOTENCY_KEY_PREFIX = 'lock:cdr:';
export type CdrStreamFieldMap = Record<string, string>;
export interface CdrRedisCommands {
xadd(stream: string, id: string, ...fieldValues: string[]): Promise<string>;
xack(stream: string, group: string, ...ids: string[]): Promise<number>;
xgroup(...args: string[]): Promise<string>;
xreadgroup(...args: Array<string | number>): Promise<RedisStreamReadResponse | null>;
xautoclaim(...args: Array<string | number>): Promise<RedisAutoClaimResponse>;
set(key: string, value: string, mode: 'NX', expireMode: 'EX', ttlSeconds: number): Promise<'OK' | null>;
del(...keys: string[]): Promise<number>;
}
export type RedisStreamEntry = [id: string, fields: string[]];
export type RedisStreamReadResponse = Array<[stream: string, entries: RedisStreamEntry[]]>;
export type RedisAutoClaimResponse = [nextStartId: string, entries: RedisStreamEntry[], deletedIds?: string[]];
export interface CdrStreamEvent {
schema_version: string;
event_id: string;
idempotency_key: string;
call_id: string;
node_id: string;
opensips_instance: string;
ingress_a_ip: string;
rtpengine_node: string;
customer_id: string;
customer_gateway_id: string;
customer_gateway_policy_id: string;
source_ip: string;
caller: string;
callee: string;
vendor_id: string;
vendor_gateway_id: string;
line_group_id: string;
started_at: string;
answered_at: string;
ended_at: string;
duration_sec: string;
sip_code: string;
hangup_reason: string;
recording_key: string;
config_version: string;
created_at: string;
}
export interface ParsedCdrStreamEvent extends CdrStreamEvent {
durationSeconds: number;
sipCode: number;
}
export interface CdrStreamPublishInput {
eventId?: string;
idempotencyKey?: string;
callId: string;
nodeId?: string;
opensipsInstance?: string;
ingressAIp?: string;
rtpengineNode?: string;
customerId?: string;
customerGatewayId?: string;
customerGatewayPolicyId?: string;
sourceIp: string;
caller?: string;
callee?: string;
vendorId?: string;
vendorGatewayId?: string;
lineGroupId?: string;
startedAt?: string;
answeredAt?: string;
endedAt?: string;
durationSec?: number;
sipCode: number;
hangupReason: string;
recordingKey?: string;
configVersion?: string;
createdAt?: string;
}
export type CdrHandlerResult = 'processed' | 'duplicate';
export type CdrStreamHandler = (event: ParsedCdrStreamEvent, redisId: string) => Promise<CdrHandlerResult>;
export class CdrRetryableError extends Error {
constructor(message: string) {
super(message);
this.name = 'CdrRetryableError';
}
}
export interface ProcessCdrBatchOptions {
stream?: string;
group?: string;
consumer: string;
count?: number;
blockMs?: number;
idempotencyTtlSeconds?: number;
}
export interface ProcessPendingOptions {
stream?: string;
group?: string;
consumer: string;
minIdleMs?: number;
startId?: string;
count?: number;
idempotencyTtlSeconds?: number;
}
export interface CdrProcessSummary {
processed: number;
duplicates: number;
deadlettered: number;
pendingLeft: number;
ackedIds: string[];
}
const requiredFields = [
'schema_version',
'event_id',
'idempotency_key',
'call_id',
'node_id',
'opensips_instance',
'ingress_a_ip',
'rtpengine_node',
'source_ip',
'sip_code',
'hangup_reason',
'created_at'
] as const;
export function buildCdrStreamEvent(input: CdrStreamPublishInput): CdrStreamEvent {
const eventId = input.eventId ?? randomUUID();
const endedAt = input.endedAt ?? '';
return {
schema_version: CDR_EVENT_SCHEMA_VERSION,
event_id: eventId,
idempotency_key: input.idempotencyKey ?? `${input.callId}:${endedAt || eventId}`,
call_id: input.callId,
node_id: input.nodeId ?? 'a1',
opensips_instance: input.opensipsInstance ?? 'opensips-a1',
ingress_a_ip: input.ingressAIp ?? '',
rtpengine_node: input.rtpengineNode ?? input.nodeId ?? 'a1',
customer_id: input.customerId ?? '',
customer_gateway_id: input.customerGatewayId ?? '',
customer_gateway_policy_id: input.customerGatewayPolicyId ?? '',
source_ip: input.sourceIp,
caller: input.caller ?? '',
callee: input.callee ?? '',
vendor_id: input.vendorId ?? '',
vendor_gateway_id: input.vendorGatewayId ?? '',
line_group_id: input.lineGroupId ?? '',
started_at: input.startedAt ?? '',
answered_at: input.answeredAt ?? '',
ended_at: endedAt,
duration_sec: String(input.durationSec ?? 0),
sip_code: String(input.sipCode),
hangup_reason: input.hangupReason,
recording_key: input.recordingKey ?? '',
config_version: input.configVersion ?? '',
created_at: input.createdAt ?? new Date().toISOString()
};
}
export function serializeCdrEvent(event: CdrStreamEvent): string[] {
return Object.entries(event).flatMap(([field, value]) => [field, value]);
}
export function parseStreamFields(fields: string[]): CdrStreamFieldMap {
const parsed: CdrStreamFieldMap = {};
for (let index = 0; index < fields.length; index += 2) {
const key = fields[index];
const value = fields[index + 1];
if (key !== undefined && value !== undefined) {
parsed[key] = value;
}
}
return parsed;
}
export function parseCdrStreamEvent(fields: string[]): ParsedCdrStreamEvent {
const parsed = parseStreamFields(fields);
for (const field of requiredFields) {
if (!parsed[field]) {
throw new Error(`CDR stream event missing required field: ${field}`);
}
}
if (parsed.schema_version !== CDR_EVENT_SCHEMA_VERSION) {
throw new Error(`Unsupported CDR schema version: ${parsed.schema_version}`);
}
const durationSeconds = Number.parseInt(parsed.duration_sec ?? '0', 10);
const sipCode = Number.parseInt(parsed.sip_code, 10);
if (!Number.isInteger(durationSeconds) || durationSeconds < 0) {
throw new Error(`Invalid CDR duration_sec: ${parsed.duration_sec}`);
}
if (!Number.isInteger(sipCode) || sipCode < 100 || sipCode > 699) {
throw new Error(`Invalid CDR sip_code: ${parsed.sip_code}`);
}
return {
schema_version: parsed.schema_version,
event_id: parsed.event_id,
idempotency_key: parsed.idempotency_key,
call_id: parsed.call_id,
node_id: parsed.node_id,
opensips_instance: parsed.opensips_instance,
ingress_a_ip: parsed.ingress_a_ip,
rtpengine_node: parsed.rtpengine_node,
customer_id: parsed.customer_id ?? '',
customer_gateway_id: parsed.customer_gateway_id ?? '',
customer_gateway_policy_id: parsed.customer_gateway_policy_id ?? '',
source_ip: parsed.source_ip,
caller: parsed.caller ?? '',
callee: parsed.callee ?? '',
vendor_id: parsed.vendor_id ?? '',
vendor_gateway_id: parsed.vendor_gateway_id ?? '',
line_group_id: parsed.line_group_id ?? '',
started_at: parsed.started_at ?? '',
answered_at: parsed.answered_at ?? '',
ended_at: parsed.ended_at ?? '',
duration_sec: parsed.duration_sec ?? '0',
sip_code: parsed.sip_code,
hangup_reason: parsed.hangup_reason,
recording_key: parsed.recording_key ?? '',
config_version: parsed.config_version ?? '',
created_at: parsed.created_at,
durationSeconds,
sipCode
};
}
export async function ensureCdrConsumerGroup(
redis: CdrRedisCommands,
stream = CDR_STREAM,
group = CDR_CONSUMER_GROUP
): Promise<void> {
try {
await redis.xgroup('CREATE', stream, group, '0', 'MKSTREAM');
} catch (error) {
if (!(error instanceof Error) || !error.message.includes('BUSYGROUP')) {
throw error;
}
}
}
export async function publishCdrEvent(
redis: CdrRedisCommands,
input: CdrStreamPublishInput,
stream = CDR_STREAM
): Promise<string> {
const event = buildCdrStreamEvent(input);
return redis.xadd(stream, '*', ...serializeCdrEvent(event));
}
export async function processCdrBatch(
redis: CdrRedisCommands,
handler: CdrStreamHandler,
options: ProcessCdrBatchOptions
): Promise<CdrProcessSummary> {
const stream = options.stream ?? CDR_STREAM;
const group = options.group ?? CDR_CONSUMER_GROUP;
const count = options.count ?? 10;
const blockMs = options.blockMs ?? 1000;
const response = await redis.xreadgroup(
'GROUP',
group,
options.consumer,
'COUNT',
count,
'BLOCK',
blockMs,
'STREAMS',
stream,
'>'
);
return processReadResponse(redis, handler, response, {
stream,
group,
idempotencyTtlSeconds: options.idempotencyTtlSeconds ?? 86400
});
}
export async function processPendingCdrBatch(
redis: CdrRedisCommands,
handler: CdrStreamHandler,
options: ProcessPendingOptions
): Promise<CdrProcessSummary> {
const stream = options.stream ?? CDR_STREAM;
const group = options.group ?? CDR_CONSUMER_GROUP;
const response = await redis.xautoclaim(
stream,
group,
options.consumer,
options.minIdleMs ?? 60000,
options.startId ?? '0-0',
'COUNT',
options.count ?? 10
);
return processEntries(redis, handler, response[1], {
stream,
group,
idempotencyTtlSeconds: options.idempotencyTtlSeconds ?? 86400
});
}
async function processReadResponse(
redis: CdrRedisCommands,
handler: CdrStreamHandler,
response: RedisStreamReadResponse | null,
options: { stream: string; group: string; idempotencyTtlSeconds: number }
): Promise<CdrProcessSummary> {
const entries = response?.flatMap(([, streamEntries]) => streamEntries) ?? [];
return processEntries(redis, handler, entries, options);
}
async function processEntries(
redis: CdrRedisCommands,
handler: CdrStreamHandler,
entries: RedisStreamEntry[],
options: { stream: string; group: string; idempotencyTtlSeconds: number }
): Promise<CdrProcessSummary> {
const summary: CdrProcessSummary = {
processed: 0,
duplicates: 0,
deadlettered: 0,
pendingLeft: 0,
ackedIds: []
};
for (const [redisId, fields] of entries) {
try {
const event = parseCdrStreamEvent(fields);
const lock = await redis.set(
`${CDR_IDEMPOTENCY_KEY_PREFIX}${event.event_id}`,
redisId,
'NX',
'EX',
options.idempotencyTtlSeconds
);
if (lock === null) {
await ack(redis, options.stream, options.group, redisId, summary);
summary.duplicates += 1;
continue;
}
const result = await handler(event, redisId);
await ack(redis, options.stream, options.group, redisId, summary);
if (result === 'duplicate') {
summary.duplicates += 1;
} else {
summary.processed += 1;
}
} catch (error) {
if (error instanceof CdrRetryableError) {
const fieldMap = parseStreamFields(fields);
if (fieldMap.event_id) {
await redis.del(`${CDR_IDEMPOTENCY_KEY_PREFIX}${fieldMap.event_id}`);
}
summary.pendingLeft += 1;
continue;
}
await moveToDeadletter(redis, redisId, fields, error);
await ack(redis, options.stream, options.group, redisId, summary);
summary.deadlettered += 1;
}
}
return summary;
}
async function ack(
redis: CdrRedisCommands,
stream: string,
group: string,
redisId: string,
summary: CdrProcessSummary
): Promise<void> {
await redis.xack(stream, group, redisId);
summary.ackedIds.push(redisId);
}
async function moveToDeadletter(redis: CdrRedisCommands, redisId: string, fields: string[], error: unknown): Promise<void> {
const fieldMap = parseStreamFields(fields);
await redis.xadd(
CDR_DEADLETTER_STREAM,
'*',
'original_redis_id',
redisId,
'event_id',
fieldMap.event_id ?? '',
'call_id',
fieldMap.call_id ?? '',
'error',
error instanceof Error ? error.message : 'Unknown CDR processing error',
'payload',
JSON.stringify(fieldMap),
'deadlettered_at',
new Date().toISOString()
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Redis } from 'ioredis';
export const CDR_STREAM = 'stream:cdr_payload';
export const CDR_DEADLETTER_STREAM = 'stream:cdr_deadletter';
export const CDR_CONSUMER_GROUP = 'billing-workers';
export const CONFIG_ACTIVE_VERSION_KEY = 'cfg:active_version';
export const CONFIG_PREVIOUS_VERSION_KEY = 'cfg:previous_version';
export type RedisClient = Redis;
export function configVersionPrefix(version: string): string {
return `cfg:v:${version}`;
}
export function configVersionManifestKey(version: string): string {
return `${configVersionPrefix(version)}:manifest`;
}
export function createRedisClient(redisUrl: string): Redis {
return new Redis(redisUrl, {
lazyConnect: true,
maxRetriesPerRequest: 3,
enableReadyCheck: true
});
}
export * from './cdr-stream.js';
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}