Files
yaklang-chrome-extension/src/features/browser-crypto/adapters/adapters.test.ts
T

339 lines
16 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { CryptoAdapterScope, CryptoAdapterToolkit } from './contract';
import { cryptoAdapterLabel } from './catalog';
import { cryptoJsAdapter } from './cryptojs';
import { jsEncryptAdapter } from './jsencrypt';
import { webCryptoAdapter } from './webcrypto';
import { smCryptoAdapter } from './sm-crypto';
import { nodeForgeAdapter } from './node-forge';
import { jsrsasignAdapter } from './jsrsasign';
import { joseAdapter } from './jose';
function byteLength(value: unknown): number | undefined {
if (typeof value === 'string') return new TextEncoder().encode(value).byteLength;
if (ArrayBuffer.isView(value)) return value.byteLength;
if (value instanceof ArrayBuffer) return value.byteLength;
if (value && typeof value === 'object' && typeof (value as { sigBytes?: unknown }).sigBytes === 'number') {
return (value as { sigBytes: number }).sigBytes;
}
return undefined;
}
function toolkit(): CryptoAdapterToolkit {
return {
unique: (prefix) => `${prefix}-1`,
byteLength,
dataType: (value) => typeof value,
fingerprint: () => 'v2:opaque-fingerprint',
argument: (index, role, value, replaceable, retained, summary) => ({
index,
role,
dataType: typeof value,
byteLength: byteLength(value),
replaceable,
retained,
summary,
}),
collectEvidence: (value, path) => [{
path,
fingerprint: `fingerprint:${String(value)}`,
encoding: 'text',
byteLength: byteLength(String(value)) || 0,
}],
defaultOutputEvidence: () => [],
defaultAdaptInput: (value) => value,
bytesForInput: (value) => value instanceof Uint8Array ? value : undefined,
bytesToBase64: (value) => `base64:${Array.from(value).join(',')}`,
};
}
describe('page crypto adapters', () => {
it('keeps the UI catalog separate and safely falls back for unknown adapter IDs', () => {
expect(cryptoAdapterLabel('webcrypto')).toBe('WebCrypto');
expect(cryptoAdapterLabel('vendor-suite.v2')).toBe('vendor-suite.v2');
});
it('describes WebCrypto input roles and state without reading key material', () => {
const subtlePrototype = { encrypt() { return Promise.resolve(new ArrayBuffer(0)); } };
const subtle = Object.create(subtlePrototype) as SubtleCrypto;
const operations = webCryptoAdapter.discover({
window: { crypto: { subtle } } as unknown as Window,
crypto: { subtle } as Crypto,
});
const encrypt = operations.find((item) => item.operation === 'encrypt');
const key = { type: 'secret' } as CryptoKey;
const plan = encrypt?.describe(subtle, [
{ name: 'AES-GCM', iv: new Uint8Array(12), tagLength: 128 },
key,
new Uint8Array([1, 2, 3]),
], toolkit());
expect(plan?.crypto).toMatchObject({
adapterId: 'webcrypto',
providerKind: 'native',
family: 'symmetric',
operation: 'encrypt',
algorithm: 'AES-GCM tag=128 ivBytes=12',
state: { model: 'receiver', phase: 'one-shot' },
});
expect(plan?.arguments.map((argument) => argument.role)).toEqual(['algorithm', 'key', 'data']);
expect(plan?.arguments[2]).toMatchObject({ replaceable: true, retained: true, byteLength: 3 });
expect(JSON.stringify(plan)).not.toContain('secret');
});
it('describes CryptoJS modes and adapts bytes through the page encoder', () => {
const CBC = {};
const Pkcs7 = {};
const parsed: string[] = [];
const cryptoJs = {
AES: { encrypt() { return 'cipher'; } },
mode: { CBC },
pad: { Pkcs7 },
enc: { Base64: { parse(value: string) { parsed.push(value); return { wordArray: value }; } } },
};
const scope = { window: { CryptoJS: cryptoJs } as unknown as Window } satisfies CryptoAdapterScope;
const encrypt = cryptoJsAdapter.discover(scope).find((item) => item.operation === 'AES.encrypt');
const plan = encrypt?.describe(cryptoJs.AES, [
{ sigBytes: 3 },
{ sigBytes: 16 },
{ mode: CBC, padding: Pkcs7, iv: { sigBytes: 16 } },
], toolkit());
expect(plan?.crypto).toMatchObject({
adapterId: 'cryptojs', family: 'symmetric', operation: 'AES.encrypt',
mode: 'CBC', padding: 'Pkcs7', outputEncoding: 'base64',
});
expect(plan?.arguments[2].summary).toBe('mode=CBC padding=Pkcs7 ivBytes=16');
expect(plan?.adaptInput?.(new Uint8Array([4, 5, 6]))).toEqual({ wordArray: 'base64:4,5,6' });
expect(parsed).toEqual(['base64:4,5,6']);
});
it('retains only bounded JSEncrypt receiver metadata', () => {
const prototype = {
encrypt() { return 'ciphertext'; },
decrypt() { return 'plaintext'; },
sign() { return 'signature'; },
verify() { return true; },
};
const instance = {
key: {
n: { bitLength: () => 2048, toString: () => 'public-modulus' },
e: 65_537,
},
};
const encrypt = jsEncryptAdapter.discover({
window: { JSEncrypt: { prototype } } as unknown as Window,
}).find((item) => item.operation === 'encrypt');
const plan = encrypt?.describe(instance, ['plain'], toolkit());
expect(plan?.crypto).toMatchObject({
adapterId: 'jsencrypt', family: 'asymmetric', algorithm: 'RSA',
state: { model: 'receiver', phase: 'one-shot' },
key: { kind: 'public', bits: 2048, fingerprint: 'v2:opaque-fingerprint' },
});
expect(plan?.arguments[0]).toMatchObject({ role: 'data', replaceable: true, retained: true });
expect(JSON.stringify(plan?.crypto)).not.toContain('public-modulus');
});
it('describes sm-crypto SM2/SM3/SM4 using one bounded contract', () => {
const smCrypto = {
sm2: {
doEncrypt: () => 'cipher',
doDecrypt: () => 'plain',
doSignature: () => 'signature',
doVerifySignature: () => true,
},
sm3: () => 'digest',
sm4: { encrypt: () => 'cipher', decrypt: () => 'plain' },
};
const operations = smCryptoAdapter.discover({ window: { ...smCrypto } as unknown as Window });
const sm4 = operations.find((item) => item.operation === 'sm4.encrypt');
const plan = sm4?.describe(smCrypto.sm4, [
'plain',
'00112233445566778899aabbccddeeff',
{ mode: 'cbc', padding: 'pkcs#7', iv: '0102030405060708' },
], toolkit());
const verify = operations.find((item) => item.operation === 'sm2.verify')?.describe(
smCrypto.sm2, ['plain', 'signature', 'public-key', { hash: true }], toolkit(),
);
expect(operations).toHaveLength(7);
expect(plan?.crypto).toMatchObject({
adapterId: 'sm-crypto', family: 'symmetric', algorithm: 'SM4', mode: 'cbc', padding: 'pkcs#7',
state: { model: 'stateless', phase: 'one-shot' },
key: { kind: 'secret', bits: 128, fingerprint: 'v2:opaque-fingerprint' },
});
expect(plan?.arguments[2].summary).toContain('ivBytes=16');
expect(verify?.callableKind).toBe('verify');
expect(verify?.arguments.map((item) => item.role)).toEqual(['data', 'signature', 'key', 'options']);
expect(JSON.stringify(plan?.crypto)).not.toContain('00112233445566778899aabbccddeeff');
});
it('discovers node-forge stateful cipher sessions without treating them as replay-safe one-shot calls', () => {
const outputBuffer = {
bytes: () => 'cipher-bytes',
length: () => 12,
getBytes: () => 'cipher-bytes',
};
const session = {
output: outputBuffer,
start: () => undefined,
update: () => undefined,
finish: () => true,
};
const forge = {
cipher: { createCipher: () => session, createDecipher: () => session },
hmac: { create: () => ({ start() {}, update() {}, digest: () => outputBuffer }) },
pki: {
publicKeyFromPem: () => ({
n: { bitLength: () => 2048, toString: () => 'modulus' }, e: 65_537,
encrypt: (value: string) => value, verify: () => true,
}),
privateKeyFromPem: () => ({
n: { bitLength: () => 2048, toString: () => 'modulus' }, e: 65_537,
d: {}, decrypt: (value: string) => value, sign: () => 'signature',
}),
},
md: {
sha256: { create: () => ({ start() {}, update() {}, digest: () => outputBuffer }) },
},
};
const operations = nodeForgeAdapter.discover({ window: { forge } as unknown as Window });
const factory = operations.find((item) => item.operation === 'cipher.create.encrypt');
const factoryPlan = factory?.describe(forge.cipher, ['AES-CBC', 'secret-key'], toolkit());
const sessionOperations = factoryPlan?.discoverResult?.(session) || [];
const update = sessionOperations.find((item) => item.operation === 'cipher.encrypt.update');
const finish = sessionOperations.find((item) => item.operation === 'cipher.encrypt.finish');
const updatePlan = update?.describe(session, [outputBuffer], toolkit());
const finishPlan = finish?.describe(session, [], toolkit());
expect(factoryPlan?.crypto).toMatchObject({
adapterId: 'node-forge', family: 'symmetric', algorithm: 'AES-CBC',
state: { model: 'session', phase: 'create', correlationId: 'forge-session-1' },
});
expect(updatePlan?.crypto.state).toMatchObject({ model: 'stream', phase: 'update', correlationId: 'forge-session-1' });
expect(updatePlan?.callableKind).toBeUndefined();
expect(finishPlan?.outputEvidence?.(true)[0]).toMatchObject({ path: '$receiver.output' });
expect(sessionOperations.some((item) => item.operation === 'cipher.encrypt.output.getBytes')).toBe(true);
});
it('turns node-forge RSA key instances into receiver-bound direct operations without exporting PEM', () => {
const key = {
n: { bitLength: () => 2048, toString: () => 'private-modulus' },
e: 65_537,
encrypt: (value: string) => `cipher:${value}`,
verify: () => true,
};
const forge = { pki: { publicKeyFromPem: () => key } };
const factory = nodeForgeAdapter.discover({ window: { forge } as unknown as Window })
.find((item) => item.operation === 'pki.public-key.create');
const plan = factory?.describe(forge.pki, ['-----BEGIN PUBLIC KEY-----raw-material'], toolkit());
const encrypt = plan?.discoverResult?.(key).find((item) => item.operation === 'rsa.encrypt');
const encryptPlan = encrypt?.describe(key, ['plain', 'RSA-OAEP'], toolkit());
expect(plan?.outputEvidence?.(key)).toEqual([]);
expect(encryptPlan?.callableKind).toBe('encrypt');
expect(encryptPlan?.crypto).toMatchObject({
family: 'asymmetric', algorithm: 'RSA', state: { model: 'receiver', phase: 'one-shot' },
key: { kind: 'public', bits: 2048, fingerprint: 'v2:opaque-fingerprint' },
});
expect(JSON.stringify(encryptPlan?.crypto)).not.toContain('raw-material');
expect(JSON.stringify(encryptPlan?.crypto)).not.toContain('private-modulus');
});
it('models a jsrsasign constructor session as create, init, update, and final stages', () => {
class Signature {
constructor(public options: { alg: string }) {}
init(_key: unknown) {}
updateString(_value: string) {}
sign() { return 'deadbeef'; }
verify(_signature: string) { return true; }
}
const JWS = {
sign: (_algorithm: string, _header: unknown, payload: unknown) => `jws:${String(payload)}`,
verify: () => true,
verifyJWT: () => true,
getJWKthumbprint: () => 'thumbprint',
};
const window = {
KJUR: { crypto: { Signature }, jws: { JWS } },
KEYUTIL: { getKey: () => ({}), getJWK: () => ({ kty: 'RSA' }), getPEM: () => 'pem' },
} as unknown as Window;
const operations = jsrsasignAdapter.discover({ window });
const constructor = operations.find((item) => item.operation === 'Signature.create');
const createPlan = constructor?.describe(undefined, [{ alg: 'SHA256withRSA' }], toolkit());
const instance = new Signature({ alg: 'SHA256withRSA' });
const stages = createPlan?.discoverResult?.(instance) || [];
const init = stages.find((item) => item.operation === 'Signature.init')
?.describe(instance, ['-----BEGIN PRIVATE KEY-----private-material'], toolkit());
const update = stages.find((item) => item.operation === 'Signature.updateString')
?.describe(instance, ['canonical-request'], toolkit());
const sign = stages.find((item) => item.operation === 'Signature.sign')
?.describe(instance, [], toolkit());
expect(constructor?.invocationMode).toBe('construct');
expect(createPlan?.crypto).toMatchObject({
adapterId: 'jsrsasign', family: 'signature', algorithm: 'SHA256withRSA',
state: { model: 'session', phase: 'create', correlationId: 'jsrsasign-signature-1' },
});
expect(init?.crypto.state).toMatchObject({ phase: 'init', correlationId: 'jsrsasign-signature-1' });
expect(update?.crypto.state).toMatchObject({ phase: 'update', correlationId: 'jsrsasign-signature-1' });
expect(sign?.crypto.state).toMatchObject({ phase: 'final', correlationId: 'jsrsasign-signature-1' });
expect(sign?.callableKind).toBeUndefined();
expect(JSON.stringify(init?.crypto)).not.toContain('private-material');
const jwsSign = operations.find((item) => item.operation === 'JWS.sign')
?.describe(JWS, ['RS256', { alg: 'RS256' }, { account: 'admin' }, 'private-key'], toolkit());
expect(jwsSign).toMatchObject({ inputIndex: 2, callableKind: 'sign' });
});
it('models jose builders as async stateful envelopes and keeps key material opaque', () => {
class SignJWT {
constructor(public payload: unknown) {}
setProtectedHeader(_header: unknown) { return this; }
setIssuedAt() { return this; }
sign(_key: unknown) { return Promise.resolve('header.payload.signature'); }
}
class CompactSign {
constructor(public payload: Uint8Array) {}
setProtectedHeader(_header: unknown) { return this; }
sign(_key: unknown) { return Promise.resolve('header.payload.signature'); }
}
class CompactEncrypt {
constructor(public payload: Uint8Array) {}
setProtectedHeader(_header: unknown) { return this; }
encrypt(_key: unknown) { return Promise.resolve('compact-jwe'); }
}
const jose = {
SignJWT, CompactSign, CompactEncrypt,
compactVerify: async () => ({ payload: new Uint8Array() }),
jwtVerify: async () => ({ payload: {} }),
compactDecrypt: async () => ({ plaintext: new Uint8Array() }),
jwtDecrypt: async () => ({ payload: {} }),
importJWK: async () => ({}),
exportJWK: async () => ({ kty: 'RSA' }),
};
const operations = joseAdapter.discover({ window: { jose } as unknown as Window });
const constructor = operations.find((item) => item.operation === 'SignJWT.create');
const createPlan = constructor?.describe(undefined, [{ account: 'admin' }], toolkit());
const instance = new SignJWT({ account: 'admin' });
const stages = createPlan?.discoverResult?.(instance) || [];
const header = stages.find((item) => item.operation === 'SignJWT.setProtectedHeader')
?.describe(instance, [{ alg: 'RS256' }], toolkit());
const final = stages.find((item) => item.operation === 'SignJWT.sign')
?.describe(instance, [{ type: 'private', algorithm: { name: 'RSA-PSS' }, secret: 'never-export' }], toolkit());
expect(constructor?.invocationMode).toBe('construct');
expect(createPlan?.crypto.state).toMatchObject({ model: 'async-ready', phase: 'create', correlationId: 'jose-session-1' });
expect(header?.crypto).toMatchObject({ algorithm: 'RS256', state: { phase: 'update', correlationId: 'jose-session-1' } });
expect(final?.crypto).toMatchObject({
family: 'signature', algorithm: 'RS256', key: { kind: 'private' },
state: { phase: 'final', correlationId: 'jose-session-1' },
});
expect(stages.find((item) => item.operation === 'SignJWT.sign')?.resultMode).toBe('promise');
expect(final?.callableKind).toBeUndefined();
expect(JSON.stringify(final?.crypto)).not.toContain('never-export');
expect(operations.find((item) => item.operation === 'CompactVerify.verify')?.resultMode).toBe('promise');
});
});