learning_ai_common_plat/packages/client-encrypt/src/aes-gcm.ts
saravanakumardb1 1bce981f43 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
2026-03-21 11:15:27 -07:00

216 lines
6.0 KiB
TypeScript

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