- 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
216 lines
6.0 KiB
TypeScript
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']
|
|
);
|
|
}
|