feat(client-encrypt): create @bytelyst/client-encrypt — Web Crypto API encryption

- 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
This commit is contained in:
saravanakumardb1 2026-03-21 11:15:27 -07:00
parent 9bb322113a
commit 1bce981f43
9 changed files with 553 additions and 0 deletions

View File

@ -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',

View File

@ -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"
}
}

View File

@ -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');
});
});

View File

@ -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<EncryptedField> {
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<string> {
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<CryptoKey> {
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<CryptoKey> {
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<string> {
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<CryptoKey> {
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']
);
}

View File

@ -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<string, unknown>;
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'
);
}

View File

@ -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;
}

View File

@ -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';

View File

@ -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;
}

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"]
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}