mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-25 20:51:52 +08:00
feat: advance browser agent integration workflows
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user