214 lines
8 KiB
TypeScript
214 lines
8 KiB
TypeScript
|
|
/**
|
||
|
|
* Unit tests for error-type fidelity with the Python SDK.
|
||
|
|
*
|
||
|
|
* Python distinguishes malformed *data* (ValueError) from a failed integrity
|
||
|
|
* check (SecurityError). The JS port previously collapsed both into
|
||
|
|
* SecurityError, which hid real bugs behind a security-sounding message.
|
||
|
|
* Malformed data now surfaces as a plain Error; only genuine crypto failures
|
||
|
|
* stay opaque.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { SecureCompletionClient } from '../../src/core/SecureCompletionClient';
|
||
|
|
import { SecurityError, APIConnectionError } from '../../src/errors';
|
||
|
|
import { AESEncryption } from '../../src/core/crypto/encryption';
|
||
|
|
import { RSAOperations } from '../../src/core/crypto/rsa';
|
||
|
|
import {
|
||
|
|
arrayBufferToBase64,
|
||
|
|
stringToArrayBuffer,
|
||
|
|
} from '../../src/core/crypto/utils';
|
||
|
|
|
||
|
|
const TAG_LENGTH = 16;
|
||
|
|
|
||
|
|
/** Build a wire-format encrypted package addressed to `pubPem`. */
|
||
|
|
async function sealTo(pubPem: string, plaintext: Uint8Array): Promise<ArrayBuffer> {
|
||
|
|
const aes = new AESEncryption();
|
||
|
|
const rsa = new RSAOperations();
|
||
|
|
|
||
|
|
const aesKey = await aes.generateKey();
|
||
|
|
const rawKey = await aes.exportKey(aesKey);
|
||
|
|
const { ciphertext, nonce } = await aes.encrypt(
|
||
|
|
plaintext.buffer.slice(
|
||
|
|
plaintext.byteOffset,
|
||
|
|
plaintext.byteOffset + plaintext.byteLength
|
||
|
|
) as ArrayBuffer,
|
||
|
|
aesKey
|
||
|
|
);
|
||
|
|
|
||
|
|
// Web Crypto appends the GCM tag; the wire format keeps them separate
|
||
|
|
const combined = new Uint8Array(ciphertext);
|
||
|
|
const ct = combined.slice(0, combined.length - TAG_LENGTH);
|
||
|
|
const tag = combined.slice(combined.length - TAG_LENGTH);
|
||
|
|
|
||
|
|
const serverKey = await rsa.importPublicKey(pubPem);
|
||
|
|
const encryptedAesKey = await rsa.encryptKey(rawKey, serverKey);
|
||
|
|
|
||
|
|
return stringToArrayBuffer(JSON.stringify({
|
||
|
|
version: '1.0',
|
||
|
|
algorithm: 'hybrid-aes256-rsa4096',
|
||
|
|
encrypted_payload: {
|
||
|
|
ciphertext: arrayBufferToBase64(ct.buffer),
|
||
|
|
nonce: arrayBufferToBase64(nonce),
|
||
|
|
tag: arrayBufferToBase64(tag.buffer),
|
||
|
|
},
|
||
|
|
encrypted_aes_key: arrayBufferToBase64(encryptedAesKey),
|
||
|
|
key_algorithm: 'RSA-OAEP-SHA256',
|
||
|
|
payload_algorithm: 'AES-256-GCM',
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
function newClient(): SecureCompletionClient {
|
||
|
|
return new SecureCompletionClient({
|
||
|
|
routerUrl: 'https://router.test',
|
||
|
|
keySize: 2048,
|
||
|
|
keyRotationInterval: 0,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function withKeys(client: SecureCompletionClient): Promise<string> {
|
||
|
|
await client.generateKeys();
|
||
|
|
return (client as unknown as {
|
||
|
|
keyManager: { getPublicKeyPEM: () => Promise<string> };
|
||
|
|
}).keyManager.getPublicKeyPEM();
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('decryptResponse error types', () => {
|
||
|
|
test('authenticated-but-unparseable plaintext is a format error, not a security error', async () => {
|
||
|
|
const client = newClient();
|
||
|
|
const pubPem = await withKeys(client);
|
||
|
|
const sealed = await sealTo(pubPem, new TextEncoder().encode('not json at all'));
|
||
|
|
|
||
|
|
const error = await client.decryptResponse(sealed, 'pid').catch((e: unknown) => e);
|
||
|
|
|
||
|
|
expect(error).toBeInstanceOf(Error);
|
||
|
|
expect(error).not.toBeInstanceOf(SecurityError);
|
||
|
|
expect((error as Error).message).toBe('Decrypted response is not valid JSON');
|
||
|
|
client.dispose();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('valid JSON of the wrong shape is reported as a schema failure', async () => {
|
||
|
|
const client = newClient();
|
||
|
|
const pubPem = await withKeys(client);
|
||
|
|
const sealed = await sealTo(pubPem, new TextEncoder().encode('{"not":"a completion"}'));
|
||
|
|
|
||
|
|
await expect(client.decryptResponse(sealed, 'pid'))
|
||
|
|
.rejects.toThrow('does not conform to expected schema');
|
||
|
|
client.dispose();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('a tampered ciphertext stays opaque', async () => {
|
||
|
|
const client = newClient();
|
||
|
|
const pubPem = await withKeys(client);
|
||
|
|
const sealed = await sealTo(pubPem, new TextEncoder().encode('{"a":1}'));
|
||
|
|
|
||
|
|
// Flip a byte of the ciphertext so GCM authentication fails
|
||
|
|
const pkg = JSON.parse(new TextDecoder().decode(sealed)) as {
|
||
|
|
encrypted_payload: { ciphertext: string };
|
||
|
|
};
|
||
|
|
const raw = Buffer.from(pkg.encrypted_payload.ciphertext, 'base64');
|
||
|
|
raw[0] ^= 0xff;
|
||
|
|
pkg.encrypted_payload.ciphertext = raw.toString('base64');
|
||
|
|
|
||
|
|
const error = await client
|
||
|
|
.decryptResponse(stringToArrayBuffer(JSON.stringify(pkg)), 'pid')
|
||
|
|
.catch((e: unknown) => e);
|
||
|
|
|
||
|
|
expect(error).toBeInstanceOf(SecurityError);
|
||
|
|
// No detail about which stage failed
|
||
|
|
expect((error as Error).message)
|
||
|
|
.toBe('Decryption failed: integrity check or authentication failed');
|
||
|
|
client.dispose();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('decrypting without keys reports the missing key, not an integrity failure', async () => {
|
||
|
|
const client = newClient();
|
||
|
|
const other = newClient();
|
||
|
|
const pubPem = await withKeys(other);
|
||
|
|
const sealed = await sealTo(pubPem, new TextEncoder().encode('{"a":1}'));
|
||
|
|
|
||
|
|
const error = await client.decryptResponse(sealed, 'pid').catch((e: unknown) => e);
|
||
|
|
|
||
|
|
expect(error).toBeInstanceOf(SecurityError);
|
||
|
|
expect((error as Error).message).toMatch(/Private key not initialized/);
|
||
|
|
client.dispose();
|
||
|
|
other.dispose();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('a malformed package is rejected before any crypto runs', async () => {
|
||
|
|
const client = newClient();
|
||
|
|
await withKeys(client);
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
client.decryptResponse(stringToArrayBuffer('{"version":"1.0"}'), 'pid')
|
||
|
|
).rejects.toThrow('Missing required field');
|
||
|
|
client.dispose();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('fetchServerPublicKey error types', () => {
|
||
|
|
function clientWithResponse(response: { statusCode: number; body: ArrayBuffer }) {
|
||
|
|
const client = newClient();
|
||
|
|
(client as unknown as { httpClient: unknown }).httpClient = {
|
||
|
|
get: jest.fn(async () => ({ ...response, headers: {} })),
|
||
|
|
post: jest.fn(),
|
||
|
|
};
|
||
|
|
return client;
|
||
|
|
}
|
||
|
|
|
||
|
|
test('a non-200 is a server-response problem, not a connection problem', async () => {
|
||
|
|
const client = clientWithResponse({
|
||
|
|
statusCode: 500,
|
||
|
|
body: stringToArrayBuffer('boom'),
|
||
|
|
});
|
||
|
|
|
||
|
|
const error = await client.fetchServerPublicKey().catch((e: unknown) => e);
|
||
|
|
|
||
|
|
expect(error).toBeInstanceOf(Error);
|
||
|
|
expect(error).not.toBeInstanceOf(APIConnectionError);
|
||
|
|
expect((error as Error).message).toMatch(/HTTP 500/);
|
||
|
|
client.dispose();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('an unparseable key is a format problem, not a connection problem', async () => {
|
||
|
|
const client = clientWithResponse({
|
||
|
|
statusCode: 200,
|
||
|
|
body: stringToArrayBuffer('-----BEGIN PUBLIC KEY-----\nnonsense\n-----END PUBLIC KEY-----'),
|
||
|
|
});
|
||
|
|
|
||
|
|
const error = await client.fetchServerPublicKey().catch((e: unknown) => e);
|
||
|
|
|
||
|
|
expect(error).toBeInstanceOf(Error);
|
||
|
|
expect(error).not.toBeInstanceOf(APIConnectionError);
|
||
|
|
expect((error as Error).message).toBe('Server returned invalid public key format');
|
||
|
|
client.dispose();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('a transport failure is still a connection error', async () => {
|
||
|
|
const client = newClient();
|
||
|
|
(client as unknown as { httpClient: unknown }).httpClient = {
|
||
|
|
get: jest.fn(async () => { throw new Error('ECONNREFUSED'); }),
|
||
|
|
post: jest.fn(),
|
||
|
|
};
|
||
|
|
|
||
|
|
await expect(client.fetchServerPublicKey()).rejects.toBeInstanceOf(APIConnectionError);
|
||
|
|
client.dispose();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('defaults', () => {
|
||
|
|
test('request timeout matches the Python SDK (900 s)', () => {
|
||
|
|
const client = newClient();
|
||
|
|
expect((client as unknown as { requestTimeout: number }).requestTimeout).toBe(900000);
|
||
|
|
client.dispose();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('an explicit timeout still wins', () => {
|
||
|
|
const client = new SecureCompletionClient({
|
||
|
|
routerUrl: 'https://router.test',
|
||
|
|
keyRotationInterval: 0,
|
||
|
|
timeout: 5000,
|
||
|
|
});
|
||
|
|
expect((client as unknown as { requestTimeout: number }).requestTimeout).toBe(5000);
|
||
|
|
client.dispose();
|
||
|
|
});
|
||
|
|
});
|