feat: advance browser agent integration workflows

This commit is contained in:
go0p
2026-08-06 15:11:35 +08:00
committed by go0p
parent af5a4db694
commit 0c8e1c7b69
215 changed files with 35137 additions and 5442 deletions
@@ -8,6 +8,10 @@ import { smCryptoAdapter } from './sm-crypto';
import { nodeForgeAdapter } from './node-forge';
import { jsrsasignAdapter } from './jsrsasign';
import { joseAdapter } from './jose';
import { libsodiumAdapter } from './libsodium';
import { tweetNaclAdapter } from './tweetnacl';
import { nobleAdapter } from './noble';
import { openPgpAdapter } from './openpgp';
function byteLength(value: unknown): number | undefined {
if (typeof value === 'string') return new TextEncoder().encode(value).byteLength;
@@ -50,6 +54,8 @@ function toolkit(): CryptoAdapterToolkit {
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('libsodium')).toBe('libsodium.js');
expect(cryptoAdapterLabel('openpgp')).toBe('OpenPGP.js');
expect(cryptoAdapterLabel('vendor-suite.v2')).toBe('vendor-suite.v2');
});
@@ -104,6 +110,11 @@ describe('page crypto adapters', () => {
mode: 'CBC', padding: 'Pkcs7', outputEncoding: 'base64',
});
expect(plan?.arguments[2].summary).toBe('mode=CBC padding=Pkcs7 ivBytes=16');
expect(plan?.inputEvidence?.({}).map((item) => item.path)).toEqual([
'$input',
'$input.key',
'$input.iv',
]);
expect(plan?.adaptInput?.(new Uint8Array([4, 5, 6]))).toEqual({ wordArray: 'base64:4,5,6' });
expect(parsed).toEqual(['base64:4,5,6']);
});
@@ -335,4 +346,143 @@ describe('page crypto adapters', () => {
expect(JSON.stringify(final?.crypto)).not.toContain('never-export');
expect(operations.find((item) => item.operation === 'CompactVerify.verify')?.resultMode).toBe('promise');
});
it('describes libsodium async-ready one-shot operations and preserves the real AEAD input index', () => {
const sodium = {
ready: Promise.resolve(),
crypto_secretbox_easy: () => new Uint8Array([1]),
crypto_secretbox_open_easy: () => new Uint8Array([2]),
crypto_aead_xchacha20poly1305_ietf_encrypt: () => new Uint8Array([3]),
crypto_aead_xchacha20poly1305_ietf_decrypt: () => new Uint8Array([4]),
crypto_sign_detached: () => new Uint8Array([5]),
crypto_sign_verify_detached: () => true,
};
const operations = libsodiumAdapter.discover({ window: { sodium } as unknown as Window });
const secretbox = operations.find((item) => item.operation === 'secretbox.encrypt')?.describe(
sodium,
[new Uint8Array([1, 2]), new Uint8Array(24), new Uint8Array(32).fill(9)],
toolkit(),
);
const xchachaDecrypt = operations.find((item) => item.operation === 'aead.xchacha20poly1305.decrypt')?.describe(
sodium,
[null, new Uint8Array([8, 9]), new Uint8Array([1]), new Uint8Array(24), new Uint8Array(32).fill(7)],
toolkit(),
);
expect(secretbox?.crypto).toMatchObject({
adapterId: 'libsodium', family: 'symmetric', algorithm: 'XSalsa20-Poly1305',
state: { model: 'async-ready', phase: 'one-shot' },
key: { kind: 'secret', bits: 256, fingerprint: 'v2:opaque-fingerprint' },
});
expect(secretbox?.arguments.map((item) => item.role)).toEqual(['data', 'nonce', 'key']);
expect(xchachaDecrypt).toMatchObject({ inputIndex: 1, callableKind: 'decrypt' });
expect(xchachaDecrypt?.arguments.map((item) => item.role)).toEqual(['options', 'data', 'aad', 'nonce', 'key']);
expect(JSON.stringify(secretbox?.crypto)).not.toContain('9,9,9');
});
it('discovers TweetNaCl nested methods without flattening nonce or key semantics', () => {
const secretbox = Object.assign(
(_message: Uint8Array, _nonce: Uint8Array, _key: Uint8Array) => new Uint8Array([1]),
{ open: () => new Uint8Array([2]) },
);
const detached = Object.assign(
(_message: Uint8Array, _key: Uint8Array) => new Uint8Array([3]),
{ verify: () => true },
);
const sign = Object.assign(
(_message: Uint8Array, _key: Uint8Array) => new Uint8Array([4]),
{ open: () => new Uint8Array([5]), detached },
);
const nacl = { secretbox, sign, hash: () => new Uint8Array(64) };
const operations = tweetNaclAdapter.discover({ window: { nacl } as unknown as Window });
const open = operations.find((item) => item.operation === 'secretbox.decrypt')?.describe(
secretbox,
[new Uint8Array([1]), new Uint8Array(24), new Uint8Array(32)],
toolkit(),
);
const verify = operations.find((item) => item.operation === 'ed25519.verify')?.describe(
detached,
[new Uint8Array([1]), new Uint8Array(64), new Uint8Array(32)],
toolkit(),
);
expect(open?.crypto).toMatchObject({ adapterId: 'tweetnacl', algorithm: 'XSalsa20-Poly1305' });
expect(open?.arguments[1]).toMatchObject({ role: 'nonce', summary: 'nonceBytes=24' });
expect(verify).toMatchObject({ inputIndex: 0, callableKind: 'verify' });
expect(verify?.arguments.map((item) => item.role)).toEqual(['data', 'signature', 'key']);
});
it('promotes explicit noble cipher factories to receiver-bound encrypt/decrypt callables', () => {
const cipher = {
encrypt: (value: Uint8Array) => value,
decrypt: (value: Uint8Array) => value,
};
const nobleCiphers = { gcm: () => cipher };
const nobleCurves = { ed25519: { sign: () => new Uint8Array(64), verify: () => true } };
const operations = nobleAdapter.discover({
window: { nobleCiphers, nobleCurves } as unknown as Window,
});
const factory = operations.find((item) => item.operation === 'AES-GCM.create');
const create = factory?.describe(
nobleCiphers,
[new Uint8Array(32), new Uint8Array(12), new Uint8Array([1, 2])],
toolkit(),
);
const encrypt = create?.discoverResult?.(cipher).find((item) => item.operation === 'AES-GCM.encrypt');
const encryptPlan = encrypt?.describe(cipher, [new Uint8Array([3, 4])], toolkit());
const verify = operations.find((item) => item.operation === 'ed25519.verify')?.describe(
nobleCurves.ed25519,
[new Uint8Array(64), new Uint8Array([5]), new Uint8Array(32)],
toolkit(),
);
expect(create?.crypto).toMatchObject({
adapterId: 'noble', algorithm: 'AES-GCM', mode: 'gcm',
state: { model: 'session', phase: 'create', correlationId: 'noble-cipher-1' },
});
expect(encryptPlan).toMatchObject({
inputIndex: 0, callableKind: 'encrypt',
crypto: { state: { model: 'receiver', correlationId: 'noble-cipher-1' } },
});
expect(verify).toMatchObject({ inputIndex: 1, callableKind: 'verify' });
});
it('uses OpenPGP message state as evidence while requiring a business closure for safe replay', () => {
const openpgp = {
createMessage: async () => ({}),
readMessage: async () => ({}),
encrypt: async () => 'armored',
decrypt: async () => ({ data: 'plain' }),
sign: async () => 'signature',
verify: async () => ({ signatures: [] }),
};
const operations = openPgpAdapter.discover({ window: { openpgp } as unknown as Window });
const message = {};
const create = operations.find((item) => item.operation === 'createMessage')?.describe(
openpgp,
[{ text: 'plain request' }],
toolkit(),
);
create?.discoverResult?.(message);
const encrypt = operations.find((item) => item.operation === 'OpenPGP.encrypt')?.describe(
openpgp,
[{ message, encryptionKeys: [{}], format: 'armored' }],
toolkit(),
);
const decrypt = operations.find((item) => item.operation === 'OpenPGP.decrypt')?.describe(
openpgp,
[{ message, decryptionKeys: [{}] }],
toolkit(),
);
expect(encrypt?.crypto).toMatchObject({
adapterId: 'openpgp', family: 'asymmetric', algorithm: 'OpenPGP public-key',
state: { model: 'async-ready', phase: 'final', correlationId: 'openpgp-message-1' },
key: { kind: 'public' },
});
expect(encrypt?.callableKind).toBeUndefined();
expect(encrypt?.arguments[0]).toMatchObject({ replaceable: false, retained: false });
expect(encrypt?.inputEvidence?.({})[0]).toMatchObject({ path: '$input.text' });
expect(decrypt?.outputEvidence?.({ data: 'plain' })[0]).toMatchObject({ path: '$output.data' });
});
});
@@ -56,6 +56,38 @@ export const joseManifest: CryptoAdapterManifest = {
globalPaths: ['jose'],
};
export const libsodiumManifest: CryptoAdapterManifest = {
id: 'libsodium',
displayName: 'libsodium.js',
providerKind: 'library',
dynamic: true,
globalPaths: ['sodium'],
};
export const tweetNaclManifest: CryptoAdapterManifest = {
id: 'tweetnacl',
displayName: 'TweetNaCl.js',
providerKind: 'library',
dynamic: true,
globalPaths: ['nacl'],
};
export const nobleManifest: CryptoAdapterManifest = {
id: 'noble',
displayName: 'noble-*',
providerKind: 'library',
dynamic: true,
globalPaths: ['noble', 'nobleCiphers', 'nobleHashes', 'nobleCurves'],
};
export const openPgpManifest: CryptoAdapterManifest = {
id: 'openpgp',
displayName: 'OpenPGP.js',
providerKind: 'library',
dynamic: true,
globalPaths: ['openpgp'],
};
export const CRYPTO_ADAPTER_MANIFESTS: Readonly<Record<string, CryptoAdapterManifest>> = Object.freeze(
Object.fromEntries([
webCryptoManifest,
@@ -65,6 +97,10 @@ export const CRYPTO_ADAPTER_MANIFESTS: Readonly<Record<string, CryptoAdapterMani
nodeForgeManifest,
jsrsasignManifest,
joseManifest,
libsodiumManifest,
tweetNaclManifest,
nobleManifest,
openPgpManifest,
].map((manifest) => [manifest.id, Object.freeze(manifest)])),
);
@@ -70,5 +70,6 @@ export interface CryptoAdapterOperation {
export interface PageCryptoAdapter {
manifest: CryptoAdapterManifest;
ready?(scope: CryptoAdapterScope): PromiseLike<unknown> | undefined;
discover(scope: CryptoAdapterScope): CryptoAdapterOperation[];
}
@@ -92,6 +92,19 @@ function describe(
Boolean(callableKind),
roles[index] === 'options' ? options.summary : undefined,
)),
inputEvidence() {
const evidence = toolkit.collectEvidence(args[0], '$input');
for (let index = 1; index < Math.min(args.length, roles.length); index += 1) {
const role = roles[index];
if (role === 'options') {
const iv = ownValue(args[index], 'iv');
if (iv !== undefined) evidence.push(...toolkit.collectEvidence(iv, '$input.iv'));
continue;
}
if (role !== 'unknown') evidence.push(...toolkit.collectEvidence(args[index], `$input.${role}`));
}
return evidence.slice(0, 48);
},
outputEvidence(value) {
const output = toolkit.defaultOutputEvidence(value);
if (!value || (typeof value !== 'object' && typeof value !== 'function') || output.length >= 48) return output;
@@ -6,6 +6,10 @@ import { smCryptoAdapter } from './sm-crypto';
import { nodeForgeAdapter } from './node-forge';
import { jsrsasignAdapter } from './jsrsasign';
import { joseAdapter } from './jose';
import { libsodiumAdapter } from './libsodium';
import { tweetNaclAdapter } from './tweetnacl';
import { nobleAdapter } from './noble';
import { openPgpAdapter } from './openpgp';
export const PAGE_CRYPTO_ADAPTERS: PageCryptoAdapter[] = [
webCryptoAdapter,
@@ -15,6 +19,10 @@ export const PAGE_CRYPTO_ADAPTERS: PageCryptoAdapter[] = [
nodeForgeAdapter,
jsrsasignAdapter,
joseAdapter,
libsodiumAdapter,
tweetNaclAdapter,
nobleAdapter,
openPgpAdapter,
];
export type {
@@ -0,0 +1,111 @@
import type { BrowserRecordingCallArgument, BrowserRecordingCrypto } from '@/types/models';
import type {
CallableOperationKind,
CryptoAdapterInvocationPlan,
CryptoAdapterOperation,
CryptoAdapterToolkit,
PageCryptoAdapter,
} from './contract';
import { libsodiumManifest } from './catalog';
import { asRecord, callableProxy, hasMethod, opaqueKey } from './modern-common';
interface SodiumOperationDefinition {
key: string;
operation: string;
family: BrowserRecordingCrypto['family'];
algorithm: string;
callableKind?: CallableOperationKind;
inputIndex: number;
roles: BrowserRecordingCallArgument['role'][];
keyIndex?: number;
keyKind?: NonNullable<BrowserRecordingCrypto['key']>['kind'];
failureOnEmpty?: boolean;
}
const OPERATIONS: SodiumOperationDefinition[] = [
{ key: 'crypto_secretbox_easy', operation: 'secretbox.encrypt', family: 'symmetric', algorithm: 'XSalsa20-Poly1305', callableKind: 'encrypt', inputIndex: 0, roles: ['data', 'nonce', 'key'], keyIndex: 2, keyKind: 'secret' },
{ key: 'crypto_secretbox_open_easy', operation: 'secretbox.decrypt', family: 'symmetric', algorithm: 'XSalsa20-Poly1305', callableKind: 'decrypt', inputIndex: 0, roles: ['data', 'nonce', 'key'], keyIndex: 2, keyKind: 'secret', failureOnEmpty: true },
{ key: 'crypto_box_easy', operation: 'box.encrypt', family: 'asymmetric', algorithm: 'X25519-XSalsa20-Poly1305', callableKind: 'encrypt', inputIndex: 0, roles: ['data', 'nonce', 'key', 'key'], keyIndex: 3, keyKind: 'private' },
{ key: 'crypto_box_open_easy', operation: 'box.decrypt', family: 'asymmetric', algorithm: 'X25519-XSalsa20-Poly1305', callableKind: 'decrypt', inputIndex: 0, roles: ['data', 'nonce', 'key', 'key'], keyIndex: 3, keyKind: 'private', failureOnEmpty: true },
{ key: 'crypto_box_seal', operation: 'sealed-box.encrypt', family: 'asymmetric', algorithm: 'X25519-SealedBox', callableKind: 'encrypt', inputIndex: 0, roles: ['data', 'key'], keyIndex: 1, keyKind: 'public' },
{ key: 'crypto_box_seal_open', operation: 'sealed-box.decrypt', family: 'asymmetric', algorithm: 'X25519-SealedBox', callableKind: 'decrypt', inputIndex: 0, roles: ['data', 'key', 'key'], keyIndex: 2, keyKind: 'private', failureOnEmpty: true },
{ key: 'crypto_aead_xchacha20poly1305_ietf_encrypt', operation: 'aead.xchacha20poly1305.encrypt', family: 'symmetric', algorithm: 'XChaCha20-Poly1305-IETF', callableKind: 'encrypt', inputIndex: 0, roles: ['data', 'aad', 'options', 'nonce', 'key'], keyIndex: 4, keyKind: 'secret' },
{ key: 'crypto_aead_xchacha20poly1305_ietf_decrypt', operation: 'aead.xchacha20poly1305.decrypt', family: 'symmetric', algorithm: 'XChaCha20-Poly1305-IETF', callableKind: 'decrypt', inputIndex: 1, roles: ['options', 'data', 'aad', 'nonce', 'key'], keyIndex: 4, keyKind: 'secret', failureOnEmpty: true },
{ key: 'crypto_aead_chacha20poly1305_ietf_encrypt', operation: 'aead.chacha20poly1305.encrypt', family: 'symmetric', algorithm: 'ChaCha20-Poly1305-IETF', callableKind: 'encrypt', inputIndex: 0, roles: ['data', 'aad', 'options', 'nonce', 'key'], keyIndex: 4, keyKind: 'secret' },
{ key: 'crypto_aead_chacha20poly1305_ietf_decrypt', operation: 'aead.chacha20poly1305.decrypt', family: 'symmetric', algorithm: 'ChaCha20-Poly1305-IETF', callableKind: 'decrypt', inputIndex: 1, roles: ['options', 'data', 'aad', 'nonce', 'key'], keyIndex: 4, keyKind: 'secret', failureOnEmpty: true },
{ key: 'crypto_sign_detached', operation: 'ed25519.sign', family: 'signature', algorithm: 'Ed25519', callableKind: 'sign', inputIndex: 0, roles: ['data', 'key'], keyIndex: 1, keyKind: 'private' },
{ key: 'crypto_sign_verify_detached', operation: 'ed25519.verify', family: 'signature', algorithm: 'Ed25519', callableKind: 'verify', inputIndex: 1, roles: ['signature', 'data', 'key'], keyIndex: 2, keyKind: 'public', failureOnEmpty: true },
{ key: 'crypto_hash_sha256', operation: 'sha256.digest', family: 'digest', algorithm: 'SHA-256', callableKind: 'digest', inputIndex: 0, roles: ['data'] },
{ key: 'crypto_hash_sha512', operation: 'sha512.digest', family: 'digest', algorithm: 'SHA-512', callableKind: 'digest', inputIndex: 0, roles: ['data'] },
{ key: 'crypto_auth', operation: 'hmacsha512256.sign', family: 'mac', algorithm: 'HMAC-SHA-512/256', callableKind: 'sign', inputIndex: 0, roles: ['data', 'key'], keyIndex: 1, keyKind: 'secret' },
{ key: 'crypto_auth_verify', operation: 'hmacsha512256.verify', family: 'mac', algorithm: 'HMAC-SHA-512/256', callableKind: 'verify', inputIndex: 1, roles: ['signature', 'data', 'key'], keyIndex: 2, keyKind: 'secret', failureOnEmpty: true },
];
function describe(
definition: SodiumOperationDefinition,
args: unknown[],
toolkit: CryptoAdapterToolkit,
): CryptoAdapterInvocationPlan {
const nonceIndex = definition.roles.indexOf('nonce');
const additionalDataIndex = definition.roles.indexOf('aad');
const summary = [
nonceIndex >= 0 ? `nonceBytes=${toolkit.byteLength(args[nonceIndex]) || 0}` : undefined,
additionalDataIndex >= 0 && args[additionalDataIndex] != null
? `aadBytes=${toolkit.byteLength(args[additionalDataIndex]) || 0}`
: undefined,
].filter(Boolean).join(' ');
return {
crypto: {
adapterId: libsodiumManifest.id,
providerKind: libsodiumManifest.providerKind,
family: definition.family,
operation: definition.operation,
algorithm: definition.algorithm,
inputEncoding: 'auto',
outputEncoding: 'auto',
state: { model: 'async-ready', phase: 'one-shot' },
key: definition.keyIndex === undefined
? undefined
: opaqueKey(args[definition.keyIndex], definition.keyKind || 'unknown', toolkit),
},
inputIndex: definition.inputIndex,
callableKind: definition.callableKind,
outputEncoding: 'auto',
arguments: args.slice(0, 8).map((value, index) => toolkit.argument(
index,
definition.roles[index] || 'unknown',
value,
index === definition.inputIndex,
Boolean(definition.callableKind),
(index === nonceIndex || index === additionalDataIndex) && summary ? summary : undefined,
)),
outputError: definition.failureOnEmpty
? (value) => value === false || value === null ? `${definition.algorithm} authentication failed` : undefined
: undefined,
adaptInput: (value) => toolkit.defaultAdaptInput(value, args[definition.inputIndex]),
};
}
export const libsodiumAdapter: PageCryptoAdapter = {
manifest: libsodiumManifest,
ready(scope) {
const root = asRecord((scope.window as unknown as { sodium?: unknown }).sodium);
const ready = root?.ready;
return ready && typeof (ready as { then?: unknown }).then === 'function'
? ready as PromiseLike<unknown>
: undefined;
},
discover(scope): CryptoAdapterOperation[] {
const root = asRecord((scope.window as unknown as { sodium?: unknown }).sodium);
if (!root) return [];
return OPERATIONS.filter((definition) => hasMethod(root, definition.key)).map((definition) => ({
id: `libsodium.${definition.key}`,
operation: definition.operation,
owner: root,
key: definition.key,
resultMode: 'sync',
describe: (_thisArg, args, toolkit) => describe(definition, args, toolkit),
createWrapper: callableProxy,
}));
},
};
@@ -0,0 +1,62 @@
import type { BrowserRecordingCrypto } from '@/types/models';
import type { CryptoAdapterToolkit } from './contract';
export function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && (typeof value === 'object' || typeof value === 'function')
? value as Record<string, unknown>
: undefined;
}
export function hasMethod(owner: Record<string, unknown> | undefined, key: string): boolean {
try { return Boolean(owner && typeof owner[key] === 'function'); } catch { return false; }
}
export function callableProxy(
original: Function,
invoke: (thisArg: unknown, args: unknown[]) => unknown,
): Function {
return new Proxy(original, {
apply(_target, thisArg, args) { return invoke(thisArg, args); },
});
}
export function opaqueKey(
value: unknown,
kind: NonNullable<BrowserRecordingCrypto['key']>['kind'],
toolkit: CryptoAdapterToolkit,
): BrowserRecordingCrypto['key'] {
let material: string | undefined;
let bits: number | undefined;
try {
if (typeof value === 'string') {
material = value;
bits = toolkit.byteLength(value) ? toolkit.byteLength(value)! * 8 : undefined;
} else {
const bytes = toolkit.bytesForInput(value);
if (bytes) {
material = toolkit.bytesToBase64(bytes);
bits = bytes.byteLength * 8;
}
}
} catch {
material = undefined;
bits = undefined;
}
return {
kind,
bits,
fingerprint: material ? toolkit.fingerprint(material) : undefined,
};
}
export function uniqueRecords(values: unknown[]): Record<string, unknown>[] {
const seen = new Set<Record<string, unknown>>();
const output: Record<string, unknown>[] = [];
for (const value of values) {
const item = asRecord(value);
if (!item || seen.has(item)) continue;
seen.add(item);
output.push(item);
}
return output;
}
@@ -0,0 +1,320 @@
import type { BrowserRecordingCallArgument, BrowserRecordingCrypto } from '@/types/models';
import type {
CallableOperationKind,
CryptoAdapterInvocationPlan,
CryptoAdapterOperation,
CryptoAdapterToolkit,
PageCryptoAdapter,
} from './contract';
import { nobleManifest } from './catalog';
import { asRecord, callableProxy, opaqueKey, uniqueRecords } from './modern-common';
interface FactoryDefinition {
key: string;
algorithm: string;
mode: string;
}
const CIPHER_FACTORIES: FactoryDefinition[] = [
{ key: 'gcm', algorithm: 'AES-GCM', mode: 'gcm' },
{ key: 'gcmsiv', algorithm: 'AES-GCM-SIV', mode: 'gcm-siv' },
{ key: 'cbc', algorithm: 'AES-CBC', mode: 'cbc' },
{ key: 'ctr', algorithm: 'AES-CTR', mode: 'ctr' },
{ key: 'ecb', algorithm: 'AES-ECB', mode: 'ecb' },
{ key: 'cfb', algorithm: 'AES-CFB', mode: 'cfb' },
{ key: 'chacha20poly1305', algorithm: 'ChaCha20-Poly1305', mode: 'aead' },
{ key: 'xchacha20poly1305', algorithm: 'XChaCha20-Poly1305', mode: 'aead' },
];
interface DirectCipherDefinition {
key: string;
algorithm: string;
}
const DIRECT_CIPHERS: DirectCipherDefinition[] = [
{ key: 'chacha20', algorithm: 'ChaCha20' },
{ key: 'xchacha20', algorithm: 'XChaCha20' },
{ key: 'salsa20', algorithm: 'Salsa20' },
{ key: 'xsalsa20', algorithm: 'XSalsa20' },
];
const HASHES: Array<{ key: string; algorithm: string }> = [
{ key: 'sha256', algorithm: 'SHA-256' },
{ key: 'sha512', algorithm: 'SHA-512' },
{ key: 'sha3_256', algorithm: 'SHA3-256' },
{ key: 'sha3_512', algorithm: 'SHA3-512' },
{ key: 'blake2b', algorithm: 'BLAKE2b' },
{ key: 'blake2s', algorithm: 'BLAKE2s' },
{ key: 'blake3', algorithm: 'BLAKE3' },
];
function child(owner: Record<string, unknown> | undefined, key: string): Record<string, unknown> | undefined {
try { return owner ? asRecord(owner[key]) : undefined; } catch { return undefined; }
}
function isMethod(owner: Record<string, unknown>, key: string): boolean {
try { return typeof owner[key] === 'function'; } catch { return false; }
}
function cipherInstanceOperations(
value: unknown,
definition: FactoryDefinition,
correlationId: string,
key: BrowserRecordingCrypto['key'],
): CryptoAdapterOperation[] {
const owner = asRecord(value);
if (!owner) return [];
return (['encrypt', 'decrypt'] as const).flatMap((method) => isMethod(owner, method) ? [{
id: `noble.${correlationId}.${definition.key}.${method}`,
operation: `${definition.algorithm}.${method}`,
owner,
key: method,
resultMode: 'sync' as const,
describe: (_thisArg: unknown, args: unknown[], toolkit: CryptoAdapterToolkit): CryptoAdapterInvocationPlan => ({
crypto: {
adapterId: nobleManifest.id,
providerKind: nobleManifest.providerKind,
family: 'symmetric',
operation: `${definition.algorithm}.${method}`,
algorithm: definition.algorithm,
mode: definition.mode,
inputEncoding: 'auto',
outputEncoding: 'auto',
state: { model: 'receiver', phase: 'one-shot', correlationId },
key,
},
inputIndex: 0,
callableKind: method,
outputEncoding: 'auto',
arguments: args.slice(0, 4).map((argument, index) => toolkit.argument(
index, index === 0 ? 'data' : 'options', argument, index === 0, true,
)),
adaptInput: (input) => toolkit.defaultAdaptInput(input, args[0]),
}),
createWrapper: callableProxy,
}] : []);
}
function factoryOperation(
owner: Record<string, unknown>,
definition: FactoryDefinition,
ownerIndex: number,
): CryptoAdapterOperation {
return {
id: `noble.factory.${ownerIndex}.${definition.key}`,
operation: `${definition.algorithm}.create`,
owner,
key: definition.key,
resultMode: 'sync',
describe: (_thisArg, args, toolkit) => {
const correlationId = toolkit.unique('noble-cipher');
const key = opaqueKey(args[0], 'secret', toolkit);
const nonceBytes = toolkit.byteLength(args[1]);
const aadBytes = toolkit.byteLength(args[2]);
return {
crypto: {
adapterId: nobleManifest.id,
providerKind: nobleManifest.providerKind,
family: 'symmetric',
operation: `${definition.algorithm}.create`,
algorithm: definition.algorithm,
mode: definition.mode,
inputEncoding: 'auto',
outputEncoding: 'auto',
state: { model: 'session', phase: 'create', correlationId },
key,
},
inputIndex: -1,
arguments: args.slice(0, 4).map((argument, index) => toolkit.argument(
index,
index === 0 ? 'key' : index === 1 ? 'nonce' : index === 2 ? 'aad' : 'options',
argument,
false,
false,
index === 1 && nonceBytes !== undefined
? `nonceBytes=${nonceBytes}${aadBytes !== undefined ? ` aadBytes=${aadBytes}` : ''}`
: undefined,
)),
outputEvidence: () => [],
discoverResult: (result) => cipherInstanceOperations(result, definition, correlationId, key),
};
},
createWrapper: callableProxy,
};
}
function directCipherOperation(
owner: Record<string, unknown>,
definition: DirectCipherDefinition,
ownerIndex: number,
): CryptoAdapterOperation {
return {
id: `noble.stream.${ownerIndex}.${definition.key}`,
operation: `${definition.algorithm}.transform`,
owner,
key: definition.key,
resultMode: 'sync',
describe: (_thisArg, args, toolkit) => ({
crypto: {
adapterId: nobleManifest.id,
providerKind: nobleManifest.providerKind,
family: 'symmetric',
operation: `${definition.algorithm}.transform`,
algorithm: definition.algorithm,
mode: 'stream',
inputEncoding: 'auto',
outputEncoding: 'auto',
state: { model: 'stateless', phase: 'one-shot' },
key: opaqueKey(args[0], 'secret', toolkit),
},
inputIndex: 2,
callableKind: 'encrypt',
outputEncoding: 'auto',
arguments: args.slice(0, 6).map((argument, index) => toolkit.argument(
index,
index === 0 ? 'key' : index === 1 ? 'nonce' : index === 2 ? 'data' : 'options',
argument,
index === 2,
true,
index === 1 ? `nonceBytes=${toolkit.byteLength(argument) || 0}` : undefined,
)),
adaptInput: (input) => toolkit.defaultAdaptInput(input, args[2]),
}),
createWrapper: callableProxy,
};
}
function hashOperation(
owner: Record<string, unknown>,
definition: { key: string; algorithm: string },
ownerIndex: number,
): CryptoAdapterOperation {
return {
id: `noble.hash.${ownerIndex}.${definition.key}`,
operation: `${definition.algorithm}.digest`,
owner,
key: definition.key,
resultMode: 'sync',
describe: (_thisArg, args, toolkit) => ({
crypto: {
adapterId: nobleManifest.id,
providerKind: nobleManifest.providerKind,
family: 'digest',
operation: `${definition.algorithm}.digest`,
algorithm: definition.algorithm,
inputEncoding: 'auto',
outputEncoding: 'auto',
state: { model: 'stateless', phase: 'one-shot' },
},
inputIndex: 0,
callableKind: 'digest',
outputEncoding: 'auto',
arguments: args.slice(0, 4).map((argument, index) => toolkit.argument(
index, index === 0 ? 'data' : 'options', argument, index === 0, true,
)),
adaptInput: (input) => toolkit.defaultAdaptInput(input, args[0]),
}),
createWrapper: callableProxy,
};
}
function curveOperations(owner: Record<string, unknown>, algorithm: string, ownerIndex: number): CryptoAdapterOperation[] {
const definitions: Array<{
key: string;
operation: string;
callableKind: CallableOperationKind;
inputIndex: number;
roles: BrowserRecordingCallArgument['role'][];
keyIndex: number;
keyKind: NonNullable<BrowserRecordingCrypto['key']>['kind'];
resultMode: 'sync' | 'promise';
}> = [
{ key: 'sign', operation: 'sign', callableKind: 'sign', inputIndex: 0, roles: ['data', 'key', 'options'], keyIndex: 1, keyKind: 'private', resultMode: 'sync' },
{ key: 'signAsync', operation: 'sign', callableKind: 'sign', inputIndex: 0, roles: ['data', 'key', 'options'], keyIndex: 1, keyKind: 'private', resultMode: 'promise' },
{ key: 'verify', operation: 'verify', callableKind: 'verify', inputIndex: 1, roles: ['signature', 'data', 'key', 'options'], keyIndex: 2, keyKind: 'public', resultMode: 'sync' },
{ key: 'verifyAsync', operation: 'verify', callableKind: 'verify', inputIndex: 1, roles: ['signature', 'data', 'key', 'options'], keyIndex: 2, keyKind: 'public', resultMode: 'promise' },
];
return definitions.flatMap((definition) => isMethod(owner, definition.key) ? [{
id: `noble.curve.${ownerIndex}.${algorithm}.${definition.key}`,
operation: `${algorithm}.${definition.operation}`,
owner,
key: definition.key,
resultMode: definition.resultMode,
describe: (_thisArg: unknown, args: unknown[], toolkit: CryptoAdapterToolkit): CryptoAdapterInvocationPlan => ({
crypto: {
adapterId: nobleManifest.id,
providerKind: nobleManifest.providerKind,
family: 'signature',
operation: `${algorithm}.${definition.operation}`,
algorithm,
inputEncoding: 'auto',
outputEncoding: 'auto',
state: { model: 'stateless', phase: 'one-shot' },
key: opaqueKey(args[definition.keyIndex], definition.keyKind, toolkit),
},
inputIndex: definition.inputIndex,
callableKind: definition.callableKind,
outputEncoding: 'auto',
arguments: args.slice(0, 6).map((argument, index) => toolkit.argument(
index,
definition.roles[index] || 'unknown',
argument,
index === definition.inputIndex,
true,
)),
outputError: definition.callableKind === 'verify'
? (result) => result === false ? `${algorithm} verification failed` : undefined
: undefined,
adaptInput: (input) => toolkit.defaultAdaptInput(input, args[definition.inputIndex]),
}),
createWrapper: callableProxy,
}] : []);
}
export const nobleAdapter: PageCryptoAdapter = {
manifest: nobleManifest,
discover(scope): CryptoAdapterOperation[] {
const globals = scope.window as unknown as {
noble?: unknown;
nobleCiphers?: unknown;
nobleHashes?: unknown;
nobleCurves?: unknown;
};
const noble = asRecord(globals.noble);
const cipherNamespace = asRecord(globals.nobleCiphers) || child(noble, 'ciphers');
const hashNamespace = asRecord(globals.nobleHashes) || child(noble, 'hashes');
const curveNamespace = asRecord(globals.nobleCurves) || child(noble, 'curves');
const cipherOwners = uniqueRecords([
cipherNamespace,
child(cipherNamespace, 'aes'),
child(cipherNamespace, 'chacha'),
child(cipherNamespace, 'salsa'),
noble,
child(noble, 'aes'),
child(noble, 'chacha'),
]);
const hashOwners = uniqueRecords([
hashNamespace,
child(hashNamespace, 'sha2'),
child(hashNamespace, 'sha3'),
child(hashNamespace, 'blake'),
child(noble, 'hash'),
]);
const curveNames = ['ed25519', 'ed448', 'secp256k1', 'p256', 'p384', 'p521'];
const curveOwners = curveNames.flatMap((name) => {
const owner = child(curveNamespace, name) || child(noble, name);
return owner ? [{ owner, name }] : [];
});
return [
...cipherOwners.flatMap((owner, index) => [
...CIPHER_FACTORIES.filter((definition) => isMethod(owner, definition.key))
.map((definition) => factoryOperation(owner, definition, index)),
...DIRECT_CIPHERS.filter((definition) => isMethod(owner, definition.key))
.map((definition) => directCipherOperation(owner, definition, index)),
]),
...hashOwners.flatMap((owner, index) => HASHES.filter((definition) => isMethod(owner, definition.key))
.map((definition) => hashOperation(owner, definition, index))),
...curveOwners.flatMap(({ owner, name }, index) => curveOperations(owner, name, index)),
];
},
};
@@ -0,0 +1,206 @@
import type { BrowserRecordingCrypto, BrowserRecordingValueEvidence } from '@/types/models';
import type {
CryptoAdapterInvocationPlan,
CryptoAdapterOperation,
CryptoAdapterToolkit,
PageCryptoAdapter,
} from './contract';
import { openPgpManifest } from './catalog';
import { asRecord, callableProxy, hasMethod } from './modern-common';
interface MessageEvidence {
correlationId: string;
evidence: BrowserRecordingValueEvidence[];
sourceKind: 'text' | 'binary' | 'stream' | 'unknown';
}
interface HighLevelDefinition {
key: 'encrypt' | 'decrypt' | 'sign' | 'verify';
family: BrowserRecordingCrypto['family'];
keyKind: NonNullable<BrowserRecordingCrypto['key']>['kind'];
}
const HIGH_LEVEL_OPERATIONS: HighLevelDefinition[] = [
{ key: 'encrypt', family: 'asymmetric', keyKind: 'public' },
{ key: 'decrypt', family: 'asymmetric', keyKind: 'private' },
{ key: 'sign', family: 'signature', keyKind: 'private' },
{ key: 'verify', family: 'signature', keyKind: 'public' },
];
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 streamLike(value: unknown): boolean {
if (!value || typeof value !== 'object') return false;
try { return typeof (value as { getReader?: unknown }).getReader === 'function'; } catch { return false; }
}
function sourceFromOptions(value: unknown): { value?: unknown; path: string; kind: MessageEvidence['sourceKind'] } {
for (const [key, kind] of [
['text', 'text'],
['binary', 'binary'],
['armoredMessage', 'text'],
['binaryMessage', 'binary'],
['cleartextMessage', 'text'],
] as const) {
const source = ownValue(value, key);
if (source !== undefined) return {
value: source,
path: `$input.${key}`,
kind: streamLike(source) ? 'stream' : kind,
};
}
return { path: '$input', kind: 'unknown' };
}
function messageOperation(
root: Record<string, unknown>,
key: 'createMessage' | 'createCleartextMessage' | 'readMessage' | 'readCleartextMessage',
messages: WeakMap<object, MessageEvidence>,
): CryptoAdapterOperation | undefined {
if (!hasMethod(root, key)) return undefined;
return {
id: `openpgp.${key}`,
operation: key,
owner: root,
key,
resultMode: 'promise',
describe: (_thisArg, args, toolkit) => {
const source = sourceFromOptions(args[0]);
const correlationId = toolkit.unique('openpgp-message');
const evidence = source.value === undefined
? []
: toolkit.collectEvidence(source.value, source.path).slice(0, 48);
return {
crypto: {
adapterId: openPgpManifest.id,
providerKind: openPgpManifest.providerKind,
family: 'unknown',
operation: key,
algorithm: 'OpenPGP',
inputEncoding: source.kind === 'text' ? 'utf8' : 'auto',
outputEncoding: 'auto',
state: {
model: source.kind === 'stream' ? 'stream' : 'async-ready',
phase: 'create',
correlationId,
},
},
inputIndex: 0,
arguments: args.slice(0, 3).map((argument, index) => toolkit.argument(
index, index === 0 ? 'data' : 'options', argument, false, false,
index === 0 ? `source=${source.kind}` : undefined,
)),
inputEvidence: () => evidence,
outputEvidence: () => [],
discoverResult: (result) => {
if (result && typeof result === 'object') messages.set(result, { correlationId, evidence, sourceKind: source.kind });
return [];
},
};
},
createWrapper: callableProxy,
};
}
function keyCount(value: unknown): number {
if (Array.isArray(value)) return value.length;
return value == null ? 0 : 1;
}
function highLevelOperation(
root: Record<string, unknown>,
definition: HighLevelDefinition,
messages: WeakMap<object, MessageEvidence>,
): CryptoAdapterOperation | undefined {
if (!hasMethod(root, definition.key)) return undefined;
return {
id: `openpgp.${definition.key}`,
operation: `OpenPGP.${definition.key}`,
owner: root,
key: definition.key,
resultMode: 'promise',
describe: (_thisArg, args, toolkit): CryptoAdapterInvocationPlan => {
const options = args[0];
const message = ownValue(options, 'message');
const metadata = message && typeof message === 'object' ? messages.get(message) : undefined;
const format = ownValue(options, 'format');
const encryptionKeys = ownValue(options, 'encryptionKeys');
const decryptionKeys = ownValue(options, 'decryptionKeys');
const signingKeys = ownValue(options, 'signingKeys');
const verificationKeys = ownValue(options, 'verificationKeys');
const passwords = ownValue(options, 'passwords');
const hasPasswords = keyCount(passwords) > 0;
const keyValue = definition.key === 'encrypt' ? encryptionKeys
: definition.key === 'decrypt' ? decryptionKeys
: definition.key === 'sign' ? signingKeys : verificationKeys;
const family = hasPasswords && (definition.key === 'encrypt' || definition.key === 'decrypt')
? 'symmetric'
: definition.family;
const summary = [
typeof format === 'string' ? `format=${format.slice(0, 32)}` : undefined,
keyCount(keyValue) ? `keys=${keyCount(keyValue)}` : undefined,
hasPasswords ? `passwords=${keyCount(passwords)}` : undefined,
metadata ? `source=${metadata.sourceKind}` : undefined,
].filter(Boolean).join(' ');
return {
crypto: {
adapterId: openPgpManifest.id,
providerKind: openPgpManifest.providerKind,
family,
operation: `OpenPGP.${definition.key}`,
algorithm: hasPasswords ? 'OpenPGP password-based' : 'OpenPGP public-key',
inputEncoding: metadata?.sourceKind === 'text' ? 'utf8' : 'auto',
outputEncoding: format === 'binary' ? 'auto' : 'utf8',
state: {
model: metadata?.sourceKind === 'stream' ? 'stream' : 'async-ready',
phase: 'final',
correlationId: metadata?.correlationId,
},
key: keyCount(keyValue) || hasPasswords
? { kind: hasPasswords ? 'secret' : definition.keyKind }
: undefined,
},
// OpenPGP's public API accepts a composite options object. Replacing that
// object would discard message/key/stream state, so replay is promoted to
// the enclosing business closure instead of exposing an unsafe primitive.
inputIndex: 0,
arguments: args.slice(0, 3).map((argument, index) => toolkit.argument(
index, index === 0 ? 'data' : 'options', argument, false, false, index === 0 ? summary : undefined,
)),
inputEvidence: () => metadata?.evidence || [],
outputEvidence: definition.key === 'decrypt'
? (result) => {
const data = ownValue(result, 'data');
return data === undefined ? toolkit.defaultOutputEvidence(result) : toolkit.collectEvidence(data, '$output.data');
}
: undefined,
outputError: (result) => result === false || result === null ? `OpenPGP.${definition.key} returned no result` : undefined,
};
},
createWrapper: callableProxy,
};
}
export const openPgpAdapter: PageCryptoAdapter = {
manifest: openPgpManifest,
discover(scope): CryptoAdapterOperation[] {
const root = asRecord((scope.window as unknown as { openpgp?: unknown }).openpgp);
if (!root) return [];
const messages = new WeakMap<object, MessageEvidence>();
return [
messageOperation(root, 'createMessage', messages),
messageOperation(root, 'createCleartextMessage', messages),
messageOperation(root, 'readMessage', messages),
messageOperation(root, 'readCleartextMessage', messages),
...HIGH_LEVEL_OPERATIONS.map((definition) => highLevelOperation(root, definition, messages)),
].filter((operation): operation is CryptoAdapterOperation => Boolean(operation));
},
};
@@ -191,6 +191,35 @@ describe('crypto adapter runtime', () => {
expect(discoveries).toBe(6);
});
it('installs an async-ready adapter as soon as its page-owned readiness promise settles', async () => {
vi.useFakeTimers();
const document = fakeDocument();
const owner: Record<string, unknown> = {};
let ready = false;
let resolveReady!: () => void;
const readiness = new Promise<void>((resolve) => { resolveReady = resolve; });
const asyncAdapter: PageCryptoAdapter = {
manifest: { id: 'vendor', displayName: 'Vendor', providerKind: 'library', dynamic: true, globalPaths: ['Vendor'] },
ready: () => readiness,
discover: () => ready ? [operation(owner)] : [],
};
const runtime = createCryptoAdapterRuntime([asyncAdapter], scope(document), toolkit(), {
unique: () => 'wrapper-ready',
invoke(_operation, target, thisArg, args) { return Reflect.apply(target, thisArg, args); },
});
runtime.start();
expect(owner.encrypt).toBeUndefined();
owner.encrypt = (value: string) => value;
ready = true;
resolveReady();
await readiness;
await Promise.resolve();
expect(runtime.wrapperFunction('wrapper-ready')).toBe(owner.encrypt);
runtime.stop();
});
it('installs returned session operations immediately and restores them across restart', () => {
vi.useFakeTimers();
const document = fakeDocument();
@@ -38,8 +38,27 @@ export function createCryptoAdapterRuntime(
const restorers: Array<() => void> = [];
const dynamicOperations: Array<{ adapter: PageCryptoAdapter; operation: CryptoAdapterOperation }> = [];
const retryTimers = new Set<number>();
const watchedReadiness = new WeakSet<object>();
let active = false;
const watchReadiness = (adapter: PageCryptoAdapter): void => {
if (!adapter.ready) return;
let readiness: PromiseLike<unknown> | undefined;
try { readiness = adapter.ready(scope); } catch { return; }
if (!readiness || (typeof readiness !== 'object' && typeof readiness !== 'function')) return;
const identity = readiness as object;
if (watchedReadiness.has(identity)) return;
watchedReadiness.add(identity);
void Promise.resolve(readiness).then(() => {
if (!active) return;
let operations: CryptoAdapterOperation[] = [];
try { operations = adapter.discover(scope); } catch { return; }
for (const operation of operations) {
try { installOperation(adapter, operation); } catch { /* A readiness callback cannot break recording. */ }
}
}).catch(() => undefined);
};
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;
@@ -85,6 +104,7 @@ export function createCryptoAdapterRuntime(
if (!active) return;
for (const adapter of adapters) {
if (dynamicOnly && !adapter.manifest.dynamic) continue;
watchReadiness(adapter);
let operations: CryptoAdapterOperation[] = [];
try { operations = adapter.discover(scope); } catch { continue; }
for (const operation of operations) {
@@ -0,0 +1,107 @@
import type { BrowserRecordingCallArgument, BrowserRecordingCrypto } from '@/types/models';
import type {
CallableOperationKind,
CryptoAdapterInvocationPlan,
CryptoAdapterOperation,
CryptoAdapterToolkit,
PageCryptoAdapter,
} from './contract';
import { tweetNaclManifest } from './catalog';
import { asRecord, callableProxy, opaqueKey } from './modern-common';
interface TweetNaclOperationDefinition {
path: string;
operation: string;
family: BrowserRecordingCrypto['family'];
algorithm: string;
callableKind: CallableOperationKind;
inputIndex: number;
roles: BrowserRecordingCallArgument['role'][];
keyIndex?: number;
keyKind?: NonNullable<BrowserRecordingCrypto['key']>['kind'];
failureOnEmpty?: boolean;
}
const OPERATIONS: TweetNaclOperationDefinition[] = [
{ path: 'secretbox', operation: 'secretbox.encrypt', family: 'symmetric', algorithm: 'XSalsa20-Poly1305', callableKind: 'encrypt', inputIndex: 0, roles: ['data', 'nonce', 'key'], keyIndex: 2, keyKind: 'secret' },
{ path: 'secretbox.open', operation: 'secretbox.decrypt', family: 'symmetric', algorithm: 'XSalsa20-Poly1305', callableKind: 'decrypt', inputIndex: 0, roles: ['data', 'nonce', 'key'], keyIndex: 2, keyKind: 'secret', failureOnEmpty: true },
{ path: 'box', operation: 'box.encrypt', family: 'asymmetric', algorithm: 'X25519-XSalsa20-Poly1305', callableKind: 'encrypt', inputIndex: 0, roles: ['data', 'nonce', 'key', 'key'], keyIndex: 3, keyKind: 'private' },
{ path: 'box.open', operation: 'box.decrypt', family: 'asymmetric', algorithm: 'X25519-XSalsa20-Poly1305', callableKind: 'decrypt', inputIndex: 0, roles: ['data', 'nonce', 'key', 'key'], keyIndex: 3, keyKind: 'private', failureOnEmpty: true },
{ path: 'sign', operation: 'ed25519.sign-attached', family: 'signature', algorithm: 'Ed25519', callableKind: 'sign', inputIndex: 0, roles: ['data', 'key'], keyIndex: 1, keyKind: 'private' },
{ path: 'sign.open', operation: 'ed25519.open-signed', family: 'signature', algorithm: 'Ed25519', callableKind: 'verify', inputIndex: 0, roles: ['data', 'key'], keyIndex: 1, keyKind: 'public', failureOnEmpty: true },
{ path: 'sign.detached', operation: 'ed25519.sign', family: 'signature', algorithm: 'Ed25519', callableKind: 'sign', inputIndex: 0, roles: ['data', 'key'], keyIndex: 1, keyKind: 'private' },
{ path: 'sign.detached.verify', operation: 'ed25519.verify', family: 'signature', algorithm: 'Ed25519', callableKind: 'verify', inputIndex: 0, roles: ['data', 'signature', 'key'], keyIndex: 2, keyKind: 'public', failureOnEmpty: true },
{ path: 'hash', operation: 'sha512.digest', family: 'digest', algorithm: 'SHA-512', callableKind: 'digest', inputIndex: 0, roles: ['data'] },
];
function resolve(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 = asRecord(owner[segment]);
if (!next) return undefined;
owner = next;
}
const key = segments.at(-1)!;
try { return typeof owner[key] === 'function' ? { owner, key } : undefined; } catch { return undefined; }
}
function describe(
definition: TweetNaclOperationDefinition,
args: unknown[],
toolkit: CryptoAdapterToolkit,
): CryptoAdapterInvocationPlan {
const nonceIndex = definition.roles.indexOf('nonce');
const nonceSummary = nonceIndex >= 0 ? `nonceBytes=${toolkit.byteLength(args[nonceIndex]) || 0}` : undefined;
return {
crypto: {
adapterId: tweetNaclManifest.id,
providerKind: tweetNaclManifest.providerKind,
family: definition.family,
operation: definition.operation,
algorithm: definition.algorithm,
inputEncoding: 'auto',
outputEncoding: 'auto',
state: { model: 'stateless', phase: 'one-shot' },
key: definition.keyIndex === undefined
? undefined
: opaqueKey(args[definition.keyIndex], definition.keyKind || 'unknown', toolkit),
},
inputIndex: definition.inputIndex,
callableKind: definition.callableKind,
outputEncoding: 'auto',
arguments: args.slice(0, 8).map((value, index) => toolkit.argument(
index,
definition.roles[index] || 'unknown',
value,
index === definition.inputIndex,
true,
index === nonceIndex ? nonceSummary : undefined,
)),
outputError: definition.failureOnEmpty
? (value) => value === false || value === null ? `${definition.algorithm} verification failed` : undefined
: undefined,
adaptInput: (value) => toolkit.defaultAdaptInput(value, args[definition.inputIndex]),
};
}
export const tweetNaclAdapter: PageCryptoAdapter = {
manifest: tweetNaclManifest,
discover(scope): CryptoAdapterOperation[] {
const globals = scope.window as unknown as { nacl?: unknown; tweetnacl?: unknown };
const root = asRecord(globals.nacl) || asRecord(globals.tweetnacl);
if (!root) return [];
return OPERATIONS.flatMap((definition) => {
const target = resolve(root, definition.path);
return target ? [{
id: `tweetnacl.${definition.path}`,
operation: definition.operation,
owner: target.owner,
key: target.key,
resultMode: 'sync' as const,
describe: (_thisArg: unknown, args: unknown[], toolkit: CryptoAdapterToolkit) => describe(definition, args, toolkit),
createWrapper: callableProxy,
}] : [];
});
},
};
@@ -4,6 +4,7 @@ import {
cryptoDeepCaptureMatcher,
cryptoEventLabel,
isForwardCryptoEvent,
isReverseCryptoEvent,
normalizeBrowserRecordingCrypto,
} from './model';
@@ -50,6 +51,8 @@ describe('browser crypto model', () => {
it('classifies forward and reverse RSA calls', () => {
expect(isForwardCryptoEvent(cryptoEvent('encrypt'))).toBe(true);
expect(isForwardCryptoEvent(cryptoEvent('decrypt'))).toBe(false);
expect(isReverseCryptoEvent(cryptoEvent('decrypt'))).toBe(true);
expect(isReverseCryptoEvent(cryptoEvent('verify'))).toBe(false);
});
it('uses adapter-aware labels and exact wrapper handles for deep capture', () => {
+8
View File
@@ -82,6 +82,14 @@ export function isForwardCryptoEvent(event: BrowserRecordingEvent): boolean {
.some((name) => operation.includes(name));
}
export function isReverseCryptoEvent(event: BrowserRecordingEvent): boolean {
if (event.kind !== 'crypto' || !event.crypto) return false;
const operation = `${event.operation} ${event.crypto.operation}`.toLowerCase();
if (operation.includes('verify')) return false;
return ['decrypt', 'decipher', 'unseal', '.open', 'box.open', 'secretbox.open']
.some((name) => operation.includes(name));
}
export function cryptoDeepCaptureMatcher(event: Pick<
BrowserRecordingEvent,
'kind' | 'crypto' | 'wrapperHandleId' | 'scriptUrl'