- AES-256-GCM via SubtleCrypto (browsers + React Native with polyfill) - Wire-compatible EncryptedField with @bytelyst/field-encrypt (server) and BLFieldEncrypt (Swift/Kotlin native SDKs) - encryptField, decryptField, generateKey, keyFromHex, keyToHex - PBKDF2 key derivation (600k iterations per OWASP 2023) - isEncryptedField type guard, toHex/fromHex helpers - 22 Vitest tests, all passing - Add Web Crypto globals to root ESLint config
182 lines
6.0 KiB
TypeScript
182 lines
6.0 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
encryptField,
|
|
decryptField,
|
|
generateKey,
|
|
keyFromHex,
|
|
keyToHex,
|
|
deriveKey,
|
|
} from './aes-gcm.js';
|
|
import { isEncryptedField } from './guards.js';
|
|
import { toHex, fromHex } from './hex.js';
|
|
|
|
describe('encryptField / decryptField', () => {
|
|
it('roundtrip', async () => {
|
|
const key = await generateKey();
|
|
const encrypted = await encryptField('Hello, World!', key, 'dek_test');
|
|
const decrypted = await decryptField(encrypted, key);
|
|
expect(decrypted).toBe('Hello, World!');
|
|
});
|
|
|
|
it('empty string', async () => {
|
|
const key = await generateKey();
|
|
const encrypted = await encryptField('', key, 'dek_test');
|
|
const decrypted = await decryptField(encrypted, key);
|
|
expect(decrypted).toBe('');
|
|
});
|
|
|
|
it('unicode', async () => {
|
|
const key = await generateKey();
|
|
const text = 'こんにちは世界 🌍 مرحبا Ñoño';
|
|
const encrypted = await encryptField(text, key, 'dek_test');
|
|
const decrypted = await decryptField(encrypted, key);
|
|
expect(decrypted).toBe(text);
|
|
});
|
|
|
|
it('large payload', async () => {
|
|
const key = await generateKey();
|
|
const text = 'A'.repeat(100_000);
|
|
const encrypted = await encryptField(text, key, 'dek_test');
|
|
const decrypted = await decryptField(encrypted, key);
|
|
expect(decrypted).toBe(text);
|
|
});
|
|
});
|
|
|
|
describe('EncryptedField structure', () => {
|
|
it('has correct sentinel fields', async () => {
|
|
const key = await generateKey();
|
|
const encrypted = await encryptField('test', key, 'dek_test');
|
|
expect(encrypted.__encrypted).toBe(true);
|
|
expect(encrypted.v).toBe(1);
|
|
expect(encrypted.alg).toBe('aes-256-gcm');
|
|
expect(encrypted.dekId).toBe('dek_test');
|
|
});
|
|
|
|
it('has correct hex lengths', async () => {
|
|
const key = await generateKey();
|
|
const encrypted = await encryptField('test', key, 'dek_test');
|
|
expect(encrypted.iv.length).toBe(24); // 12 bytes = 24 hex
|
|
expect(encrypted.tag.length).toBe(32); // 16 bytes = 32 hex
|
|
expect(encrypted.ct.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('unique IVs per encryption', async () => {
|
|
const key = await generateKey();
|
|
const a = await encryptField('same', key, 'dek_test');
|
|
const b = await encryptField('same', key, 'dek_test');
|
|
expect(a.iv).not.toBe(b.iv);
|
|
expect(a.ct).not.toBe(b.ct);
|
|
});
|
|
});
|
|
|
|
describe('AAD (Additional Authenticated Data)', () => {
|
|
it('roundtrip with AAD', async () => {
|
|
const key = await generateKey();
|
|
const encrypted = await encryptField('secret', key, 'dek_test', 'user:ctx');
|
|
const decrypted = await decryptField(encrypted, key, 'user:ctx');
|
|
expect(decrypted).toBe('secret');
|
|
});
|
|
|
|
it('wrong AAD fails', async () => {
|
|
const key = await generateKey();
|
|
const encrypted = await encryptField('secret', key, 'dek_test', 'correct');
|
|
await expect(decryptField(encrypted, key, 'wrong')).rejects.toThrow();
|
|
});
|
|
|
|
it('missing AAD fails', async () => {
|
|
const key = await generateKey();
|
|
const encrypted = await encryptField('secret', key, 'dek_test', 'some-aad');
|
|
await expect(decryptField(encrypted, key)).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('wrong key', () => {
|
|
it('decrypt with wrong key fails', async () => {
|
|
const key = await generateKey();
|
|
const wrongKey = await generateKey();
|
|
const encrypted = await encryptField('secret', key, 'dek_test');
|
|
await expect(decryptField(encrypted, wrongKey)).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('keyFromHex / keyToHex', () => {
|
|
it('roundtrip', async () => {
|
|
const key = await generateKey();
|
|
const hex = await keyToHex(key);
|
|
expect(hex.length).toBe(64); // 32 bytes = 64 hex chars
|
|
const restored = await keyFromHex(hex);
|
|
const encrypted = await encryptField('test', key, 'dek_test');
|
|
const decrypted = await decryptField(encrypted, restored);
|
|
expect(decrypted).toBe('test');
|
|
});
|
|
|
|
it('rejects invalid length', async () => {
|
|
await expect(keyFromHex('aabb')).rejects.toThrow('32-byte key');
|
|
});
|
|
});
|
|
|
|
describe('deriveKey', () => {
|
|
it('derives consistent key from passphrase + salt', async () => {
|
|
const salt = new Uint8Array(16);
|
|
globalThis.crypto.getRandomValues(salt);
|
|
const key1 = await deriveKey('my-passphrase', salt, 1000, true);
|
|
const key2 = await deriveKey('my-passphrase', salt, 1000, true);
|
|
const hex1 = await keyToHex(key1);
|
|
const hex2 = await keyToHex(key2);
|
|
expect(hex1).toBe(hex2);
|
|
});
|
|
|
|
it('different passphrases produce different keys', async () => {
|
|
const salt = new Uint8Array(16);
|
|
globalThis.crypto.getRandomValues(salt);
|
|
const key1 = await deriveKey('pass-1', salt, 1000, true);
|
|
const key2 = await deriveKey('pass-2', salt, 1000, true);
|
|
const hex1 = await keyToHex(key1);
|
|
const hex2 = await keyToHex(key2);
|
|
expect(hex1).not.toBe(hex2);
|
|
});
|
|
|
|
it('derived key can encrypt/decrypt', async () => {
|
|
const salt = new Uint8Array(16);
|
|
globalThis.crypto.getRandomValues(salt);
|
|
const key = await deriveKey('test', salt, 1000, true);
|
|
const encrypted = await encryptField('hello', key, 'dek_test');
|
|
const decrypted = await decryptField(encrypted, key);
|
|
expect(decrypted).toBe('hello');
|
|
});
|
|
});
|
|
|
|
describe('isEncryptedField', () => {
|
|
it('true for valid EncryptedField', async () => {
|
|
const key = await generateKey();
|
|
const encrypted = await encryptField('test', key, 'dek_test');
|
|
expect(isEncryptedField(encrypted)).toBe(true);
|
|
});
|
|
|
|
it('false for plain string', () => {
|
|
expect(isEncryptedField('just a string')).toBe(false);
|
|
});
|
|
|
|
it('false for null', () => {
|
|
expect(isEncryptedField(null)).toBe(false);
|
|
});
|
|
|
|
it('false for incomplete object', () => {
|
|
expect(isEncryptedField({ __encrypted: true, v: 1 })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('hex utilities', () => {
|
|
it('toHex / fromHex roundtrip', () => {
|
|
const bytes = new Uint8Array([0x00, 0x0f, 0xff, 0xab, 0xcd]);
|
|
const hex = toHex(bytes);
|
|
expect(hex).toBe('000fffabcd');
|
|
const restored = fromHex(hex);
|
|
expect(restored).toEqual(bytes);
|
|
});
|
|
|
|
it('fromHex rejects odd length', () => {
|
|
expect(() => fromHex('a')).toThrow('even length');
|
|
});
|
|
});
|