Enhance architecture documentation and update project dependencies. Introduce new features for browser recording, page callables, and transform capabilities. Improve build scripts and permissions for better functionality.

This commit is contained in:
go0p
2026-08-06 15:11:35 +08:00
committed by go0p
parent 0371a8b802
commit af5a4db694
125 changed files with 25018 additions and 1675 deletions
@@ -0,0 +1,338 @@
import { describe, expect, it } from 'vitest';
import type { CryptoAdapterScope, CryptoAdapterToolkit } from './contract';
import { cryptoAdapterLabel } from './catalog';
import { cryptoJsAdapter } from './cryptojs';
import { jsEncryptAdapter } from './jsencrypt';
import { webCryptoAdapter } from './webcrypto';
import { smCryptoAdapter } from './sm-crypto';
import { nodeForgeAdapter } from './node-forge';
import { jsrsasignAdapter } from './jsrsasign';
import { joseAdapter } from './jose';
function byteLength(value: unknown): number | undefined {
if (typeof value === 'string') return new TextEncoder().encode(value).byteLength;
if (ArrayBuffer.isView(value)) return value.byteLength;
if (value instanceof ArrayBuffer) return value.byteLength;
if (value && typeof value === 'object' && typeof (value as { sigBytes?: unknown }).sigBytes === 'number') {
return (value as { sigBytes: number }).sigBytes;
}
return undefined;
}
function toolkit(): CryptoAdapterToolkit {
return {
unique: (prefix) => `${prefix}-1`,
byteLength,
dataType: (value) => typeof value,
fingerprint: () => 'v2:opaque-fingerprint',
argument: (index, role, value, replaceable, retained, summary) => ({
index,
role,
dataType: typeof value,
byteLength: byteLength(value),
replaceable,
retained,
summary,
}),
collectEvidence: (value, path) => [{
path,
fingerprint: `fingerprint:${String(value)}`,
encoding: 'text',
byteLength: byteLength(String(value)) || 0,
}],
defaultOutputEvidence: () => [],
defaultAdaptInput: (value) => value,
bytesForInput: (value) => value instanceof Uint8Array ? value : undefined,
bytesToBase64: (value) => `base64:${Array.from(value).join(',')}`,
};
}
describe('page crypto adapters', () => {
it('keeps the UI catalog separate and safely falls back for unknown adapter IDs', () => {
expect(cryptoAdapterLabel('webcrypto')).toBe('WebCrypto');
expect(cryptoAdapterLabel('vendor-suite.v2')).toBe('vendor-suite.v2');
});
it('describes WebCrypto input roles and state without reading key material', () => {
const subtlePrototype = { encrypt() { return Promise.resolve(new ArrayBuffer(0)); } };
const subtle = Object.create(subtlePrototype) as SubtleCrypto;
const operations = webCryptoAdapter.discover({
window: { crypto: { subtle } } as unknown as Window,
crypto: { subtle } as Crypto,
});
const encrypt = operations.find((item) => item.operation === 'encrypt');
const key = { type: 'secret' } as CryptoKey;
const plan = encrypt?.describe(subtle, [
{ name: 'AES-GCM', iv: new Uint8Array(12), tagLength: 128 },
key,
new Uint8Array([1, 2, 3]),
], toolkit());
expect(plan?.crypto).toMatchObject({
adapterId: 'webcrypto',
providerKind: 'native',
family: 'symmetric',
operation: 'encrypt',
algorithm: 'AES-GCM tag=128 ivBytes=12',
state: { model: 'receiver', phase: 'one-shot' },
});
expect(plan?.arguments.map((argument) => argument.role)).toEqual(['algorithm', 'key', 'data']);
expect(plan?.arguments[2]).toMatchObject({ replaceable: true, retained: true, byteLength: 3 });
expect(JSON.stringify(plan)).not.toContain('secret');
});
it('describes CryptoJS modes and adapts bytes through the page encoder', () => {
const CBC = {};
const Pkcs7 = {};
const parsed: string[] = [];
const cryptoJs = {
AES: { encrypt() { return 'cipher'; } },
mode: { CBC },
pad: { Pkcs7 },
enc: { Base64: { parse(value: string) { parsed.push(value); return { wordArray: value }; } } },
};
const scope = { window: { CryptoJS: cryptoJs } as unknown as Window } satisfies CryptoAdapterScope;
const encrypt = cryptoJsAdapter.discover(scope).find((item) => item.operation === 'AES.encrypt');
const plan = encrypt?.describe(cryptoJs.AES, [
{ sigBytes: 3 },
{ sigBytes: 16 },
{ mode: CBC, padding: Pkcs7, iv: { sigBytes: 16 } },
], toolkit());
expect(plan?.crypto).toMatchObject({
adapterId: 'cryptojs', family: 'symmetric', operation: 'AES.encrypt',
mode: 'CBC', padding: 'Pkcs7', outputEncoding: 'base64',
});
expect(plan?.arguments[2].summary).toBe('mode=CBC padding=Pkcs7 ivBytes=16');
expect(plan?.adaptInput?.(new Uint8Array([4, 5, 6]))).toEqual({ wordArray: 'base64:4,5,6' });
expect(parsed).toEqual(['base64:4,5,6']);
});
it('retains only bounded JSEncrypt receiver metadata', () => {
const prototype = {
encrypt() { return 'ciphertext'; },
decrypt() { return 'plaintext'; },
sign() { return 'signature'; },
verify() { return true; },
};
const instance = {
key: {
n: { bitLength: () => 2048, toString: () => 'public-modulus' },
e: 65_537,
},
};
const encrypt = jsEncryptAdapter.discover({
window: { JSEncrypt: { prototype } } as unknown as Window,
}).find((item) => item.operation === 'encrypt');
const plan = encrypt?.describe(instance, ['plain'], toolkit());
expect(plan?.crypto).toMatchObject({
adapterId: 'jsencrypt', family: 'asymmetric', algorithm: 'RSA',
state: { model: 'receiver', phase: 'one-shot' },
key: { kind: 'public', bits: 2048, fingerprint: 'v2:opaque-fingerprint' },
});
expect(plan?.arguments[0]).toMatchObject({ role: 'data', replaceable: true, retained: true });
expect(JSON.stringify(plan?.crypto)).not.toContain('public-modulus');
});
it('describes sm-crypto SM2/SM3/SM4 using one bounded contract', () => {
const smCrypto = {
sm2: {
doEncrypt: () => 'cipher',
doDecrypt: () => 'plain',
doSignature: () => 'signature',
doVerifySignature: () => true,
},
sm3: () => 'digest',
sm4: { encrypt: () => 'cipher', decrypt: () => 'plain' },
};
const operations = smCryptoAdapter.discover({ window: { ...smCrypto } as unknown as Window });
const sm4 = operations.find((item) => item.operation === 'sm4.encrypt');
const plan = sm4?.describe(smCrypto.sm4, [
'plain',
'00112233445566778899aabbccddeeff',
{ mode: 'cbc', padding: 'pkcs#7', iv: '0102030405060708' },
], toolkit());
const verify = operations.find((item) => item.operation === 'sm2.verify')?.describe(
smCrypto.sm2, ['plain', 'signature', 'public-key', { hash: true }], toolkit(),
);
expect(operations).toHaveLength(7);
expect(plan?.crypto).toMatchObject({
adapterId: 'sm-crypto', family: 'symmetric', algorithm: 'SM4', mode: 'cbc', padding: 'pkcs#7',
state: { model: 'stateless', phase: 'one-shot' },
key: { kind: 'secret', bits: 128, fingerprint: 'v2:opaque-fingerprint' },
});
expect(plan?.arguments[2].summary).toContain('ivBytes=16');
expect(verify?.callableKind).toBe('verify');
expect(verify?.arguments.map((item) => item.role)).toEqual(['data', 'signature', 'key', 'options']);
expect(JSON.stringify(plan?.crypto)).not.toContain('00112233445566778899aabbccddeeff');
});
it('discovers node-forge stateful cipher sessions without treating them as replay-safe one-shot calls', () => {
const outputBuffer = {
bytes: () => 'cipher-bytes',
length: () => 12,
getBytes: () => 'cipher-bytes',
};
const session = {
output: outputBuffer,
start: () => undefined,
update: () => undefined,
finish: () => true,
};
const forge = {
cipher: { createCipher: () => session, createDecipher: () => session },
hmac: { create: () => ({ start() {}, update() {}, digest: () => outputBuffer }) },
pki: {
publicKeyFromPem: () => ({
n: { bitLength: () => 2048, toString: () => 'modulus' }, e: 65_537,
encrypt: (value: string) => value, verify: () => true,
}),
privateKeyFromPem: () => ({
n: { bitLength: () => 2048, toString: () => 'modulus' }, e: 65_537,
d: {}, decrypt: (value: string) => value, sign: () => 'signature',
}),
},
md: {
sha256: { create: () => ({ start() {}, update() {}, digest: () => outputBuffer }) },
},
};
const operations = nodeForgeAdapter.discover({ window: { forge } as unknown as Window });
const factory = operations.find((item) => item.operation === 'cipher.create.encrypt');
const factoryPlan = factory?.describe(forge.cipher, ['AES-CBC', 'secret-key'], toolkit());
const sessionOperations = factoryPlan?.discoverResult?.(session) || [];
const update = sessionOperations.find((item) => item.operation === 'cipher.encrypt.update');
const finish = sessionOperations.find((item) => item.operation === 'cipher.encrypt.finish');
const updatePlan = update?.describe(session, [outputBuffer], toolkit());
const finishPlan = finish?.describe(session, [], toolkit());
expect(factoryPlan?.crypto).toMatchObject({
adapterId: 'node-forge', family: 'symmetric', algorithm: 'AES-CBC',
state: { model: 'session', phase: 'create', correlationId: 'forge-session-1' },
});
expect(updatePlan?.crypto.state).toMatchObject({ model: 'stream', phase: 'update', correlationId: 'forge-session-1' });
expect(updatePlan?.callableKind).toBeUndefined();
expect(finishPlan?.outputEvidence?.(true)[0]).toMatchObject({ path: '$receiver.output' });
expect(sessionOperations.some((item) => item.operation === 'cipher.encrypt.output.getBytes')).toBe(true);
});
it('turns node-forge RSA key instances into receiver-bound direct operations without exporting PEM', () => {
const key = {
n: { bitLength: () => 2048, toString: () => 'private-modulus' },
e: 65_537,
encrypt: (value: string) => `cipher:${value}`,
verify: () => true,
};
const forge = { pki: { publicKeyFromPem: () => key } };
const factory = nodeForgeAdapter.discover({ window: { forge } as unknown as Window })
.find((item) => item.operation === 'pki.public-key.create');
const plan = factory?.describe(forge.pki, ['-----BEGIN PUBLIC KEY-----raw-material'], toolkit());
const encrypt = plan?.discoverResult?.(key).find((item) => item.operation === 'rsa.encrypt');
const encryptPlan = encrypt?.describe(key, ['plain', 'RSA-OAEP'], toolkit());
expect(plan?.outputEvidence?.(key)).toEqual([]);
expect(encryptPlan?.callableKind).toBe('encrypt');
expect(encryptPlan?.crypto).toMatchObject({
family: 'asymmetric', algorithm: 'RSA', state: { model: 'receiver', phase: 'one-shot' },
key: { kind: 'public', bits: 2048, fingerprint: 'v2:opaque-fingerprint' },
});
expect(JSON.stringify(encryptPlan?.crypto)).not.toContain('raw-material');
expect(JSON.stringify(encryptPlan?.crypto)).not.toContain('private-modulus');
});
it('models a jsrsasign constructor session as create, init, update, and final stages', () => {
class Signature {
constructor(public options: { alg: string }) {}
init(_key: unknown) {}
updateString(_value: string) {}
sign() { return 'deadbeef'; }
verify(_signature: string) { return true; }
}
const JWS = {
sign: (_algorithm: string, _header: unknown, payload: unknown) => `jws:${String(payload)}`,
verify: () => true,
verifyJWT: () => true,
getJWKthumbprint: () => 'thumbprint',
};
const window = {
KJUR: { crypto: { Signature }, jws: { JWS } },
KEYUTIL: { getKey: () => ({}), getJWK: () => ({ kty: 'RSA' }), getPEM: () => 'pem' },
} as unknown as Window;
const operations = jsrsasignAdapter.discover({ window });
const constructor = operations.find((item) => item.operation === 'Signature.create');
const createPlan = constructor?.describe(undefined, [{ alg: 'SHA256withRSA' }], toolkit());
const instance = new Signature({ alg: 'SHA256withRSA' });
const stages = createPlan?.discoverResult?.(instance) || [];
const init = stages.find((item) => item.operation === 'Signature.init')
?.describe(instance, ['-----BEGIN PRIVATE KEY-----private-material'], toolkit());
const update = stages.find((item) => item.operation === 'Signature.updateString')
?.describe(instance, ['canonical-request'], toolkit());
const sign = stages.find((item) => item.operation === 'Signature.sign')
?.describe(instance, [], toolkit());
expect(constructor?.invocationMode).toBe('construct');
expect(createPlan?.crypto).toMatchObject({
adapterId: 'jsrsasign', family: 'signature', algorithm: 'SHA256withRSA',
state: { model: 'session', phase: 'create', correlationId: 'jsrsasign-signature-1' },
});
expect(init?.crypto.state).toMatchObject({ phase: 'init', correlationId: 'jsrsasign-signature-1' });
expect(update?.crypto.state).toMatchObject({ phase: 'update', correlationId: 'jsrsasign-signature-1' });
expect(sign?.crypto.state).toMatchObject({ phase: 'final', correlationId: 'jsrsasign-signature-1' });
expect(sign?.callableKind).toBeUndefined();
expect(JSON.stringify(init?.crypto)).not.toContain('private-material');
const jwsSign = operations.find((item) => item.operation === 'JWS.sign')
?.describe(JWS, ['RS256', { alg: 'RS256' }, { account: 'admin' }, 'private-key'], toolkit());
expect(jwsSign).toMatchObject({ inputIndex: 2, callableKind: 'sign' });
});
it('models jose builders as async stateful envelopes and keeps key material opaque', () => {
class SignJWT {
constructor(public payload: unknown) {}
setProtectedHeader(_header: unknown) { return this; }
setIssuedAt() { return this; }
sign(_key: unknown) { return Promise.resolve('header.payload.signature'); }
}
class CompactSign {
constructor(public payload: Uint8Array) {}
setProtectedHeader(_header: unknown) { return this; }
sign(_key: unknown) { return Promise.resolve('header.payload.signature'); }
}
class CompactEncrypt {
constructor(public payload: Uint8Array) {}
setProtectedHeader(_header: unknown) { return this; }
encrypt(_key: unknown) { return Promise.resolve('compact-jwe'); }
}
const jose = {
SignJWT, CompactSign, CompactEncrypt,
compactVerify: async () => ({ payload: new Uint8Array() }),
jwtVerify: async () => ({ payload: {} }),
compactDecrypt: async () => ({ plaintext: new Uint8Array() }),
jwtDecrypt: async () => ({ payload: {} }),
importJWK: async () => ({}),
exportJWK: async () => ({ kty: 'RSA' }),
};
const operations = joseAdapter.discover({ window: { jose } as unknown as Window });
const constructor = operations.find((item) => item.operation === 'SignJWT.create');
const createPlan = constructor?.describe(undefined, [{ account: 'admin' }], toolkit());
const instance = new SignJWT({ account: 'admin' });
const stages = createPlan?.discoverResult?.(instance) || [];
const header = stages.find((item) => item.operation === 'SignJWT.setProtectedHeader')
?.describe(instance, [{ alg: 'RS256' }], toolkit());
const final = stages.find((item) => item.operation === 'SignJWT.sign')
?.describe(instance, [{ type: 'private', algorithm: { name: 'RSA-PSS' }, secret: 'never-export' }], toolkit());
expect(constructor?.invocationMode).toBe('construct');
expect(createPlan?.crypto.state).toMatchObject({ model: 'async-ready', phase: 'create', correlationId: 'jose-session-1' });
expect(header?.crypto).toMatchObject({ algorithm: 'RS256', state: { phase: 'update', correlationId: 'jose-session-1' } });
expect(final?.crypto).toMatchObject({
family: 'signature', algorithm: 'RS256', key: { kind: 'private' },
state: { phase: 'final', correlationId: 'jose-session-1' },
});
expect(stages.find((item) => item.operation === 'SignJWT.sign')?.resultMode).toBe('promise');
expect(final?.callableKind).toBeUndefined();
expect(JSON.stringify(final?.crypto)).not.toContain('never-export');
expect(operations.find((item) => item.operation === 'CompactVerify.verify')?.resultMode).toBe('promise');
});
});
@@ -0,0 +1,73 @@
import type { CryptoAdapterManifest } from './contract';
export const webCryptoManifest: CryptoAdapterManifest = {
id: 'webcrypto',
displayName: 'WebCrypto',
providerKind: 'native',
dynamic: false,
globalPaths: ['crypto.subtle'],
};
export const cryptoJsManifest: CryptoAdapterManifest = {
id: 'cryptojs',
displayName: 'CryptoJS',
providerKind: 'library',
dynamic: true,
globalPaths: ['CryptoJS'],
};
export const jsEncryptManifest: CryptoAdapterManifest = {
id: 'jsencrypt',
displayName: 'JSEncrypt',
providerKind: 'library',
dynamic: true,
globalPaths: ['JSEncrypt.prototype'],
};
export const smCryptoManifest: CryptoAdapterManifest = {
id: 'sm-crypto',
displayName: 'sm-crypto',
providerKind: 'library',
dynamic: true,
globalPaths: ['smCrypto', 'sm2', 'sm3', 'sm4'],
};
export const nodeForgeManifest: CryptoAdapterManifest = {
id: 'node-forge',
displayName: 'node-forge',
providerKind: 'library',
dynamic: true,
globalPaths: ['forge'],
};
export const jsrsasignManifest: CryptoAdapterManifest = {
id: 'jsrsasign',
displayName: 'jsrsasign',
providerKind: 'library',
dynamic: true,
globalPaths: ['KJUR.crypto.Signature', 'KJUR.jws.JWS', 'KEYUTIL'],
};
export const joseManifest: CryptoAdapterManifest = {
id: 'jose',
displayName: 'jose',
providerKind: 'library',
dynamic: true,
globalPaths: ['jose'],
};
export const CRYPTO_ADAPTER_MANIFESTS: Readonly<Record<string, CryptoAdapterManifest>> = Object.freeze(
Object.fromEntries([
webCryptoManifest,
cryptoJsManifest,
jsEncryptManifest,
smCryptoManifest,
nodeForgeManifest,
jsrsasignManifest,
joseManifest,
].map((manifest) => [manifest.id, Object.freeze(manifest)])),
);
export function cryptoAdapterLabel(adapterId: string): string {
return CRYPTO_ADAPTER_MANIFESTS[adapterId]?.displayName || adapterId;
}
@@ -0,0 +1,46 @@
import type { BrowserCryptoFamily } from '@/types/models';
import type { CallableOperationKind } from './contract';
export function cryptoFamily(operation: string, algorithm?: string): BrowserCryptoFamily {
const value = `${operation} ${algorithm || ''}`.toLowerCase();
if (value.includes('hmac')) return 'mac';
if (value.includes('digest') || /\b(?:sha\d*|md5|ripemd|sm3)\b/.test(value)) return 'digest';
if (value.includes('derive') || value.includes('pbkdf') || value.includes('evpkdf') || value.includes('kdf')) return 'kdf';
if (value.includes('sign') || value.includes('verify')) return 'signature';
if (value.includes('rsa') || value.includes('ecies') || value.includes('sm2')) return 'asymmetric';
if (value.includes('encrypt') || value.includes('decrypt') || value.includes('wrap') || value.includes('sm4')) return 'symmetric';
if (value.includes('key') || value.includes('import') || value.includes('export')) return 'key-management';
return 'unknown';
}
export function callableOperationKind(operation: string): CallableOperationKind | undefined {
const normalized = operation.toLowerCase();
if (normalized.includes('decrypt')) return 'decrypt';
if (normalized.includes('encrypt')) return 'encrypt';
if (normalized.includes('verify')) return 'verify';
if (normalized.includes('sign') || normalized.includes('hmac')) return 'sign';
if (normalized.includes('digest') || normalized.includes('sha') || normalized.includes('md5') || normalized.includes('sm3')) return 'digest';
return undefined;
}
export function algorithmSummary(
value: unknown,
byteLength: (input: unknown) => number | undefined,
): string | undefined {
if (typeof value === 'string') return value.slice(0, 160);
if (!value || typeof value !== 'object') return undefined;
const algorithm = value as Record<string, unknown>;
const parts = [typeof algorithm.name === 'string' ? algorithm.name : 'unknown'];
if (typeof algorithm.namedCurve === 'string') parts.push(`curve=${algorithm.namedCurve}`);
if (typeof algorithm.length === 'number') parts.push(`length=${algorithm.length}`);
if (typeof algorithm.tagLength === 'number') parts.push(`tag=${algorithm.tagLength}`);
const hash = algorithm.hash;
if (typeof hash === 'string') parts.push(`hash=${hash}`);
else if (hash && typeof hash === 'object' && typeof (hash as { name?: unknown }).name === 'string') {
parts.push(`hash=${(hash as { name: string }).name}`);
}
if (algorithm.iv !== undefined) parts.push(`ivBytes=${byteLength(algorithm.iv) || 0}`);
if (algorithm.salt !== undefined) parts.push(`saltBytes=${byteLength(algorithm.salt) || 0}`);
return parts.join(' ').slice(0, 240);
}
@@ -0,0 +1,74 @@
import type {
BrowserCryptoProviderKind,
BrowserPageCallableValueEncoding,
BrowserRecordingCallArgument,
BrowserRecordingCrypto,
BrowserRecordingValueEvidence,
} from '@/types/models';
export type CallableOperationKind = 'encrypt' | 'decrypt' | 'sign' | 'verify' | 'digest';
export interface CryptoAdapterManifest {
id: string;
displayName: string;
providerKind: BrowserCryptoProviderKind;
dynamic: boolean;
globalPaths: string[];
}
export interface CryptoAdapterScope {
window: Window;
crypto?: Crypto;
}
export interface CryptoAdapterToolkit {
unique(prefix: string): string;
byteLength(value: unknown): number | undefined;
dataType(value: unknown): string;
fingerprint(value: string): string;
argument(
index: number,
role: BrowserRecordingCallArgument['role'],
value: unknown,
replaceable: boolean,
retained: boolean,
summary?: string,
): BrowserRecordingCallArgument;
collectEvidence(value: unknown, path: string): BrowserRecordingValueEvidence[];
defaultOutputEvidence(value: unknown): BrowserRecordingValueEvidence[];
defaultAdaptInput(value: unknown, originalInput: unknown): unknown;
bytesForInput(value: unknown): Uint8Array | undefined;
bytesToBase64(value: Uint8Array): string;
}
export interface CryptoAdapterInvocationPlan {
crypto: BrowserRecordingCrypto;
inputIndex: number;
arguments: BrowserRecordingCallArgument[];
callableKind?: CallableOperationKind;
outputEncoding?: BrowserPageCallableValueEncoding;
inputEvidence?(value: unknown): BrowserRecordingValueEvidence[];
outputEvidence?(value: unknown): BrowserRecordingValueEvidence[];
outputError?(value: unknown): string | undefined;
adaptInput?(value: unknown): unknown;
discoverResult?(value: unknown): CryptoAdapterOperation[];
}
export interface CryptoAdapterOperation {
id: string;
operation: string;
owner: Record<string, unknown>;
key: string;
invocationMode?: 'call' | 'construct';
resultMode: 'sync' | 'promise';
describe(thisArg: unknown, args: unknown[], toolkit: CryptoAdapterToolkit): CryptoAdapterInvocationPlan;
createWrapper(
original: Function,
invoke: (thisArg: unknown, args: unknown[]) => unknown,
): Function;
}
export interface PageCryptoAdapter {
manifest: CryptoAdapterManifest;
discover(scope: CryptoAdapterScope): CryptoAdapterOperation[];
}
@@ -0,0 +1,155 @@
import { callableOperationKind, cryptoFamily } from './common';
import type {
CryptoAdapterInvocationPlan,
CryptoAdapterOperation,
CryptoAdapterScope,
CryptoAdapterToolkit,
PageCryptoAdapter,
} from './contract';
import { cryptoJsManifest } from './catalog';
const PATHS = [
'AES.encrypt', 'AES.decrypt', 'DES.encrypt', 'DES.decrypt', 'TripleDES.encrypt', 'TripleDES.decrypt',
'RC4.encrypt', 'RC4.decrypt', 'Rabbit.encrypt', 'Rabbit.decrypt', 'MD5', 'SHA1', 'SHA224', 'SHA256',
'SHA384', 'SHA512', 'SHA3', 'RIPEMD160', 'HmacMD5', 'HmacSHA1', 'HmacSHA224', 'HmacSHA256',
'HmacSHA384', 'HmacSHA512', 'PBKDF2', 'EvpKDF',
];
function ownValue(value: unknown, key: string): unknown {
if (!value || typeof value !== 'object') return undefined;
try {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
return descriptor && 'value' in descriptor ? descriptor.value : undefined;
} catch {
return undefined;
}
}
function memberName(cryptoJs: Record<string, unknown>, group: 'mode' | 'pad', value: unknown): string | undefined {
const members = cryptoJs[group];
if (!members || typeof members !== 'object') return undefined;
try {
return Object.entries(members as Record<string, unknown>).find(([, candidate]) => candidate === value)?.[0]?.slice(0, 80);
} catch {
return undefined;
}
}
function optionsMetadata(
cryptoJs: Record<string, unknown>,
value: unknown,
toolkit: CryptoAdapterToolkit,
): { summary?: string; mode?: string; padding?: string } {
if (!value || typeof value !== 'object') return {};
const mode = memberName(cryptoJs, 'mode', ownValue(value, 'mode'));
const padding = memberName(cryptoJs, 'pad', ownValue(value, 'padding'));
const iv = ownValue(value, 'iv');
const parts: string[] = [];
if (mode) parts.push(`mode=${mode}`);
if (padding) parts.push(`padding=${padding}`);
if (iv !== undefined) parts.push(`ivBytes=${toolkit.byteLength(iv) || 0}`);
return { summary: parts.length ? parts.join(' ').slice(0, 240) : undefined, mode, padding };
}
function describe(
scope: CryptoAdapterScope,
path: string,
args: unknown[],
toolkit: CryptoAdapterToolkit,
): CryptoAdapterInvocationPlan {
const cryptoJs = (scope.window as unknown as { CryptoJS?: Record<string, unknown> }).CryptoJS || {};
const normalized = path.toLowerCase();
const encrypting = normalized.includes('encrypt');
const options = normalized.includes('encrypt') || normalized.includes('decrypt')
? optionsMetadata(cryptoJs, args[2], toolkit)
: {};
let roles: Array<'data' | 'key' | 'salt' | 'options' | 'unknown'> = ['data'];
if (normalized.includes('hmac')) roles = ['data', 'key'];
else if (normalized.includes('pbkdf2') || normalized.includes('evpkdf')) roles = ['data', 'salt', 'options'];
else if (normalized.includes('.encrypt') || normalized.includes('.decrypt')) roles = ['data', 'key', 'options'];
const callableKind = callableOperationKind(path);
return {
crypto: {
adapterId: cryptoJsManifest.id,
providerKind: cryptoJsManifest.providerKind,
family: cryptoFamily(path, path),
operation: path,
algorithm: path,
mode: options.mode,
padding: options.padding,
inputEncoding: 'auto',
outputEncoding: encrypting ? 'base64' : 'auto',
state: { model: 'stateless', phase: 'one-shot' },
},
inputIndex: 0,
callableKind,
outputEncoding: encrypting ? 'base64' : 'auto',
arguments: args.slice(0, 8).map((value, index) => toolkit.argument(
index,
roles[index] || 'unknown',
value,
index === 0,
Boolean(callableKind),
roles[index] === 'options' ? options.summary : undefined,
)),
outputEvidence(value) {
const output = toolkit.defaultOutputEvidence(value);
if (!value || (typeof value !== 'object' && typeof value !== 'function') || output.length >= 48) return output;
try {
const toString = (value as { toString?: unknown }).toString;
if (typeof toString !== 'function') return output;
const text = Reflect.apply(toString, value, []);
if (typeof text !== 'string' || !text || text === '[object Object]') return output;
const extra = toolkit.collectEvidence(text, '$output:string')[0];
if (extra && !output.some((item) => item.path === extra.path && item.fingerprint === extra.fingerprint)) output.push(extra);
} catch {
// Compatible CryptoJS result objects are best-effort evidence only.
}
return output.slice(0, 48);
},
adaptInput(value) {
const originalInput = args[0];
if (originalInput && typeof originalInput === 'object'
&& typeof (originalInput as { sigBytes?: unknown }).sigBytes === 'number') {
const bytes = toolkit.bytesForInput(value);
const encoder = (cryptoJs as { enc?: { Base64?: { parse?(input: string): unknown } } }).enc?.Base64;
if (bytes && typeof encoder?.parse === 'function') return encoder.parse(toolkit.bytesToBase64(bytes));
}
return toolkit.defaultAdaptInput(value, originalInput);
},
};
}
export const cryptoJsAdapter: PageCryptoAdapter = {
manifest: cryptoJsManifest,
discover(scope): CryptoAdapterOperation[] {
const cryptoJs = (scope.window as unknown as { CryptoJS?: Record<string, unknown> }).CryptoJS;
if (!cryptoJs) return [];
const output: CryptoAdapterOperation[] = [];
for (const path of PATHS) {
const segments = path.split('.');
let owner: Record<string, unknown> | undefined = cryptoJs;
for (const segment of segments.slice(0, -1)) {
const next = owner?.[segment];
if (!next || (typeof next !== 'object' && typeof next !== 'function')) {
owner = undefined;
break;
}
owner = next as Record<string, unknown>;
}
if (!owner) continue;
output.push({
id: `cryptojs.${path}`,
operation: path,
owner,
key: segments.at(-1)!,
resultMode: 'sync',
describe: (_thisArg, args, toolkit) => describe(scope, path, args, toolkit),
createWrapper: (_original, invoke) => function recordedCryptoJs(this: unknown, ...args: unknown[]) {
return invoke(this, args);
},
});
}
return output;
},
};
@@ -0,0 +1,32 @@
import type { PageCryptoAdapter } from './contract';
import { cryptoJsAdapter } from './cryptojs';
import { jsEncryptAdapter } from './jsencrypt';
import { webCryptoAdapter } from './webcrypto';
import { smCryptoAdapter } from './sm-crypto';
import { nodeForgeAdapter } from './node-forge';
import { jsrsasignAdapter } from './jsrsasign';
import { joseAdapter } from './jose';
export const PAGE_CRYPTO_ADAPTERS: PageCryptoAdapter[] = [
webCryptoAdapter,
cryptoJsAdapter,
jsEncryptAdapter,
smCryptoAdapter,
nodeForgeAdapter,
jsrsasignAdapter,
joseAdapter,
];
export type {
CallableOperationKind,
CryptoAdapterInvocationPlan,
CryptoAdapterManifest,
CryptoAdapterOperation,
CryptoAdapterScope,
CryptoAdapterToolkit,
PageCryptoAdapter,
} from './contract';
export { createCryptoAdapterRuntime } from './registry';
export type { CryptoAdapterRuntime, CryptoAdapterRuntimeHost } from './registry';
export { CRYPTO_ADAPTER_MANIFESTS, cryptoAdapterLabel } from './catalog';
@@ -0,0 +1,247 @@
import type { BrowserRecordingCrypto } from '@/types/models';
import type {
CryptoAdapterOperation,
CryptoAdapterToolkit,
PageCryptoAdapter,
} from './contract';
import { joseManifest } from './catalog';
type JoseFamily = 'signature' | 'asymmetric';
interface JoseBuilderDefinition {
key: 'SignJWT' | 'CompactSign' | 'CompactEncrypt';
family: JoseFamily;
finalMethod: 'sign' | 'encrypt';
setters: string[];
}
const BUILDERS: JoseBuilderDefinition[] = [
{
key: 'SignJWT', family: 'signature', finalMethod: 'sign',
setters: ['setProtectedHeader', 'setIssuer', 'setSubject', 'setAudience', 'setJti', 'setNotBefore', 'setExpirationTime', 'setIssuedAt'],
},
{
key: 'CompactSign', family: 'signature', finalMethod: 'sign',
setters: ['setProtectedHeader'],
},
{
key: 'CompactEncrypt', family: 'asymmetric', finalMethod: 'encrypt',
setters: ['setProtectedHeader', 'setKeyManagementParameters', 'setContentEncryptionKey', 'setInitializationVector'],
},
];
function record(value: unknown): Record<string, unknown> | undefined {
return value && (typeof value === 'object' || typeof value === 'function')
? value as Record<string, unknown>
: undefined;
}
function method(owner: Record<string, unknown> | undefined, key: string): boolean {
try { return Boolean(owner && typeof owner[key] === 'function'); } catch { return false; }
}
function callWrapper(invoke: (thisArg: unknown, args: unknown[]) => unknown): Function {
return function recordedJose(this: unknown, ...args: unknown[]) { return invoke(this, args); };
}
function constructorWrapper(
original: Function,
invoke: (thisArg: unknown, args: unknown[]) => unknown,
): Function {
return new Proxy(original, {
apply(_target, thisArg, args) { return invoke(thisArg, args); },
construct(_target, args) { return invoke(undefined, args) as object; },
});
}
function keyMetadata(
value: unknown,
toolkit: CryptoAdapterToolkit,
preferredKind: NonNullable<BrowserRecordingCrypto['key']>['kind'],
): BrowserRecordingCrypto['key'] {
const key = record(value);
if (!key) return { kind: preferredKind };
let kind = preferredKind;
let bits: number | undefined;
const parts: string[] = [];
try {
if (key.type === 'private' || key.type === 'public' || key.type === 'secret') kind = key.type;
const algorithm = record(key.algorithm);
if (typeof algorithm?.name === 'string') parts.push(`name=${algorithm.name}`);
if (typeof algorithm?.namedCurve === 'string') parts.push(`crv=${algorithm.namedCurve}`);
if (Number.isSafeInteger(algorithm?.length)) bits = Number(algorithm?.length);
for (const field of ['kty', 'crv', 'x', 'y', 'n', 'e', 'kid', 'use', 'alg']) {
const item = key[field];
if (typeof item === 'string' || typeof item === 'number') parts.push(`${field}=${String(item)}`);
}
} catch { /* CryptoKey and proxy metadata are best effort. */ }
return { kind, bits, fingerprint: parts.length ? toolkit.fingerprint(parts.join('&')) : undefined };
}
function algorithmFromHeader(value: unknown): string | undefined {
const header = record(value);
if (!header) return undefined;
const parts: string[] = [];
try {
if (typeof header.alg === 'string') parts.push(header.alg);
if (typeof header.enc === 'string') parts.push(`enc=${header.enc}`);
if (typeof header.zip === 'string') parts.push(`zip=${header.zip}`);
} catch { return undefined; }
return parts.length ? parts.join(' ').slice(0, 240) : undefined;
}
function crypto(
definition: JoseBuilderDefinition,
operation: string,
phase: NonNullable<BrowserRecordingCrypto['state']>['phase'],
correlationId: string,
algorithm?: string,
key?: BrowserRecordingCrypto['key'],
): BrowserRecordingCrypto {
return {
adapterId: joseManifest.id,
providerKind: joseManifest.providerKind,
family: definition.family,
operation,
algorithm,
inputEncoding: 'auto',
outputEncoding: 'auto',
state: { model: 'async-ready', phase, correlationId },
key,
};
}
function builderOperations(
value: unknown,
definition: JoseBuilderDefinition,
correlationId: string,
): CryptoAdapterOperation[] {
const owner = record(value);
if (!owner) return [];
const context: { algorithm?: string; key?: BrowserRecordingCrypto['key'] } = {};
const output: CryptoAdapterOperation[] = [];
for (const setter of definition.setters) {
if (!method(owner, setter)) continue;
output.push({
id: `jose.${correlationId}.${definition.key}.${setter}`,
operation: `${definition.key}.${setter}`,
owner,
key: setter,
resultMode: 'sync',
describe: (_thisArg, args, toolkit) => {
if (setter === 'setProtectedHeader') context.algorithm = algorithmFromHeader(args[0]) || context.algorithm;
return {
crypto: crypto(definition, `${definition.key}.${setter}`, 'update', correlationId, context.algorithm, context.key),
inputIndex: -1,
arguments: args.slice(0, 4).map((argument, index) => toolkit.argument(index, index === 0 ? 'options' : 'unknown', argument, false, false)),
outputEvidence: () => [],
};
},
createWrapper: (_original, invoke) => callWrapper(invoke),
});
}
if (method(owner, definition.finalMethod)) output.push({
id: `jose.${correlationId}.${definition.key}.${definition.finalMethod}`,
operation: `${definition.key}.${definition.finalMethod}`,
owner,
key: definition.finalMethod,
resultMode: 'promise',
describe: (_thisArg, args, toolkit) => {
context.key = keyMetadata(args[0], toolkit, definition.finalMethod === 'sign' ? 'private' : 'unknown');
return {
crypto: crypto(definition, `${definition.key}.${definition.finalMethod}`, 'final', correlationId, context.algorithm, context.key),
inputIndex: -1,
arguments: args.slice(0, 4).map((argument, index) => toolkit.argument(index, index === 0 ? 'key' : 'options', argument, false, false)),
outputError: (result) => result === false || result === null ? `${definition.key}.${definition.finalMethod} returned no result` : undefined,
};
},
createWrapper: (_original, invoke) => callWrapper(invoke),
});
return output;
}
function builderConstructor(
root: Record<string, unknown>,
definition: JoseBuilderDefinition,
): CryptoAdapterOperation | undefined {
if (!method(root, definition.key)) return undefined;
return {
id: `jose.${definition.key}.constructor`,
operation: `${definition.key}.create`,
owner: root,
key: definition.key,
invocationMode: 'construct',
resultMode: 'sync',
describe: (_thisArg, args, toolkit) => {
const correlationId = toolkit.unique('jose-session');
return {
crypto: crypto(definition, `${definition.key}.create`, 'create', correlationId),
inputIndex: 0,
arguments: args.slice(0, 4).map((argument, index) => toolkit.argument(index, index === 0 ? 'data' : 'options', argument, false, false)),
outputEvidence: () => [],
discoverResult: (value) => builderOperations(value, definition, correlationId),
};
},
createWrapper: constructorWrapper,
};
}
interface AsyncOperationDefinition {
key: string;
operation: string;
family: BrowserRecordingCrypto['family'];
roles: Array<'data' | 'key' | 'options'>;
keyKind: NonNullable<BrowserRecordingCrypto['key']>['kind'];
}
const ASYNC_OPERATIONS: AsyncOperationDefinition[] = [
{ key: 'compactVerify', operation: 'CompactVerify.verify', family: 'signature', roles: ['data', 'key', 'options'], keyKind: 'public' },
{ key: 'jwtVerify', operation: 'JWT.verify', family: 'signature', roles: ['data', 'key', 'options'], keyKind: 'public' },
{ key: 'compactDecrypt', operation: 'CompactDecrypt.decrypt', family: 'asymmetric', roles: ['data', 'key', 'options'], keyKind: 'private' },
{ key: 'jwtDecrypt', operation: 'JWT.decrypt', family: 'asymmetric', roles: ['data', 'key', 'options'], keyKind: 'private' },
{ key: 'importJWK', operation: 'JWK.import', family: 'key-management', roles: ['key', 'options', 'options'], keyKind: 'unknown' },
{ key: 'exportJWK', operation: 'JWK.export', family: 'key-management', roles: ['key'], keyKind: 'unknown' },
];
function asyncOperation(root: Record<string, unknown>, definition: AsyncOperationDefinition): CryptoAdapterOperation | undefined {
if (!method(root, definition.key)) return undefined;
return {
id: `jose.${definition.key}`,
operation: definition.operation,
owner: root,
key: definition.key,
resultMode: 'promise',
describe: (_thisArg, args, toolkit) => {
const keyIndex = definition.roles.indexOf('key');
return {
crypto: {
adapterId: joseManifest.id,
providerKind: joseManifest.providerKind,
family: definition.family,
operation: definition.operation,
inputEncoding: 'auto',
outputEncoding: 'auto',
state: { model: 'async-ready', phase: 'one-shot' },
key: keyIndex >= 0 ? keyMetadata(args[keyIndex], toolkit, definition.keyKind) : undefined,
},
inputIndex: -1,
arguments: args.slice(0, 6).map((argument, index) => toolkit.argument(index, definition.roles[index] || 'unknown', argument, false, false)),
outputEvidence: definition.family === 'key-management' ? () => [] : undefined,
outputError: (result) => result === false || result === null ? `${definition.operation} returned no result` : undefined,
};
},
createWrapper: (_original, invoke) => callWrapper(invoke),
};
}
export const joseAdapter: PageCryptoAdapter = {
manifest: joseManifest,
discover(scope): CryptoAdapterOperation[] {
const root = record((scope.window as unknown as { jose?: unknown }).jose);
if (!root) return [];
return [
...BUILDERS.map((definition) => builderConstructor(root, definition)),
...ASYNC_OPERATIONS.map((definition) => asyncOperation(root, definition)),
].filter((operation): operation is CryptoAdapterOperation => Boolean(operation));
},
};
@@ -0,0 +1,112 @@
import type { BrowserRecordingCrypto } from '@/types/models';
import type {
CryptoAdapterInvocationPlan,
CryptoAdapterOperation,
CryptoAdapterToolkit,
PageCryptoAdapter,
} from './contract';
import { jsEncryptManifest } from './catalog';
type JSEncryptOperation = 'encrypt' | 'decrypt' | 'sign' | 'verify';
function keyMetadata(instance: unknown, toolkit: CryptoAdapterToolkit): BrowserRecordingCrypto['key'] {
if (!instance || typeof instance !== 'object') return { kind: 'unknown' };
const record = instance as Record<string, unknown>;
const key = record.key && typeof record.key === 'object' ? record.key as Record<string, unknown> : undefined;
let bits: number | undefined;
try {
const modulus = key?.n as { bitLength?: unknown } | undefined;
if (typeof modulus?.bitLength === 'function') {
const value = Reflect.apply(modulus.bitLength as Function, modulus, []);
if (Number.isSafeInteger(value) && value >= 256 && value <= 32_768) bits = Number(value);
}
} catch {
// Key metadata must never affect the page operation.
}
let fingerprint: string | undefined;
try {
const modulus = key?.n as { toString?: unknown } | undefined;
if (typeof modulus?.toString === 'function') {
const publicMaterial = `${Reflect.apply(modulus.toString as Function, modulus, [16])}:${String(key?.e || '')}`;
if (publicMaterial.length > 1) fingerprint = toolkit.fingerprint(publicMaterial);
}
} catch {
// Compatible implementations may not expose bounded public metadata.
}
return {
kind: key?.d ? 'private' : key?.n ? 'public' : 'unknown',
bits,
fingerprint,
};
}
function describe(
operation: JSEncryptOperation,
instance: unknown,
args: unknown[],
toolkit: CryptoAdapterToolkit,
): CryptoAdapterInvocationPlan {
const encrypting = operation === 'encrypt' || operation === 'sign';
const roles: Array<'data' | 'signature' | 'algorithm' | 'options' | 'unknown'> = operation === 'verify'
? ['data', 'signature', 'algorithm']
: operation === 'sign'
? ['data', 'algorithm', 'options']
: ['data'];
return {
crypto: {
adapterId: jsEncryptManifest.id,
providerKind: jsEncryptManifest.providerKind,
family: operation === 'sign' || operation === 'verify' ? 'signature' : 'asymmetric',
operation,
algorithm: 'RSA',
padding: operation === 'encrypt' || operation === 'decrypt' ? 'PKCS1-v1_5' : 'PKCS1-v1_5-signature',
inputEncoding: operation === 'decrypt' ? 'base64' : 'utf8',
outputEncoding: encrypting ? 'base64' : operation === 'decrypt' ? 'utf8' : 'auto',
state: { model: 'receiver', phase: 'one-shot' },
key: keyMetadata(instance, toolkit),
},
inputIndex: 0,
callableKind: operation,
outputEncoding: encrypting ? 'base64' : operation === 'decrypt' ? 'utf8' : 'auto',
arguments: args.slice(0, 8).map((value, index) => toolkit.argument(
index,
roles[index] || 'unknown',
value,
index === 0,
true,
typeof value === 'function' ? value.name || 'function' : undefined,
)),
outputError: (value) => value === false || value === null ? 'JSEncrypt returned no result' : undefined,
adaptInput: (value) => toolkit.defaultAdaptInput(value, args[0]),
};
}
function wrapper(
operation: JSEncryptOperation,
invoke: (thisArg: unknown, args: unknown[]) => unknown,
): Function {
switch (operation) {
case 'encrypt': return function recordedJSEncryptEncrypt(this: unknown, ...args: unknown[]) { return invoke(this, args); };
case 'decrypt': return function recordedJSEncryptDecrypt(this: unknown, ...args: unknown[]) { return invoke(this, args); };
case 'sign': return function recordedJSEncryptSign(this: unknown, ...args: unknown[]) { return invoke(this, args); };
case 'verify': return function recordedJSEncryptVerify(this: unknown, ...args: unknown[]) { return invoke(this, args); };
}
}
export const jsEncryptAdapter: PageCryptoAdapter = {
manifest: jsEncryptManifest,
discover(scope): CryptoAdapterOperation[] {
const constructor = (scope.window as unknown as { JSEncrypt?: { prototype?: Record<string, unknown> } }).JSEncrypt;
const owner = constructor?.prototype;
if (!owner) return [];
return (['encrypt', 'decrypt', 'sign', 'verify'] as JSEncryptOperation[]).map((operation) => ({
id: `jsencrypt.${operation}`,
operation,
owner,
key: operation,
resultMode: 'sync',
describe: (thisArg, args, toolkit) => describe(operation, thisArg, args, toolkit),
createWrapper: (_original, invoke) => wrapper(operation, invoke),
}));
},
};
@@ -0,0 +1,290 @@
import type { BrowserRecordingCrypto } from '@/types/models';
import type {
CryptoAdapterInvocationPlan,
CryptoAdapterOperation,
CryptoAdapterToolkit,
PageCryptoAdapter,
} from './contract';
import { jsrsasignManifest } from './catalog';
function record(value: unknown): Record<string, unknown> | undefined {
return value && (typeof value === 'object' || typeof value === 'function')
? value as Record<string, unknown>
: undefined;
}
function method(owner: Record<string, unknown> | undefined, key: string): boolean {
try { return Boolean(owner && typeof owner[key] === 'function'); } catch { return false; }
}
function stringProperty(value: unknown, key: string): string | undefined {
const input = record(value);
try { return typeof input?.[key] === 'string' ? String(input[key]).slice(0, 160) : undefined; } catch { return undefined; }
}
function callWrapper(invoke: (thisArg: unknown, args: unknown[]) => unknown): Function {
return function recordedJsrsasign(this: unknown, ...args: unknown[]) { return invoke(this, args); };
}
function constructorWrapper(
original: Function,
invoke: (thisArg: unknown, args: unknown[]) => unknown,
): Function {
return new Proxy(original, {
apply(_target, thisArg, args) { return invoke(thisArg, args); },
construct(_target, args) { return invoke(undefined, args) as object; },
});
}
function publicKeyMaterial(value: unknown): string | undefined {
if (typeof value === 'string') return value;
const key = record(value);
if (!key) return undefined;
const fields = ['kty', 'crv', 'x', 'y', 'n', 'e', 'kid', 'use', 'alg'];
const parts: string[] = [];
for (const field of fields) {
try {
const item = key[field];
if (typeof item === 'string' || typeof item === 'number') parts.push(`${field}=${String(item)}`);
} catch { /* Proxy-backed key metadata is optional. */ }
}
try {
const modulus = record(key.n);
if (typeof modulus?.toString === 'function') parts.push(`n=${Reflect.apply(modulus.toString as Function, key.n, [16])}`);
if (key.e !== undefined) parts.push(`e=${String(key.e)}`);
} catch { /* Big integer internals differ by release. */ }
return parts.length ? parts.join('&') : undefined;
}
function keyMetadata(
value: unknown,
toolkit: CryptoAdapterToolkit,
preferredKind: NonNullable<BrowserRecordingCrypto['key']>['kind'] = 'unknown',
): BrowserRecordingCrypto['key'] {
const text = typeof value === 'string' ? value : '';
const kind = /PRIVATE KEY/.test(text) || Boolean(record(value)?.d) ? 'private'
: /PUBLIC KEY|CERTIFICATE/.test(text) ? 'public'
: preferredKind;
let bits: number | undefined;
try {
const modulus = record(record(value)?.n);
const result = typeof modulus?.bitLength === 'function'
? Reflect.apply(modulus.bitLength as Function, record(value)?.n, [])
: undefined;
if (Number.isSafeInteger(result) && Number(result) >= 256 && Number(result) <= 32_768) bits = Number(result);
} catch { /* Key size is optional. */ }
const material = publicKeyMaterial(value);
return { kind, bits, fingerprint: material ? toolkit.fingerprint(material) : undefined };
}
function signatureCrypto(
operation: string,
algorithm: string | undefined,
correlationId: string,
phase: NonNullable<BrowserRecordingCrypto['state']>['phase'],
key?: BrowserRecordingCrypto['key'],
): BrowserRecordingCrypto {
return {
adapterId: jsrsasignManifest.id,
providerKind: jsrsasignManifest.providerKind,
family: 'signature',
operation,
algorithm,
inputEncoding: operation.toLowerCase().includes('hex') ? 'hex' : 'utf8',
outputEncoding: 'hex',
state: { model: 'session', phase, correlationId },
key,
};
}
function signatureSessionOperations(
value: unknown,
correlationId: string,
initialAlgorithm: string | undefined,
): CryptoAdapterOperation[] {
const owner = record(value);
if (!owner) return [];
const context: { algorithm?: string; key?: BrowserRecordingCrypto['key'] } = { algorithm: initialAlgorithm };
const output: CryptoAdapterOperation[] = [];
for (const key of ['init', 'initSign', 'initVerifyByPublicKey', 'initVerifyByCertificatePEM']) {
if (!method(owner, key)) continue;
output.push({
id: `jsrsasign.${correlationId}.Signature.${key}`,
operation: `Signature.${key}`,
owner,
key,
resultMode: 'sync',
describe: (_thisArg, args, toolkit) => {
context.key = keyMetadata(args[0], toolkit, key.includes('Verify') ? 'public' : 'unknown');
return {
crypto: signatureCrypto(`Signature.${key}`, context.algorithm, correlationId, 'init', context.key),
inputIndex: -1,
arguments: args.slice(0, 3).map((argument, index) => toolkit.argument(
index, index === 0 ? 'key' : 'options', argument, false, false,
)),
outputEvidence: () => [],
};
},
createWrapper: (_original, invoke) => callWrapper(invoke),
});
}
if (method(owner, 'setAlgAndProvider')) output.push({
id: `jsrsasign.${correlationId}.Signature.setAlgAndProvider`,
operation: 'Signature.setAlgAndProvider',
owner,
key: 'setAlgAndProvider',
resultMode: 'sync',
describe: (_thisArg, args, toolkit) => {
if (typeof args[0] === 'string') context.algorithm = args[0].slice(0, 160);
return {
crypto: signatureCrypto('Signature.setAlgAndProvider', context.algorithm, correlationId, 'init', context.key),
inputIndex: -1,
arguments: args.slice(0, 2).map((argument, index) => toolkit.argument(index, 'algorithm', argument, false, false)),
outputEvidence: () => [],
};
},
createWrapper: (_original, invoke) => callWrapper(invoke),
});
for (const key of ['updateString', 'updateHex']) {
if (!method(owner, key)) continue;
output.push({
id: `jsrsasign.${correlationId}.Signature.${key}`,
operation: `Signature.${key}`,
owner,
key,
resultMode: 'sync',
describe: (_thisArg, args, toolkit) => ({
crypto: signatureCrypto(`Signature.${key}`, context.algorithm, correlationId, 'update', context.key),
inputIndex: 0,
arguments: args.slice(0, 2).map((argument, index) => toolkit.argument(index, index === 0 ? 'data' : 'options', argument, false, false)),
}),
createWrapper: (_original, invoke) => callWrapper(invoke),
});
}
for (const key of ['sign', 'signString', 'signHex', 'verify']) {
if (!method(owner, key)) continue;
const verify = key === 'verify';
const inputIndex = key === 'signString' || key === 'signHex' ? 0 : -1;
output.push({
id: `jsrsasign.${correlationId}.Signature.${key}`,
operation: `Signature.${key}`,
owner,
key,
resultMode: 'sync',
describe: (_thisArg, args, toolkit) => ({
crypto: signatureCrypto(`Signature.${key}`, context.algorithm, correlationId, 'final', context.key),
inputIndex,
arguments: args.slice(0, 3).map((argument, index) => toolkit.argument(
index, verify && index === 0 ? 'signature' : index === inputIndex ? 'data' : 'options', argument, false, false,
)),
outputError: (result) => verify && result === false ? 'jsrsasign signature verification failed' : undefined,
}),
createWrapper: (_original, invoke) => callWrapper(invoke),
});
}
return output;
}
function signatureConstructor(root: Record<string, unknown>): CryptoAdapterOperation | undefined {
const crypto = record(root.crypto);
if (!method(crypto, 'Signature')) return undefined;
return {
id: 'jsrsasign.Signature.constructor',
operation: 'Signature.create',
owner: crypto!,
key: 'Signature',
invocationMode: 'construct',
resultMode: 'sync',
describe: (_thisArg, args, toolkit): CryptoAdapterInvocationPlan => {
const correlationId = toolkit.unique('jsrsasign-signature');
const algorithm = stringProperty(args[0], 'alg') || (typeof args[0] === 'string' ? args[0].slice(0, 160) : undefined);
return {
crypto: signatureCrypto('Signature.create', algorithm, correlationId, 'create'),
inputIndex: -1,
arguments: args.slice(0, 3).map((argument, index) => toolkit.argument(index, index === 0 ? 'options' : 'unknown', argument, false, false)),
outputEvidence: () => [],
discoverResult: (value) => signatureSessionOperations(value, correlationId, algorithm),
};
},
createWrapper: constructorWrapper,
};
}
function oneShotOperation(
owner: Record<string, unknown>,
key: string,
operation: string,
family: BrowserRecordingCrypto['family'],
roles: Array<'algorithm' | 'options' | 'data' | 'key' | 'signature'>,
inputIndex: number,
callable: 'sign' | undefined,
): CryptoAdapterOperation | undefined {
if (!method(owner, key)) return undefined;
return {
id: `jsrsasign.${operation}`,
operation,
owner,
key,
resultMode: 'sync',
describe: (_thisArg, args, toolkit) => {
const algorithmIndex = roles.indexOf('algorithm');
const keyIndex = roles.indexOf('key');
const algorithm = algorithmIndex >= 0 && typeof args[algorithmIndex] === 'string' ? args[algorithmIndex].slice(0, 160) : undefined;
return {
crypto: {
adapterId: jsrsasignManifest.id,
providerKind: jsrsasignManifest.providerKind,
family,
operation,
algorithm,
inputEncoding: 'auto',
outputEncoding: operation.includes('sign') ? 'auto' : undefined,
state: { model: 'stateless', phase: 'one-shot' },
key: keyIndex >= 0 ? keyMetadata(args[keyIndex], toolkit, operation.includes('sign') ? 'private' : 'public') : undefined,
},
inputIndex,
callableKind: callable,
arguments: args.slice(0, 8).map((argument, index) => toolkit.argument(
index, roles[index] || 'unknown', argument, index === inputIndex, Boolean(callable),
)),
outputEvidence: family === 'key-management' ? () => [] : undefined,
outputError: (result) => result === false || result === null ? `${operation} returned no result` : undefined,
adaptInput: inputIndex >= 0 ? (value) => toolkit.defaultAdaptInput(value, args[inputIndex]) : undefined,
};
},
createWrapper: (_original, invoke) => callWrapper(invoke),
};
}
export const jsrsasignAdapter: PageCryptoAdapter = {
manifest: jsrsasignManifest,
discover(scope): CryptoAdapterOperation[] {
const globals = scope.window as unknown as { KJUR?: Record<string, unknown>; KEYUTIL?: Record<string, unknown> };
const kjur = globals.KJUR;
if (!kjur && !globals.KEYUTIL) return [];
const operations: Array<CryptoAdapterOperation | undefined> = [];
if (kjur) operations.push(signatureConstructor(kjur));
const jws = record(record(record(kjur)?.jws)?.JWS);
if (jws) {
operations.push(
oneShotOperation(jws, 'sign', 'JWS.sign', 'signature', ['algorithm', 'options', 'data', 'key', 'options'], 2, 'sign'),
oneShotOperation(jws, 'verify', 'JWS.verify', 'signature', ['data', 'key', 'options'], -1, undefined),
oneShotOperation(jws, 'verifyJWT', 'JWT.verify', 'signature', ['data', 'key', 'options'], -1, undefined),
oneShotOperation(jws, 'getJWKthumbprint', 'JWK.thumbprint', 'key-management', ['key'], -1, undefined),
);
}
const keyutil = globals.KEYUTIL;
if (keyutil) {
operations.push(
oneShotOperation(keyutil, 'getKey', 'KEYUTIL.getKey', 'key-management', ['key', 'options', 'options'], -1, undefined),
oneShotOperation(keyutil, 'getJWK', 'KEYUTIL.getJWK', 'key-management', ['key', 'options', 'options'], -1, undefined),
oneShotOperation(keyutil, 'getPEM', 'KEYUTIL.getPEM', 'key-management', ['key', 'options', 'options'], -1, undefined),
);
}
return operations.filter((operation): operation is CryptoAdapterOperation => Boolean(operation));
},
};
@@ -0,0 +1,386 @@
import type { BrowserRecordingCrypto, BrowserRecordingValueEvidence } from '@/types/models';
import type {
CallableOperationKind,
CryptoAdapterInvocationPlan,
CryptoAdapterOperation,
CryptoAdapterToolkit,
PageCryptoAdapter,
} from './contract';
import { nodeForgeManifest } from './catalog';
type ForgeKeyKind = 'public' | 'private';
function record(value: unknown): Record<string, unknown> | undefined {
return value && (typeof value === 'object' || typeof value === 'function')
? value as Record<string, unknown>
: undefined;
}
function target(root: Record<string, unknown>, path: string): { owner: Record<string, unknown>; key: string } | undefined {
const segments = path.split('.');
let owner = root;
for (const segment of segments.slice(0, -1)) {
const next = record(owner[segment]);
if (!next) return undefined;
owner = next;
}
return { owner, key: segments.at(-1)! };
}
function method(owner: Record<string, unknown>, key: string): boolean {
try { return typeof owner[key] === 'function'; } catch { return false; }
}
function wrapper(invoke: (thisArg: unknown, args: unknown[]) => unknown): Function {
return function recordedNodeForge(this: unknown, ...args: unknown[]) { return invoke(this, args); };
}
function forgeBufferValue(value: unknown): unknown {
const input = record(value);
if (!input) return value;
try {
if (typeof input.bytes === 'function') {
const lengthValue = typeof input.length === 'function' ? Reflect.apply(input.length as Function, value, []) : undefined;
const length = Number.isFinite(lengthValue) ? Math.max(0, Math.min(Number(lengthValue), 262_144)) : undefined;
return Reflect.apply(input.bytes as Function, value, length === undefined ? [] : [length]);
}
} catch {
return value;
}
return value;
}
function bufferEvidence(value: unknown, path: string, toolkit: CryptoAdapterToolkit): BrowserRecordingValueEvidence[] {
return toolkit.collectEvidence(forgeBufferValue(value), path);
}
function keyMetadata(value: unknown, kind: NonNullable<BrowserRecordingCrypto['key']>['kind'], toolkit: CryptoAdapterToolkit): BrowserRecordingCrypto['key'] {
const input = record(value);
let bits: number | undefined;
let publicMaterial: string | undefined;
try {
const modulus = record(input?.n);
if (typeof modulus?.bitLength === 'function') {
const result = Reflect.apply(modulus.bitLength as Function, input?.n, []);
if (Number.isSafeInteger(result) && result >= 256 && result <= 32_768) bits = Number(result);
}
if (typeof modulus?.toString === 'function') {
publicMaterial = `${Reflect.apply(modulus.toString as Function, input?.n, [16])}:${String(input?.e || '')}`;
}
} catch {
// Compatible forge builds may hide bigint internals.
}
if (!publicMaterial) {
try {
if (typeof value === 'string') publicMaterial = value;
else {
const bytes = toolkit.bytesForInput(forgeBufferValue(value));
if (bytes) publicMaterial = toolkit.bytesToBase64(bytes);
}
} catch {
publicMaterial = undefined;
}
}
return { kind, bits, fingerprint: publicMaterial ? toolkit.fingerprint(publicMaterial) : undefined };
}
function crypto(
family: BrowserRecordingCrypto['family'],
operation: string,
algorithm: string | undefined,
model: NonNullable<BrowserRecordingCrypto['state']>['model'],
phase: NonNullable<BrowserRecordingCrypto['state']>['phase'],
correlationId: string,
key?: BrowserRecordingCrypto['key'],
): BrowserRecordingCrypto {
return {
adapterId: nodeForgeManifest.id,
providerKind: nodeForgeManifest.providerKind,
family,
operation,
algorithm,
inputEncoding: 'auto',
outputEncoding: 'auto',
state: { model, phase, correlationId },
key,
};
}
function outputBufferOperations(
buffer: unknown,
correlationId: string,
family: BrowserRecordingCrypto['family'],
algorithm: string | undefined,
operationPrefix: string,
): CryptoAdapterOperation[] {
const owner = record(buffer);
if (!owner) return [];
return ['getBytes', 'bytes', 'toHex'].filter((key) => method(owner, key)).map((key) => ({
id: `node-forge.${correlationId}.${operationPrefix}.${key}`,
operation: `${operationPrefix}.output.${key}`,
owner,
key,
resultMode: 'sync' as const,
describe: (_thisArg: unknown, args: unknown[], toolkit: CryptoAdapterToolkit): CryptoAdapterInvocationPlan => ({
crypto: crypto(family, `${operationPrefix}.output.${key}`, algorithm, 'stream', 'final', correlationId),
inputIndex: -1,
arguments: args.slice(0, 2).map((value, index) => toolkit.argument(index, 'options', value, false, false)),
outputEvidence: (value) => toolkit.collectEvidence(value, '$output'),
}),
createWrapper: (_original: Function, invoke: (thisArg: unknown, args: unknown[]) => unknown) => wrapper(invoke),
}));
}
function rsaOperations(
value: unknown,
kind: ForgeKeyKind,
correlationId: string,
toolkit: CryptoAdapterToolkit,
): CryptoAdapterOperation[] {
const owner = record(value);
if (!owner) return [];
const metadata = keyMetadata(value, kind, toolkit);
const definitions: Array<{
key: string;
operation: string;
family: BrowserRecordingCrypto['family'];
callable?: CallableOperationKind;
roles: Array<'data' | 'signature' | 'options'>;
}> = kind === 'public' ? [
{ key: 'encrypt', operation: 'rsa.encrypt', family: 'asymmetric', callable: 'encrypt', roles: ['data', 'options', 'options'] },
{ key: 'verify', operation: 'rsa.verify', family: 'signature', roles: ['data', 'signature', 'options'] },
] : [
{ key: 'decrypt', operation: 'rsa.decrypt', family: 'asymmetric', callable: 'decrypt', roles: ['data', 'options', 'options'] },
{ key: 'sign', operation: 'rsa.sign', family: 'signature', roles: ['data', 'options'] },
];
return definitions.filter((definition) => method(owner, definition.key)).map((definition) => ({
id: `node-forge.${correlationId}.${definition.operation}`,
operation: definition.operation,
owner,
key: definition.key,
resultMode: 'sync' as const,
describe: (_thisArg: unknown, args: unknown[], adapterToolkit: CryptoAdapterToolkit): CryptoAdapterInvocationPlan => ({
crypto: crypto(definition.family, definition.operation, 'RSA', 'receiver', 'one-shot', correlationId, metadata),
inputIndex: 0,
callableKind: definition.callable,
arguments: args.slice(0, 8).map((argument, index) => adapterToolkit.argument(
index, definition.roles[index] || 'unknown', argument, index === 0, Boolean(definition.callable),
)),
outputError: (result) => result === false || result === null ? 'node-forge RSA returned no result' : undefined,
adaptInput: (input) => adapterToolkit.defaultAdaptInput(input, args[0]),
}),
createWrapper: (_original: Function, invoke: (thisArg: unknown, args: unknown[]) => unknown) => wrapper(invoke),
}));
}
function cipherSessionOperations(
value: unknown,
direction: 'encrypt' | 'decrypt',
algorithm: string | undefined,
correlationId: string,
): CryptoAdapterOperation[] {
const owner = record(value);
if (!owner) return [];
const family: BrowserRecordingCrypto['family'] = 'symmetric';
const output: CryptoAdapterOperation[] = [];
const definitions = [
{ key: 'start', phase: 'init' as const, inputIndex: -1, role: 'options' as const },
{ key: 'update', phase: 'update' as const, inputIndex: 0, role: 'data' as const },
{ key: 'finish', phase: 'final' as const, inputIndex: -1, role: 'options' as const },
];
for (const definition of definitions) {
if (!method(owner, definition.key)) continue;
const operation = `cipher.${direction}.${definition.key}`;
output.push({
id: `node-forge.${correlationId}.${operation}`,
operation,
owner,
key: definition.key,
resultMode: 'sync',
describe: (_thisArg, args, toolkit) => ({
crypto: crypto(family, operation, algorithm, 'stream', definition.phase, correlationId),
inputIndex: definition.inputIndex,
arguments: args.slice(0, 8).map((argument, index) => toolkit.argument(
index, index === 0 ? definition.role : 'options', argument, false, false,
)),
outputEvidence: definition.key === 'finish'
? () => bufferEvidence(owner.output, '$receiver.output', toolkit)
: () => [],
inputEvidence: definition.key === 'update'
? (input) => bufferEvidence(input, '$input', toolkit)
: undefined,
discoverResult: definition.key === 'start'
? () => outputBufferOperations(owner.output, correlationId, family, algorithm, `cipher.${direction}`)
: undefined,
outputError: definition.key === 'finish'
? (result) => result === false ? 'node-forge cipher authentication or padding failed' : undefined
: undefined,
}),
createWrapper: (_original, invoke) => wrapper(invoke),
});
}
output.push(...outputBufferOperations(owner.output, correlationId, family, algorithm, `cipher.${direction}`));
return output;
}
function digestSessionOperations(
value: unknown,
algorithm: string,
correlationId: string,
): CryptoAdapterOperation[] {
const owner = record(value);
if (!owner) return [];
const output: CryptoAdapterOperation[] = [];
for (const definition of [
{ key: 'start', phase: 'init' as const, inputIndex: -1 },
{ key: 'update', phase: 'update' as const, inputIndex: 0 },
{ key: 'digest', phase: 'final' as const, inputIndex: -1 },
]) {
if (!method(owner, definition.key)) continue;
const operation = `digest.${definition.key}`;
output.push({
id: `node-forge.${correlationId}.${operation}`,
operation,
owner,
key: definition.key,
resultMode: 'sync',
describe: (_thisArg, args, toolkit) => ({
crypto: crypto('digest', operation, algorithm, 'session', definition.phase, correlationId),
inputIndex: definition.inputIndex,
arguments: args.slice(0, 4).map((argument, index) => toolkit.argument(index, index === 0 ? 'data' : 'options', argument, false, false)),
outputEvidence: definition.key === 'digest'
? (result) => bufferEvidence(result, '$output', toolkit)
: () => [],
inputEvidence: definition.key === 'update'
? (input) => bufferEvidence(input, '$input', toolkit)
: undefined,
discoverResult: definition.key === 'digest'
? (result) => outputBufferOperations(result, correlationId, 'digest', algorithm, 'digest')
: undefined,
}),
createWrapper: (_original, invoke) => wrapper(invoke),
});
}
return output;
}
function hmacSessionOperations(value: unknown, correlationId: string): CryptoAdapterOperation[] {
const owner = record(value);
if (!owner) return [];
const context: { algorithm?: string; key?: BrowserRecordingCrypto['key'] } = {};
const output: CryptoAdapterOperation[] = [];
for (const definition of [
{ key: 'start', phase: 'init' as const, inputIndex: -1 },
{ key: 'update', phase: 'update' as const, inputIndex: 0 },
{ key: 'digest', phase: 'final' as const, inputIndex: -1 },
]) {
if (!method(owner, definition.key)) continue;
const operation = `hmac.${definition.key}`;
output.push({
id: `node-forge.${correlationId}.${operation}`,
operation,
owner,
key: definition.key,
resultMode: 'sync',
describe: (_thisArg, args, toolkit) => {
if (definition.key === 'start') {
context.algorithm = typeof args[0] === 'string' ? args[0].slice(0, 80) : 'HMAC';
context.key = keyMetadata(args[1], 'secret', toolkit);
}
return {
crypto: crypto('mac', operation, context.algorithm || 'HMAC', 'session', definition.phase, correlationId, context.key),
inputIndex: definition.inputIndex,
arguments: args.slice(0, 4).map((argument, index) => toolkit.argument(
index, definition.key === 'start' && index === 1 ? 'key' : index === 0 ? 'data' : 'options', argument, false, false,
)),
outputEvidence: definition.key === 'digest'
? (result) => bufferEvidence(result, '$output', toolkit)
: () => [],
inputEvidence: definition.key === 'update'
? (input) => bufferEvidence(input, '$input', toolkit)
: undefined,
discoverResult: definition.key === 'digest'
? (result) => outputBufferOperations(result, correlationId, 'mac', context.algorithm || 'HMAC', 'hmac')
: undefined,
};
},
createWrapper: (_original, invoke) => wrapper(invoke),
});
}
return output;
}
function factoryOperation(
root: Record<string, unknown>,
path: string,
operation: string,
family: BrowserRecordingCrypto['family'],
algorithm: (args: unknown[]) => string | undefined,
discover: (value: unknown, args: unknown[], correlationId: string, toolkit: CryptoAdapterToolkit) => CryptoAdapterOperation[],
roles: Array<'algorithm' | 'key' | 'options' | 'unknown'>,
): CryptoAdapterOperation | undefined {
const resolved = target(root, path);
if (!resolved) return undefined;
return {
id: `node-forge.${operation}`,
operation,
owner: resolved.owner,
key: resolved.key,
resultMode: 'sync',
describe: (_thisArg, args, toolkit) => {
const correlationId = toolkit.unique('forge-session');
const algorithmName = algorithm(args);
const keyIndex = roles.indexOf('key');
const factoryKey = family === 'symmetric' && keyIndex >= 0
? keyMetadata(args[keyIndex], 'secret', toolkit)
: undefined;
return {
crypto: crypto(family, operation, algorithmName, 'session', 'create', correlationId, factoryKey),
inputIndex: -1,
arguments: args.slice(0, 8).map((argument, index) => toolkit.argument(
index, roles[index] || 'unknown', argument, false, false,
roles[index] === 'algorithm' && typeof argument === 'string' ? argument.slice(0, 120) : undefined,
)),
outputEvidence: () => [],
discoverResult: (value) => discover(value, args, correlationId, toolkit),
};
},
createWrapper: (_original, invoke) => wrapper(invoke),
};
}
export const nodeForgeAdapter: PageCryptoAdapter = {
manifest: nodeForgeManifest,
discover(scope): CryptoAdapterOperation[] {
const forge = (scope.window as unknown as { forge?: Record<string, unknown> }).forge;
if (!forge) return [];
const operations: Array<CryptoAdapterOperation | undefined> = [
factoryOperation(forge, 'cipher.createCipher', 'cipher.create.encrypt', 'symmetric',
(args) => typeof args[0] === 'string' ? args[0].slice(0, 120) : undefined,
(value, args, correlationId) => cipherSessionOperations(value, 'encrypt', typeof args[0] === 'string' ? args[0].slice(0, 120) : undefined, correlationId),
['algorithm', 'key']),
factoryOperation(forge, 'cipher.createDecipher', 'cipher.create.decrypt', 'symmetric',
(args) => typeof args[0] === 'string' ? args[0].slice(0, 120) : undefined,
(value, args, correlationId) => cipherSessionOperations(value, 'decrypt', typeof args[0] === 'string' ? args[0].slice(0, 120) : undefined, correlationId),
['algorithm', 'key']),
factoryOperation(forge, 'hmac.create', 'hmac.create', 'mac', () => 'HMAC',
(value, _args, correlationId) => hmacSessionOperations(value, correlationId), []),
factoryOperation(forge, 'pki.publicKeyFromPem', 'pki.public-key.create', 'key-management', () => 'RSA',
(value, _args, correlationId, toolkit) => rsaOperations(value, 'public', correlationId, toolkit), ['key']),
factoryOperation(forge, 'pki.privateKeyFromPem', 'pki.private-key.create', 'key-management', () => 'RSA',
(value, _args, correlationId, toolkit) => rsaOperations(value, 'private', correlationId, toolkit), ['key']),
];
for (const algorithm of ['md5', 'sha1', 'sha256', 'sha384', 'sha512']) {
operations.push(factoryOperation(
forge,
`md.${algorithm}.create`,
`digest.${algorithm}.create`,
'digest',
() => algorithm.toUpperCase(),
(value, _args, correlationId) => digestSessionOperations(value, algorithm.toUpperCase(), correlationId),
[],
));
}
return operations.filter((operation): operation is CryptoAdapterOperation => Boolean(operation));
},
};
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import { KEYUTIL, KJUR } from 'jsrsasign';
import {
CompactEncrypt,
CompactSign,
SignJWT,
compactDecrypt,
compactVerify,
jwtVerify,
} from 'jose';
import type { CryptoAdapterToolkit } from './contract';
import { jsrsasignAdapter } from './jsrsasign';
import { joseAdapter } from './jose';
function toolkit(): CryptoAdapterToolkit {
return {
unique: (prefix) => `${prefix}-acceptance`,
byteLength: (value) => typeof value === 'string' ? new TextEncoder().encode(value).byteLength
: ArrayBuffer.isView(value) ? value.byteLength
: value instanceof ArrayBuffer ? value.byteLength : undefined,
dataType: (value) => Object.prototype.toString.call(value).slice(8, -1),
fingerprint: (value) => `opaque:${value.length}`,
argument: (index, role, value, replaceable, retained, summary) => ({
index, role, dataType: typeof value, replaceable, retained, summary,
}),
collectEvidence: (value, path) => [{
path, fingerprint: `evidence:${String(value).length}`, encoding: 'text', byteLength: String(value).length,
}],
defaultOutputEvidence: () => [],
defaultAdaptInput: (value) => value,
bytesForInput: (value) => value instanceof Uint8Array ? value : undefined,
bytesToBase64: (value) => Buffer.from(value).toString('base64'),
};
}
describe('modern protocol adapter acceptance', () => {
it('tracks a real jsrsasign Signature session and independently verifies its output', { timeout: 15_000 }, () => {
const operations = jsrsasignAdapter.discover({
window: { KJUR, KEYUTIL } as unknown as Window,
});
const constructor = operations.find((item) => item.operation === 'Signature.create');
const createPlan = constructor?.describe(undefined, [{ alg: 'SHA256withRSA' }], toolkit());
const keypair = KEYUTIL.generateKeypair('RSA', 1024);
const signer = new KJUR.crypto.Signature({ alg: 'SHA256withRSA' });
const stages = createPlan?.discoverResult?.(signer) || [];
const init = stages.find((item) => item.operation === 'Signature.init');
const update = stages.find((item) => item.operation === 'Signature.updateString');
const final = stages.find((item) => item.operation === 'Signature.sign');
const canonical = 'POST\n/api/order\naccount=admin&nonce=1700000000';
const plans = [
init?.describe(signer, [keypair.prvKeyObj], toolkit()),
update?.describe(signer, [canonical], toolkit()),
final?.describe(signer, [], toolkit()),
];
signer.init(keypair.prvKeyObj);
signer.updateString(canonical);
const signature = signer.sign();
const verifier = new KJUR.crypto.Signature({ alg: 'SHA256withRSA' });
verifier.init(keypair.pubKeyObj);
verifier.updateString(canonical);
expect(verifier.verify(signature)).toBe(true);
expect(plans.map((plan) => plan?.crypto.state?.phase)).toEqual(['init', 'update', 'final']);
expect(new Set(plans.map((plan) => plan?.crypto.state?.correlationId))).toEqual(new Set(['jsrsasign-signature-acceptance']));
const jwkOperation = operations.find((item) => item.operation === 'KEYUTIL.getJWK');
const jwkPlan = jwkOperation?.describe(KEYUTIL as unknown as Record<string, unknown>, [keypair.prvKeyObj], toolkit());
const privateJwk = KEYUTIL.getJWK(keypair.prvKeyObj);
expect(privateJwk).toHaveProperty('d');
expect(jwkPlan?.outputEvidence?.(privateJwk)).toEqual([]);
});
it('tracks real jose Promise builders and verifies/decrypts their compact envelopes independently', async () => {
const root = {
SignJWT, CompactSign, CompactEncrypt, jwtVerify, compactVerify, compactDecrypt,
jwtDecrypt: async () => undefined,
importJWK: async () => undefined,
exportJWK: async () => undefined,
};
const operations = joseAdapter.discover({ window: { jose: root } as unknown as Window });
const secret = crypto.getRandomValues(new Uint8Array(32));
const signJwtConstructor = operations.find((item) => item.operation === 'SignJWT.create');
const createPlan = signJwtConstructor?.describe(undefined, [{ account: 'admin' }], toolkit());
const builder = new SignJWT({ account: 'admin' });
const stages = createPlan?.discoverResult?.(builder) || [];
const headerPlan = stages.find((item) => item.operation === 'SignJWT.setProtectedHeader')
?.describe(builder, [{ alg: 'HS256' }], toolkit());
builder.setProtectedHeader({ alg: 'HS256' });
const signOperation = stages.find((item) => item.operation === 'SignJWT.sign');
const signPlan = signOperation?.describe(builder, [secret], toolkit());
const tokenPromise = builder.sign(secret);
const token = await tokenPromise;
const verified = await jwtVerify(token, secret, { algorithms: ['HS256'] });
expect(verified.payload.account).toBe('admin');
expect(signOperation?.resultMode).toBe('promise');
expect(headerPlan?.crypto).toMatchObject({ algorithm: 'HS256', state: { phase: 'update' } });
expect(signPlan?.crypto).toMatchObject({ algorithm: 'HS256', state: { phase: 'final' } });
expect(signPlan?.crypto.state?.correlationId).toBe(createPlan?.crypto.state?.correlationId);
const payload = new TextEncoder().encode('canonical-request');
const jws = await new CompactSign(payload).setProtectedHeader({ alg: 'HS256' }).sign(secret);
const checked = await compactVerify(jws, secret, { algorithms: ['HS256'] });
expect(new TextDecoder().decode(checked.payload)).toBe('canonical-request');
const jwe = await new CompactEncrypt(payload).setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }).encrypt(secret);
const decrypted = await compactDecrypt(jwe, secret, { keyManagementAlgorithms: ['dir'], contentEncryptionAlgorithms: ['A256GCM'] });
expect(new TextDecoder().decode(decrypted.plaintext)).toBe('canonical-request');
expect(operations.find((item) => item.operation === 'CompactVerify.verify')?.resultMode).toBe('promise');
expect(operations.find((item) => item.operation === 'CompactDecrypt.decrypt')?.resultMode).toBe('promise');
});
});
@@ -0,0 +1,264 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
CryptoAdapterOperation,
CryptoAdapterScope,
CryptoAdapterToolkit,
PageCryptoAdapter,
} from './contract';
import { createCryptoAdapterRuntime } from './registry';
interface FakeDocument {
addEventListener(type: string, listener: EventListener, capture?: boolean): void;
removeEventListener(type: string, listener: EventListener, capture?: boolean): void;
emitScriptLoad(): void;
}
function fakeDocument(): FakeDocument {
const listeners = new Set<EventListener>();
return {
addEventListener(type, listener) {
if (type === 'load') listeners.add(listener);
},
removeEventListener(type, listener) {
if (type === 'load') listeners.delete(listener);
},
emitScriptLoad() {
for (const listener of listeners) {
listener({ target: { tagName: 'SCRIPT' } } as unknown as Event);
}
},
};
}
function toolkit(): CryptoAdapterToolkit {
return {
unique: (prefix) => `${prefix}-1`,
byteLength: () => undefined,
dataType: () => 'unknown',
fingerprint: (value) => value,
argument: (index, role, _value, replaceable, retained, summary) => ({
index, role, dataType: 'unknown', replaceable, retained, summary,
}),
collectEvidence: () => [],
defaultOutputEvidence: () => [],
defaultAdaptInput: (value) => value,
bytesForInput: () => undefined,
bytesToBase64: () => '',
};
}
function operation(owner: Record<string, unknown>): CryptoAdapterOperation {
return {
id: 'vendor.encrypt',
operation: 'encrypt',
owner,
key: 'encrypt',
resultMode: 'sync',
describe: () => ({
crypto: {
adapterId: 'vendor', providerKind: 'library', family: 'symmetric', operation: 'encrypt',
},
inputIndex: 0,
arguments: [],
}),
createWrapper: (_original, invoke) => function recordedVendor(this: unknown, ...args: unknown[]) {
return invoke(this, args);
},
};
}
function adapter(owner: Record<string, unknown>, dynamic = true, onDiscover?: () => void): PageCryptoAdapter {
return {
manifest: {
id: 'vendor', displayName: 'Vendor', providerKind: 'library', dynamic, globalPaths: ['Vendor'],
},
discover: () => {
onDiscover?.();
return [operation(owner)];
},
};
}
function scope(document: FakeDocument): CryptoAdapterScope {
return {
window: {
document,
setTimeout: globalThis.setTimeout.bind(globalThis),
clearTimeout: globalThis.clearTimeout.bind(globalThis),
} as unknown as Window,
};
}
afterEach(() => {
vi.useRealTimers();
});
describe('crypto adapter runtime', () => {
it('preserves call semantics and restores the original property descriptor', () => {
vi.useFakeTimers();
const document = fakeDocument();
const owner: Record<string, unknown> = {};
const original = function originalEncrypt(this: { prefix: string }, value: string) {
return `${this.prefix}:${value}`;
};
Object.defineProperty(owner, 'encrypt', {
value: original, configurable: true, writable: false, enumerable: false,
});
const originalDescriptor = Object.getOwnPropertyDescriptor(owner, 'encrypt');
let handle = '';
const runtime = createCryptoAdapterRuntime([adapter(owner)], scope(document), toolkit(), {
unique: () => 'wrapper-stable',
invoke(_operation, target, thisArg, args, wrapperHandleId) {
handle = wrapperHandleId;
return Reflect.apply(target, thisArg, args);
},
});
runtime.start();
const wrapped = owner.encrypt as Function;
expect(wrapped).not.toBe(original);
expect(Reflect.apply(wrapped, { prefix: 'ok' }, ['value'])).toBe('ok:value');
expect(handle).toBe('wrapper-stable');
expect(runtime.wrapperFunction(handle)).toBe(wrapped);
runtime.stop();
expect(Object.getOwnPropertyDescriptor(owner, 'encrypt')).toEqual(originalDescriptor);
expect(runtime.wrapperFunction(handle)).toBeUndefined();
});
it('keeps a stable wrapper handle across stop and restart in one document', () => {
vi.useFakeTimers();
const document = fakeDocument();
const owner = { encrypt: (value: string) => value } as Record<string, unknown>;
let generated = 0;
const runtime = createCryptoAdapterRuntime([adapter(owner)], scope(document), toolkit(), {
unique: () => `wrapper-${++generated}`,
invoke(_operation, target, thisArg, args) { return Reflect.apply(target, thisArg, args); },
});
runtime.start();
expect(runtime.wrapperFunction('wrapper-1')).toBe(owner.encrypt);
runtime.stop();
runtime.start();
expect(generated).toBe(1);
expect(runtime.wrapperFunction('wrapper-1')).toBe(owner.encrypt);
runtime.stop();
});
it('does not overwrite a page replacement during cleanup', () => {
vi.useFakeTimers();
const document = fakeDocument();
const original = (value: string) => value;
const replacement = (value: string) => `page:${value}`;
const owner = { encrypt: original } as Record<string, unknown>;
const runtime = createCryptoAdapterRuntime([adapter(owner)], scope(document), toolkit(), {
unique: () => 'wrapper-1',
invoke(_operation, target, thisArg, args) { return Reflect.apply(target, thisArg, args); },
});
runtime.start();
owner.encrypt = replacement;
runtime.stop();
expect(owner.encrypt).toBe(replacement);
});
it('uses only bounded retries and reacts to script loads for dynamic adapters', () => {
vi.useFakeTimers();
const document = fakeDocument();
const owner = { encrypt: (value: string) => value } as Record<string, unknown>;
let discoveries = 0;
const runtime = createCryptoAdapterRuntime(
[adapter(owner, true, () => { discoveries += 1; })],
scope(document),
toolkit(),
{
unique: () => 'wrapper-1',
invoke(_operation, target, thisArg, args) { return Reflect.apply(target, thisArg, args); },
},
);
runtime.start();
expect(discoveries).toBe(1);
vi.runAllTimers();
expect(discoveries).toBe(5);
expect(vi.getTimerCount()).toBe(0);
document.emitScriptLoad();
expect(discoveries).toBe(6);
runtime.stop();
document.emitScriptLoad();
expect(discoveries).toBe(6);
});
it('installs returned session operations immediately and restores them across restart', () => {
vi.useFakeTimers();
const document = fakeDocument();
const sessionPrototype = { update: (value: string) => `session:${value}` };
const session = Object.create(sessionPrototype) as Record<string, unknown>;
const originalUpdate = session.update;
const owner = { create: () => session } as Record<string, unknown>;
const sessionOperation = operation(session);
sessionOperation.id = 'vendor.session-1.update';
sessionOperation.operation = 'session.update';
sessionOperation.key = 'update';
const factoryOperation: CryptoAdapterOperation = {
...operation(owner),
id: 'vendor.create',
operation: 'session.create',
key: 'create',
describe: () => ({
crypto: { adapterId: 'vendor', providerKind: 'library', family: 'symmetric', operation: 'session.create' },
inputIndex: -1,
arguments: [],
discoverResult: () => [sessionOperation],
}),
};
const factoryAdapter: PageCryptoAdapter = {
manifest: { id: 'vendor', displayName: 'Vendor', providerKind: 'library', dynamic: true, globalPaths: ['Vendor'] },
discover: () => [factoryOperation],
};
let generated = 0;
const runtime = createCryptoAdapterRuntime([factoryAdapter], scope(document), toolkit(), {
unique: () => `wrapper-${++generated}`,
invoke(adapterOperation, target, thisArg, args, _wrapperHandleId, installDynamic) {
const output = Reflect.apply(target, thisArg, args);
installDynamic(adapterOperation.describe(thisArg, args, toolkit()).discoverResult?.(output) || []);
return output;
},
});
runtime.start();
const returned = Reflect.apply(owner.create as Function, owner, []) as Record<string, unknown>;
const firstWrapped = returned.update as Function;
expect(firstWrapped).not.toBe(originalUpdate);
expect(Reflect.apply(firstWrapped, returned, ['value'])).toBe('session:value');
expect(runtime.wrapperFunction('wrapper-2')).toBe(firstWrapped);
runtime.stop();
expect(session.update).toBe(originalUpdate);
expect(Object.prototype.hasOwnProperty.call(session, 'update')).toBe(false);
runtime.start();
expect(session.update).not.toBe(originalUpdate);
expect(Object.prototype.hasOwnProperty.call(session, 'update')).toBe(true);
expect(runtime.wrapperFunction('wrapper-2')).toBe(session.update);
runtime.stop();
});
it('skips non-configurable accessors without invoking their getter', () => {
vi.useFakeTimers();
const document = fakeDocument();
const getter = vi.fn(() => () => 'secret');
const owner: Record<string, unknown> = {};
Object.defineProperty(owner, 'encrypt', { get: getter, configurable: false });
const runtime = createCryptoAdapterRuntime([adapter(owner)], scope(document), toolkit(), {
unique: () => 'wrapper-1',
invoke: () => undefined,
});
runtime.start();
expect(getter).not.toHaveBeenCalled();
expect(Object.getOwnPropertyDescriptor(owner, 'encrypt')?.get).toBe(getter);
runtime.stop();
});
});
@@ -0,0 +1,143 @@
import type {
CryptoAdapterOperation,
CryptoAdapterScope,
CryptoAdapterToolkit,
PageCryptoAdapter,
} from './contract';
export interface CryptoAdapterRuntimeHost {
unique(prefix: string): string;
invoke(
operation: CryptoAdapterOperation,
original: Function,
thisArg: unknown,
args: unknown[],
wrapperHandleId: string,
installDynamic: (operations: CryptoAdapterOperation[]) => void,
): unknown;
}
export interface CryptoAdapterRuntime {
start(): void;
stop(): void;
ensureDynamic(): void;
wrapperFunction(wrapperHandleId: string): Function | undefined;
}
const RETRY_DELAYS = [50, 250, 1_000, 3_000] as const;
export function createCryptoAdapterRuntime(
adapters: PageCryptoAdapter[],
scope: CryptoAdapterScope,
toolkit: CryptoAdapterToolkit,
host: CryptoAdapterRuntimeHost,
): CryptoAdapterRuntime {
const wrappers = new WeakSet<Function>();
const handleByTarget = new Map<string, string>();
const wrapperByHandle = new Map<string, Function>();
const restorers: Array<() => void> = [];
const dynamicOperations: Array<{ adapter: PageCryptoAdapter; operation: CryptoAdapterOperation }> = [];
const retryTimers = new Set<number>();
let active = false;
const installOperation = (adapter: PageCryptoAdapter, operation: CryptoAdapterOperation): void => {
const descriptor = Object.getOwnPropertyDescriptor(operation.owner, operation.key);
if (descriptor && (!('value' in descriptor) || (!descriptor.writable && !descriptor.configurable))) return;
const current = descriptor && 'value' in descriptor ? descriptor.value : operation.owner[operation.key];
if (typeof current !== 'function' || wrappers.has(current)) return;
const targetKey = `${adapter.manifest.id}:${operation.id}`;
const wrapperHandleId = handleByTarget.get(targetKey) || host.unique('wrapper');
handleByTarget.set(targetKey, wrapperHandleId);
const wrapped = operation.createWrapper(
current,
(thisArg, args) => host.invoke(operation, current, thisArg, args, wrapperHandleId, (operations) => {
for (const discovered of operations.slice(0, 32)) {
if (!dynamicOperations.some((item) => item.operation === discovered)) {
dynamicOperations.push({ adapter, operation: discovered });
if (dynamicOperations.length > 128) dynamicOperations.shift();
}
try { installOperation(adapter, discovered); } catch { /* Session discovery is best effort. */ }
}
}),
);
try {
if (descriptor) Object.defineProperty(operation.owner, operation.key, { ...descriptor, value: wrapped });
else operation.owner[operation.key] = wrapped;
} catch {
return;
}
wrappers.add(wrapped);
wrapperByHandle.set(wrapperHandleId, wrapped);
restorers.push(() => {
if (operation.owner[operation.key] === wrapped) {
try {
if (descriptor) Object.defineProperty(operation.owner, operation.key, descriptor);
else delete operation.owner[operation.key];
} catch {
// A page replacement wins over recorder cleanup.
}
}
if (wrapperByHandle.get(wrapperHandleId) === wrapped) wrapperByHandle.delete(wrapperHandleId);
});
};
const install = (dynamicOnly: boolean): void => {
if (!active) return;
for (const adapter of adapters) {
if (dynamicOnly && !adapter.manifest.dynamic) continue;
let operations: CryptoAdapterOperation[] = [];
try { operations = adapter.discover(scope); } catch { continue; }
for (const operation of operations) {
try { installOperation(adapter, operation); } catch { /* One adapter cannot break recording. */ }
}
}
if (!dynamicOnly) {
for (const item of dynamicOperations) {
try { installOperation(item.adapter, item.operation); } catch { /* A stale session is ignored. */ }
}
}
};
const ensureDynamic = (): void => install(true);
const onResourceLoad = (event: Event): void => {
const target = event.target as { tagName?: unknown } | null;
const ScriptElement = (scope.window as unknown as {
HTMLScriptElement?: typeof HTMLScriptElement;
}).HTMLScriptElement;
const isScript = typeof ScriptElement === 'function'
? target instanceof ScriptElement
: target?.tagName === 'SCRIPT';
if (isScript) ensureDynamic();
};
return {
start() {
if (active) return;
active = true;
install(false);
scope.window.document.addEventListener('load', onResourceLoad, true);
for (const delay of RETRY_DELAYS) {
const timer = scope.window.setTimeout(() => {
retryTimers.delete(timer);
ensureDynamic();
}, delay);
retryTimers.add(timer);
}
},
stop() {
if (!active) return;
active = false;
scope.window.document.removeEventListener('load', onResourceLoad, true);
for (const timer of retryTimers) scope.window.clearTimeout(timer);
retryTimers.clear();
while (restorers.length) {
try { restorers.pop()!(); } catch { /* Cleanup is best effort. */ }
}
wrapperByHandle.clear();
},
ensureDynamic,
wrapperFunction(wrapperHandleId) {
return wrapperByHandle.get(wrapperHandleId);
},
};
}
@@ -0,0 +1,201 @@
import type { BrowserRecordingCrypto } from '@/types/models';
import type {
CallableOperationKind,
CryptoAdapterInvocationPlan,
CryptoAdapterOperation,
CryptoAdapterToolkit,
PageCryptoAdapter,
} from './contract';
import { smCryptoManifest } from './catalog';
interface SMOperationDefinition {
path: string;
operation: string;
family: BrowserRecordingCrypto['family'];
algorithm: string;
callableKind?: CallableOperationKind;
roles: Array<'data' | 'key' | 'signature' | 'options' | 'unknown'>;
keyKind?: NonNullable<BrowserRecordingCrypto['key']>['kind'];
keyBits?: number;
outputEncoding?: BrowserRecordingCrypto['outputEncoding'];
}
const OPERATIONS: SMOperationDefinition[] = [
{ path: 'sm2.doEncrypt', operation: 'sm2.encrypt', family: 'asymmetric', algorithm: 'SM2', callableKind: 'encrypt', roles: ['data', 'key', 'options'], keyKind: 'public', keyBits: 256, outputEncoding: 'hex' },
{ path: 'sm2.doDecrypt', operation: 'sm2.decrypt', family: 'asymmetric', algorithm: 'SM2', callableKind: 'decrypt', roles: ['data', 'key', 'options', 'options'], keyKind: 'private', keyBits: 256, outputEncoding: 'utf8' },
{ path: 'sm2.doSignature', operation: 'sm2.sign', family: 'signature', algorithm: 'SM2', callableKind: 'sign', roles: ['data', 'key', 'options'], keyKind: 'private', keyBits: 256, outputEncoding: 'hex' },
{ path: 'sm2.doVerifySignature', operation: 'sm2.verify', family: 'signature', algorithm: 'SM2', callableKind: 'verify', roles: ['data', 'signature', 'key', 'options'], keyKind: 'public', keyBits: 256, outputEncoding: 'auto' },
{ path: 'sm3', operation: 'sm3.digest', family: 'digest', algorithm: 'SM3', callableKind: 'digest', roles: ['data', 'options'], outputEncoding: 'hex' },
{ path: 'sm4.encrypt', operation: 'sm4.encrypt', family: 'symmetric', algorithm: 'SM4', callableKind: 'encrypt', roles: ['data', 'key', 'options'], keyKind: 'secret', keyBits: 128, outputEncoding: 'hex' },
{ path: 'sm4.decrypt', operation: 'sm4.decrypt', family: 'symmetric', algorithm: 'SM4', callableKind: 'decrypt', roles: ['data', 'key', 'options'], keyKind: 'secret', keyBits: 128, outputEncoding: 'utf8' },
];
function ownValue(value: unknown, key: string): unknown {
if (!value || typeof value !== 'object') return undefined;
try {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
return descriptor && 'value' in descriptor ? descriptor.value : undefined;
} catch {
return undefined;
}
}
function optionSummary(definition: SMOperationDefinition, args: unknown[], toolkit: CryptoAdapterToolkit): {
mode?: string;
padding?: string;
summary?: string;
inputEncoding?: BrowserRecordingCrypto['inputEncoding'];
outputEncoding?: BrowserRecordingCrypto['outputEncoding'];
} {
const optionValues = definition.roles
.map((role, index) => role === 'options' ? args[index] : undefined)
.filter((value) => value !== undefined);
const parts: string[] = [];
let mode: string | undefined;
let padding: string | undefined;
let inputEncoding: BrowserRecordingCrypto['inputEncoding'];
let outputEncoding = definition.outputEncoding;
for (const options of optionValues) {
if (definition.algorithm === 'SM2' && typeof options === 'number') {
mode = options === 0 ? 'C1C2C3' : options === 1 ? 'C1C3C2' : `cipherMode=${options}`;
parts.push(mode);
continue;
}
if (!options || typeof options !== 'object') continue;
const rawMode = ownValue(options, 'mode');
const rawPadding = ownValue(options, 'padding');
const rawInput = ownValue(options, 'input');
const rawOutput = ownValue(options, 'output');
const iv = ownValue(options, 'iv');
if (typeof rawMode === 'string') { mode = rawMode.slice(0, 40); parts.push(`mode=${mode}`); }
if (typeof rawPadding === 'string') { padding = rawPadding.slice(0, 40); parts.push(`padding=${padding}`); }
if (iv !== undefined) parts.push(`ivBytes=${toolkit.byteLength(iv) || 0}`);
if (rawInput === 'utf8' || rawInput === 'hex' || rawInput === 'base64' || rawInput === 'auto') inputEncoding = rawInput;
if (rawOutput === 'utf8' || rawOutput === 'hex' || rawOutput === 'base64' || rawOutput === 'auto') outputEncoding = rawOutput;
}
return { mode, padding, inputEncoding, outputEncoding, summary: parts.length ? parts.join(' ').slice(0, 240) : undefined };
}
function keyMetadata(
definition: SMOperationDefinition,
args: unknown[],
toolkit: CryptoAdapterToolkit,
): BrowserRecordingCrypto['key'] | undefined {
const keyIndex = definition.roles.indexOf('key');
if (keyIndex < 0) return undefined;
const value = args[keyIndex];
let material: string | undefined;
try {
if (typeof value === 'string') material = value;
else {
const bytes = toolkit.bytesForInput(value);
if (bytes) material = toolkit.bytesToBase64(bytes);
}
} catch {
material = undefined;
}
return {
kind: definition.keyKind || 'unknown',
bits: definition.keyBits,
fingerprint: material ? toolkit.fingerprint(material) : undefined,
};
}
function operationOwner(root: Record<string, unknown>, path: string): { owner: Record<string, unknown>; key: string } | undefined {
const segments = path.split('.');
let owner = root;
for (const segment of segments.slice(0, -1)) {
const next = owner[segment];
if (!next || (typeof next !== 'object' && typeof next !== 'function')) return undefined;
owner = next as Record<string, unknown>;
}
return { owner, key: segments.at(-1)! };
}
function describe(
definition: SMOperationDefinition,
args: unknown[],
toolkit: CryptoAdapterToolkit,
): CryptoAdapterInvocationPlan {
const options = optionSummary(definition, args, toolkit);
const sm3Key = definition.operation === 'sm3.digest' ? ownValue(args[1], 'key') : undefined;
const actualFamily = sm3Key === undefined ? definition.family : 'mac';
const actualOperation = sm3Key === undefined ? definition.operation : 'sm3.hmac';
const actualCallableKind = sm3Key === undefined ? definition.callableKind : 'sign';
let sm3KeyMaterial: string | undefined;
if (typeof sm3Key === 'string') sm3KeyMaterial = sm3Key;
else if (sm3Key !== undefined) {
try {
const bytes = toolkit.bytesForInput(sm3Key);
if (bytes) sm3KeyMaterial = toolkit.bytesToBase64(bytes);
} catch { /* HMAC key metadata is optional. */ }
}
return {
crypto: {
adapterId: smCryptoManifest.id,
providerKind: smCryptoManifest.providerKind,
family: actualFamily,
operation: actualOperation,
algorithm: definition.algorithm,
mode: options.mode,
padding: options.padding,
inputEncoding: options.inputEncoding || 'auto',
outputEncoding: options.outputEncoding,
state: { model: 'stateless', phase: 'one-shot' },
key: sm3Key === undefined ? keyMetadata(definition, args, toolkit) : {
kind: 'secret',
fingerprint: sm3KeyMaterial ? toolkit.fingerprint(sm3KeyMaterial) : undefined,
},
},
inputIndex: 0,
callableKind: actualCallableKind,
outputEncoding: options.outputEncoding,
arguments: args.slice(0, 8).map((value, index) => toolkit.argument(
index,
definition.roles[index] || 'unknown',
value,
index === 0,
Boolean(actualCallableKind),
definition.roles[index] === 'options' ? options.summary : undefined,
)),
outputError: (value) => value === false || value === null ? `${definition.algorithm} returned no result` : undefined,
adaptInput: (value) => toolkit.defaultAdaptInput(value, args[0]),
};
}
export const smCryptoAdapter: PageCryptoAdapter = {
manifest: smCryptoManifest,
discover(scope): CryptoAdapterOperation[] {
const globals = scope.window as unknown as {
smCrypto?: Record<string, unknown>;
sm2?: Record<string, unknown>;
sm3?: Function;
sm4?: Record<string, unknown>;
};
const root = globals.smCrypto || {
sm2: globals.sm2,
sm3: globals.sm3,
sm4: globals.sm4,
};
if (!root.sm2 && !root.sm3 && !root.sm4) return [];
const output: CryptoAdapterOperation[] = [];
for (const definition of OPERATIONS) {
const resolved = !globals.smCrypto && definition.path === 'sm3'
? { owner: scope.window as unknown as Record<string, unknown>, key: 'sm3' }
: operationOwner(root, definition.path);
if (!resolved) continue;
output.push({
id: `sm-crypto.${definition.operation}`,
operation: definition.operation,
owner: resolved.owner,
key: resolved.key,
resultMode: 'sync',
describe: (_thisArg, args, toolkit) => describe(definition, args, toolkit),
createWrapper: (_original, invoke) => function recordedSmCrypto(this: unknown, ...args: unknown[]) {
return invoke(this, args);
},
});
}
return output;
},
};
@@ -0,0 +1,112 @@
import type { BrowserRecordingCallArgument } from '@/types/models';
import { algorithmSummary, callableOperationKind, cryptoFamily } from './common';
import type {
CryptoAdapterInvocationPlan,
CryptoAdapterOperation,
CryptoAdapterToolkit,
PageCryptoAdapter,
} from './contract';
import { webCryptoManifest } from './catalog';
type WebCryptoOperation = 'encrypt' | 'decrypt' | 'sign' | 'verify' | 'digest' | 'deriveBits' | 'deriveKey'
| 'generateKey' | 'importKey' | 'exportKey' | 'wrapKey' | 'unwrapKey';
const OPERATIONS: WebCryptoOperation[] = [
'encrypt', 'decrypt', 'sign', 'verify', 'digest', 'deriveBits', 'deriveKey',
'generateKey', 'importKey', 'exportKey', 'wrapKey', 'unwrapKey',
];
const ROLES: Partial<Record<WebCryptoOperation, BrowserRecordingCallArgument['role'][]>> = {
encrypt: ['algorithm', 'key', 'data'],
decrypt: ['algorithm', 'key', 'data'],
sign: ['algorithm', 'key', 'data'],
verify: ['algorithm', 'key', 'signature', 'data'],
digest: ['algorithm', 'data'],
deriveBits: ['algorithm', 'key', 'unknown'],
deriveKey: ['algorithm', 'key', 'algorithm', 'unknown', 'unknown'],
generateKey: ['algorithm', 'unknown', 'unknown'],
importKey: ['unknown', 'data', 'algorithm', 'unknown', 'unknown'],
exportKey: ['unknown', 'key'],
wrapKey: ['unknown', 'key', 'key', 'algorithm'],
unwrapKey: ['data', 'key', 'algorithm', 'algorithm', 'unknown', 'unknown'],
};
function inputIndex(operation: WebCryptoOperation): number {
if (operation === 'digest') return 1;
if (['encrypt', 'decrypt', 'sign'].includes(operation)) return 2;
return -1;
}
function describe(
operation: WebCryptoOperation,
args: unknown[],
toolkit: CryptoAdapterToolkit,
): CryptoAdapterInvocationPlan {
const input = inputIndex(operation);
const algorithm = algorithmSummary(args[0], toolkit.byteLength);
const callableKind = callableOperationKind(operation);
return {
crypto: {
adapterId: webCryptoManifest.id,
providerKind: webCryptoManifest.providerKind,
family: cryptoFamily(operation, algorithm),
operation,
algorithm,
inputEncoding: 'auto',
outputEncoding: 'auto',
state: { model: 'receiver', phase: 'one-shot' },
},
inputIndex: input,
callableKind: input >= 0 ? callableKind : undefined,
outputEncoding: 'auto',
arguments: args.slice(0, 8).map((value, index) => {
const role = ROLES[operation]?.[index] || 'unknown';
return toolkit.argument(
index,
role,
value,
index === input,
input >= 0 && Boolean(callableKind),
role === 'algorithm' ? algorithmSummary(value, toolkit.byteLength) : undefined,
);
}),
};
}
function wrapper(
operation: WebCryptoOperation,
invoke: (thisArg: unknown, args: unknown[]) => unknown,
): Function {
switch (operation) {
case 'encrypt': return function recordedEncrypt(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
case 'decrypt': return function recordedDecrypt(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
case 'sign': return function recordedSign(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
case 'verify': return function recordedVerify(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
case 'digest': return function recordedDigest(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
case 'deriveBits': return function recordedDeriveBits(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
case 'deriveKey': return function recordedDeriveKey(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
case 'generateKey': return function recordedGenerateKey(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
case 'importKey': return function recordedImportKey(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
case 'exportKey': return function recordedExportKey(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
case 'wrapKey': return function recordedWrapKey(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
case 'unwrapKey': return function recordedUnwrapKey(this: SubtleCrypto, ...args: unknown[]) { return invoke(this, args); };
}
}
export const webCryptoAdapter: PageCryptoAdapter = {
manifest: webCryptoManifest,
discover(scope): CryptoAdapterOperation[] {
const subtle = scope.crypto?.subtle || scope.window.crypto?.subtle;
if (!subtle) return [];
const owner = Object.getPrototypeOf(subtle) as Record<string, unknown>;
return OPERATIONS.map((operation) => ({
id: `webcrypto.subtle.${operation}`,
operation,
owner,
key: operation,
resultMode: 'promise',
describe: (_thisArg, args, toolkit) => describe(operation, args, toolkit),
createWrapper: (_original, invoke) => wrapper(operation, invoke),
}));
},
};
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import type { BrowserRecordingEvent } from '@/types/models';
import {
cryptoDeepCaptureMatcher,
cryptoEventLabel,
isForwardCryptoEvent,
normalizeBrowserRecordingCrypto,
} from './model';
function cryptoEvent(operation: string): BrowserRecordingEvent {
return {
id: 'crypto-1', sequence: 1, timestamp: 1, recordingId: 'recording-1', traceId: 'trace-1',
kind: 'crypto', operation, inputs: [], outputs: [], sensitiveCaptured: false,
wrapperHandleId: 'wrapper-jsencrypt-encrypt',
scriptUrl: 'https://example.test/app.js',
crypto: {
adapterId: 'jsencrypt', providerKind: 'library', family: 'asymmetric', operation,
algorithm: 'RSA', padding: 'PKCS1-v1_5', outputEncoding: 'base64',
state: { model: 'receiver', phase: 'one-shot' },
key: { kind: 'public', bits: 1024, fingerprint: 'v2:key' },
},
};
}
describe('browser crypto model', () => {
it('normalizes bounded JSEncrypt metadata without carrying key material', () => {
expect(normalizeBrowserRecordingCrypto({
adapterId: 'jsencrypt', providerKind: 'library', family: 'asymmetric', operation: 'encrypt', algorithm: 'RSA',
padding: 'PKCS1-v1_5', inputEncoding: 'utf8', outputEncoding: 'base64',
state: { model: 'receiver', phase: 'one-shot', internalState: 'must-not-survive' },
key: { kind: 'public', bits: 1024, fingerprint: 'v2:key', pem: 'must-not-survive' },
publicKey: 'must-not-survive',
})).toEqual({
adapterId: 'jsencrypt', providerKind: 'library', family: 'asymmetric', operation: 'encrypt', algorithm: 'RSA',
padding: 'PKCS1-v1_5', inputEncoding: 'utf8', outputEncoding: 'base64',
state: { model: 'receiver', phase: 'one-shot' },
key: { kind: 'public', bits: 1024, fingerprint: 'v2:key' },
});
});
it('accepts bounded adapter IDs while rejecting malformed adapter metadata', () => {
expect(normalizeBrowserRecordingCrypto({
adapterId: 'vendor-suite.v2', providerKind: 'library', family: 'asymmetric', operation: 'encrypt',
})?.adapterId).toBe('vendor-suite.v2');
expect(normalizeBrowserRecordingCrypto({
adapterId: '<img onerror=1>', providerKind: 'library', family: 'asymmetric', operation: 'encrypt',
})).toBeUndefined();
});
it('classifies forward and reverse RSA calls', () => {
expect(isForwardCryptoEvent(cryptoEvent('encrypt'))).toBe(true);
expect(isForwardCryptoEvent(cryptoEvent('decrypt'))).toBe(false);
});
it('uses adapter-aware labels and exact wrapper handles for deep capture', () => {
const event = cryptoEvent('encrypt');
expect(cryptoEventLabel(event)).toBe('JSEncrypt RSA');
expect(cryptoDeepCaptureMatcher(event)).toEqual({
kind: 'crypto',
adapterId: 'jsencrypt',
operation: 'encrypt',
wrapperHandleId: 'wrapper-jsencrypt-encrypt',
scriptUrl: 'https://example.test/app.js',
});
});
});
+97
View File
@@ -0,0 +1,97 @@
import type {
BrowserCryptoFamily,
BrowserCryptoProviderKind,
BrowserPageCallableValueEncoding,
BrowserRecordingCrypto,
BrowserDeepCaptureMatcher,
BrowserRecordingEvent,
} from '@/types/models';
import { cryptoAdapterLabel } from './adapters/catalog';
const PROVIDER_KINDS: BrowserCryptoProviderKind[] = ['native', 'library', 'business', 'wasm', 'unknown'];
const FAMILIES: BrowserCryptoFamily[] = [
'symmetric', 'asymmetric', 'digest', 'mac', 'signature', 'kdf', 'key-management', 'unknown',
];
const ENCODINGS: BrowserPageCallableValueEncoding[] = ['auto', 'utf8', 'hex', 'base64', 'json'];
const ADAPTER_ID = /^[a-z0-9][a-z0-9.-]{0,63}$/;
const OPERATION_ID = /^[A-Za-z0-9_$][A-Za-z0-9_$.-]{0,159}$/;
function optionalString(value: unknown, limit: number): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim().slice(0, limit) : undefined;
}
export function normalizeBrowserRecordingCrypto(value: unknown): BrowserRecordingCrypto | undefined {
if (!value || typeof value !== 'object') return undefined;
const input = value as Record<string, unknown>;
if (typeof input.adapterId !== 'string' || !ADAPTER_ID.test(input.adapterId)
|| !PROVIDER_KINDS.includes(input.providerKind as BrowserCryptoProviderKind)
|| !FAMILIES.includes(input.family as BrowserCryptoFamily)
|| typeof input.operation !== 'string' || !OPERATION_ID.test(input.operation)) return undefined;
const keyInput = input.key && typeof input.key === 'object' ? input.key as Record<string, unknown> : undefined;
const keyKinds: NonNullable<BrowserRecordingCrypto['key']>['kind'][] = ['public', 'private', 'secret', 'unknown'];
const key = keyInput && keyKinds.includes(keyInput.kind as NonNullable<BrowserRecordingCrypto['key']>['kind'])
? {
kind: keyInput.kind as NonNullable<BrowserRecordingCrypto['key']>['kind'],
bits: Number.isSafeInteger(keyInput.bits) && Number(keyInput.bits) >= 1 && Number(keyInput.bits) <= 1_048_576
? Number(keyInput.bits) : undefined,
fingerprint: optionalString(keyInput.fingerprint, 160),
}
: undefined;
const stateInput = input.state && typeof input.state === 'object' ? input.state as Record<string, unknown> : undefined;
const stateModels: NonNullable<BrowserRecordingCrypto['state']>['model'][] = [
'stateless', 'receiver', 'session', 'stream', 'async-ready',
];
const phases: NonNullable<BrowserRecordingCrypto['state']>['phase'][] = ['create', 'init', 'update', 'final', 'one-shot'];
const state = stateInput && stateModels.includes(stateInput.model as NonNullable<BrowserRecordingCrypto['state']>['model'])
? {
model: stateInput.model as NonNullable<BrowserRecordingCrypto['state']>['model'],
correlationId: optionalString(stateInput.correlationId, 160),
phase: phases.includes(stateInput.phase as NonNullable<BrowserRecordingCrypto['state']>['phase'])
? stateInput.phase as NonNullable<BrowserRecordingCrypto['state']>['phase']
: undefined,
}
: undefined;
return {
adapterId: input.adapterId,
providerKind: input.providerKind as BrowserCryptoProviderKind,
family: input.family as BrowserCryptoFamily,
operation: input.operation,
algorithm: optionalString(input.algorithm, 240),
mode: optionalString(input.mode, 120),
padding: optionalString(input.padding, 120),
inputEncoding: ENCODINGS.includes(input.inputEncoding as BrowserPageCallableValueEncoding)
? input.inputEncoding as BrowserPageCallableValueEncoding : undefined,
outputEncoding: ENCODINGS.includes(input.outputEncoding as BrowserPageCallableValueEncoding)
? input.outputEncoding as BrowserPageCallableValueEncoding : undefined,
state,
key,
};
}
export function cryptoEventLabel(event: Pick<BrowserRecordingEvent, 'operation' | 'crypto'>): string {
const crypto = event.crypto;
if (!crypto) return event.operation;
return `${cryptoAdapterLabel(crypto.adapterId)} ${crypto.algorithm || crypto.operation || event.operation}`;
}
export function isForwardCryptoEvent(event: BrowserRecordingEvent): boolean {
if (event.kind !== 'crypto' || !event.crypto) return false;
const operation = `${event.operation} ${event.crypto.operation}`.toLowerCase();
if (operation.includes('decrypt') || operation.includes('verify') || operation.includes('decode')) return false;
return ['encrypt', 'sign', 'digest', 'hmac', 'sha', 'md5', 'ripemd', 'pbkdf', 'evpkdf']
.some((name) => operation.includes(name));
}
export function cryptoDeepCaptureMatcher(event: Pick<
BrowserRecordingEvent,
'kind' | 'crypto' | 'wrapperHandleId' | 'scriptUrl'
>): BrowserDeepCaptureMatcher | undefined {
if (event.kind !== 'crypto' || !event.crypto || !event.wrapperHandleId) return undefined;
return {
kind: 'crypto',
adapterId: event.crypto.adapterId,
operation: event.crypto.operation,
wrapperHandleId: event.wrapperHandleId,
scriptUrl: event.scriptUrl,
};
}