diff --git a/eslint.config.js b/eslint.config.js index 61129f5d..a3ab2a53 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -57,6 +57,11 @@ export default [ beforeAll: 'readonly', afterAll: 'readonly', crypto: 'readonly', + CryptoKey: 'readonly', + SubtleCrypto: 'readonly', + AesGcmParams: 'readonly', + Crypto: 'readonly', + ArrayBufferView: 'readonly', Blob: 'readonly', File: 'readonly', FormData: 'readonly', diff --git a/packages/client-encrypt/package.json b/packages/client-encrypt/package.json new file mode 100644 index 00000000..dad3c837 --- /dev/null +++ b/packages/client-encrypt/package.json @@ -0,0 +1,23 @@ +{ + "name": "@bytelyst/client-encrypt", + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run" + }, + "devDependencies": { + "vitest": "^3.0.0" + } +} diff --git a/packages/client-encrypt/src/aes-gcm.test.ts b/packages/client-encrypt/src/aes-gcm.test.ts new file mode 100644 index 00000000..0c7d0448 --- /dev/null +++ b/packages/client-encrypt/src/aes-gcm.test.ts @@ -0,0 +1,181 @@ +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'); + }); +}); diff --git a/packages/client-encrypt/src/aes-gcm.ts b/packages/client-encrypt/src/aes-gcm.ts new file mode 100644 index 00000000..764d932c --- /dev/null +++ b/packages/client-encrypt/src/aes-gcm.ts @@ -0,0 +1,215 @@ +/** + * @bytelyst/client-encrypt — AES-256-GCM via Web Crypto API + * + * Works in browsers (window.crypto.subtle) and React Native (expo-crypto polyfill). + * Produces EncryptedField objects wire-compatible with: + * - @bytelyst/field-encrypt (Node.js server) + * - BLFieldEncrypt (Swift CryptoKit / Kotlin javax.crypto) + */ + +import type { EncryptedField } from './types.js'; +import { toHex, fromHex } from './hex.js'; + +const ALGORITHM = 'AES-GCM'; +const KEY_SIZE_BITS = 256; +const IV_BYTES = 12; +const TAG_BITS = 128; + +/** Get the SubtleCrypto instance (browser or globalThis). */ +function getSubtle(): SubtleCrypto { + if (typeof globalThis !== 'undefined' && globalThis.crypto?.subtle) { + return globalThis.crypto.subtle; + } + throw new Error( + '@bytelyst/client-encrypt requires Web Crypto API (SubtleCrypto). ' + + 'Use a polyfill in React Native (e.g., expo-crypto).' + ); +} + +/** Get the crypto object for random bytes. */ +function getCrypto(): Crypto { + if (typeof globalThis !== 'undefined' && globalThis.crypto) { + return globalThis.crypto; + } + throw new Error('@bytelyst/client-encrypt requires globalThis.crypto for random bytes.'); +} + +/** + * Encrypt a plaintext string with AES-256-GCM using Web Crypto API. + * + * @param plaintext - UTF-8 string to encrypt + * @param key - CryptoKey (AES-GCM, 256-bit) + * @param dekId - DEK identifier stored in the output + * @param aad - Optional additional authenticated data + * @returns EncryptedField with hex-encoded ciphertext, IV, and tag + */ +export async function encryptField( + plaintext: string, + key: CryptoKey, + dekId: string, + aad?: string +): Promise { + const subtle = getSubtle(); + const crypto = getCrypto(); + + const iv = new Uint8Array(IV_BYTES); + crypto.getRandomValues(iv); + + const encoder = new TextEncoder(); + const plaintextBytes = encoder.encode(plaintext); + + const params: AesGcmParams = { + name: ALGORITHM, + iv: iv.buffer as ArrayBuffer, + tagLength: TAG_BITS, + }; + + if (aad) { + params.additionalData = encoder.encode(aad).buffer as ArrayBuffer; + } + + // Web Crypto returns ciphertext || tag concatenated + const ciphertextWithTag = new Uint8Array( + await subtle.encrypt(params, key, plaintextBytes.buffer as ArrayBuffer) + ); + + const tagOffset = ciphertextWithTag.length - TAG_BITS / 8; + const ct = ciphertextWithTag.slice(0, tagOffset); + const tag = ciphertextWithTag.slice(tagOffset); + + return { + __encrypted: true, + v: 1, + alg: 'aes-256-gcm', + ct: toHex(ct), + iv: toHex(iv), + tag: toHex(tag), + dekId, + }; +} + +/** + * Decrypt an EncryptedField back to plaintext. + * + * @param field - EncryptedField object + * @param key - CryptoKey (must match the key used to encrypt) + * @param aad - Optional AAD (must match the AAD used during encryption) + * @returns Decrypted UTF-8 string + * @throws DOMException if authentication tag verification fails + */ +export async function decryptField( + field: EncryptedField, + key: CryptoKey, + aad?: string +): Promise { + const subtle = getSubtle(); + + const iv = fromHex(field.iv); + const ct = fromHex(field.ct); + const tag = fromHex(field.tag); + + // Web Crypto expects ciphertext || tag concatenated + const ciphertextWithTag = new Uint8Array(ct.length + tag.length); + ciphertextWithTag.set(ct, 0); + ciphertextWithTag.set(tag, ct.length); + + const params: AesGcmParams = { + name: ALGORITHM, + iv: iv.buffer as ArrayBuffer, + tagLength: TAG_BITS, + }; + + if (aad) { + params.additionalData = new TextEncoder().encode(aad).buffer as ArrayBuffer; + } + + const plaintextBytes = new Uint8Array( + await subtle.decrypt(params, key, ciphertextWithTag.buffer as ArrayBuffer) + ); + + return new TextDecoder().decode(plaintextBytes); +} + +/** + * Generate a random AES-256-GCM CryptoKey. + * + * @param extractable - Whether the key material can be exported (default: true). + * Set to `false` for non-extractable keys stored in IndexedDB. + */ +export async function generateKey(extractable = true): Promise { + const subtle = getSubtle(); + return subtle.generateKey({ name: ALGORITHM, length: KEY_SIZE_BITS }, extractable, [ + 'encrypt', + 'decrypt', + ]); +} + +/** + * Import a hex-encoded key string as a CryptoKey. + * + * @param hex - 64 hex chars = 32 bytes + * @param extractable - Whether the imported key can be exported (default: true) + */ +export async function keyFromHex(hex: string, extractable = true): Promise { + const subtle = getSubtle(); + const keyBytes = fromHex(hex); + if (keyBytes.length !== KEY_SIZE_BITS / 8) { + throw new Error(`AES-256-GCM requires a 32-byte key, got ${keyBytes.length}`); + } + return subtle.importKey( + 'raw', + keyBytes.buffer as ArrayBuffer, + { name: ALGORITHM, length: KEY_SIZE_BITS }, + extractable, + ['encrypt', 'decrypt'] + ); +} + +/** + * Export a CryptoKey to a hex-encoded string. + * Only works if the key was created with `extractable: true`. + */ +export async function keyToHex(key: CryptoKey): Promise { + const subtle = getSubtle(); + const raw = new Uint8Array(await subtle.exportKey('raw', key)); + return toHex(raw); +} + +/** + * Derive an AES-256 key from a passphrase using PBKDF2. + * + * @param passphrase - User passphrase + * @param salt - Random salt (at least 16 bytes recommended) + * @param iterations - PBKDF2 iterations (default: 600,000 per OWASP 2023) + * @param extractable - Whether derived key can be exported (default: false) + */ +export async function deriveKey( + passphrase: string, + salt: Uint8Array, + iterations = 600_000, + extractable = false +): Promise { + const subtle = getSubtle(); + const encoder = new TextEncoder(); + + const baseKey = await subtle.importKey( + 'raw', + encoder.encode(passphrase).buffer as ArrayBuffer, + 'PBKDF2', + false, + ['deriveKey'] + ); + + return subtle.deriveKey( + { + name: 'PBKDF2', + salt: salt.buffer as ArrayBuffer, + iterations, + hash: 'SHA-256', + }, + baseKey, + { name: ALGORITHM, length: KEY_SIZE_BITS }, + extractable, + ['encrypt', 'decrypt'] + ); +} diff --git a/packages/client-encrypt/src/guards.ts b/packages/client-encrypt/src/guards.ts new file mode 100644 index 00000000..7f7a5f9c --- /dev/null +++ b/packages/client-encrypt/src/guards.ts @@ -0,0 +1,22 @@ +/** + * @bytelyst/client-encrypt — Type guards + * + * Compatible with @bytelyst/field-encrypt isEncryptedField() on the server. + */ + +import type { EncryptedField } from './types.js'; + +/** Check if a value is an EncryptedField object. */ +export function isEncryptedField(value: unknown): value is EncryptedField { + if (typeof value !== 'object' || value === null) return false; + const obj = value as Record; + return ( + obj.__encrypted === true && + obj.v !== undefined && + obj.alg !== undefined && + typeof obj.ct === 'string' && + typeof obj.iv === 'string' && + typeof obj.tag === 'string' && + typeof obj.dekId === 'string' + ); +} diff --git a/packages/client-encrypt/src/hex.ts b/packages/client-encrypt/src/hex.ts new file mode 100644 index 00000000..9caf472f --- /dev/null +++ b/packages/client-encrypt/src/hex.ts @@ -0,0 +1,28 @@ +/** + * @bytelyst/client-encrypt — Hex encoding utilities + * + * Converts between Uint8Array and hex strings. + * Compatible with the hex encoding used by @bytelyst/field-encrypt (Node.js) + * and BLFieldEncrypt (Swift/Kotlin). + */ + +/** Encode a Uint8Array to a lowercase hex string. */ +export function toHex(bytes: Uint8Array): string { + const parts: string[] = new Array(bytes.length); + for (let i = 0; i < bytes.length; i++) { + parts[i] = bytes[i].toString(16).padStart(2, '0'); + } + return parts.join(''); +} + +/** Decode a hex string to a Uint8Array. */ +export function fromHex(hex: string): Uint8Array { + if (hex.length % 2 !== 0) { + throw new Error(`Hex string must have even length, got ${hex.length}`); + } + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return bytes; +} diff --git a/packages/client-encrypt/src/index.ts b/packages/client-encrypt/src/index.ts new file mode 100644 index 00000000..951c9810 --- /dev/null +++ b/packages/client-encrypt/src/index.ts @@ -0,0 +1,36 @@ +/** + * @bytelyst/client-encrypt + * + * Client-side AES-256-GCM field encryption using Web Crypto API. + * Works in browsers and React Native (with SubtleCrypto polyfill). + * Wire-compatible with @bytelyst/field-encrypt (server) and + * BLFieldEncrypt (Swift/Kotlin native SDKs). + * + * @example + * ```typescript + * import { generateKey, encryptField, decryptField } from '@bytelyst/client-encrypt'; + * + * const key = await generateKey(); + * const encrypted = await encryptField('sensitive data', key, 'dek_user1_notes'); + * const plaintext = await decryptField(encrypted, key); + * ``` + */ + +// ── Main API ──────────────────────────────────────── +export { + encryptField, + decryptField, + generateKey, + keyFromHex, + keyToHex, + deriveKey, +} from './aes-gcm.js'; + +// ── Type guards ───────────────────────────────────── +export { isEncryptedField } from './guards.js'; + +// ── Hex utilities ─────────────────────────────────── +export { toHex, fromHex } from './hex.js'; + +// ── Types ─────────────────────────────────────────── +export type { EncryptedField, ClientEncryptContext } from './types.js'; diff --git a/packages/client-encrypt/src/types.ts b/packages/client-encrypt/src/types.ts new file mode 100644 index 00000000..59ea3709 --- /dev/null +++ b/packages/client-encrypt/src/types.ts @@ -0,0 +1,33 @@ +/** + * @bytelyst/client-encrypt — Types + * + * Shared type definitions for client-side field encryption. + * Wire-compatible with @bytelyst/field-encrypt (server) and + * BLFieldEncrypt (Swift/Kotlin native SDKs). + */ + +/** Encrypted field stored in Cosmos DB or API responses. */ +export interface EncryptedField { + /** Sentinel — always true for encrypted fields. */ + readonly __encrypted: true; + /** Schema version for future algorithm changes. */ + readonly v: 1; + /** Algorithm identifier. */ + readonly alg: 'aes-256-gcm'; + /** Ciphertext (hex-encoded). */ + readonly ct: string; + /** Initialization vector (hex-encoded, 12 bytes / 24 hex chars). */ + readonly iv: string; + /** GCM authentication tag (hex-encoded, 16 bytes / 32 hex chars). */ + readonly tag: string; + /** DEK identifier — identifies which key to use for decryption. */ + readonly dekId: string; +} + +/** Options for encrypt/decrypt operations. */ +export interface ClientEncryptContext { + /** Scope for DEK isolation (typically userId). */ + readonly userId: string; + /** Additional context for DEK naming and AAD (e.g., 'transcripts', 'notes'). */ + readonly context: string; +} diff --git a/packages/client-encrypt/tsconfig.json b/packages/client-encrypt/tsconfig.json new file mode 100644 index 00000000..318c075a --- /dev/null +++ b/packages/client-encrypt/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +}