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:
parent
9bb322113a
commit
1bce981f43
@ -57,6 +57,11 @@ export default [
|
|||||||
beforeAll: 'readonly',
|
beforeAll: 'readonly',
|
||||||
afterAll: 'readonly',
|
afterAll: 'readonly',
|
||||||
crypto: 'readonly',
|
crypto: 'readonly',
|
||||||
|
CryptoKey: 'readonly',
|
||||||
|
SubtleCrypto: 'readonly',
|
||||||
|
AesGcmParams: 'readonly',
|
||||||
|
Crypto: 'readonly',
|
||||||
|
ArrayBufferView: 'readonly',
|
||||||
Blob: 'readonly',
|
Blob: 'readonly',
|
||||||
File: 'readonly',
|
File: 'readonly',
|
||||||
FormData: 'readonly',
|
FormData: 'readonly',
|
||||||
|
|||||||
23
packages/client-encrypt/package.json
Normal file
23
packages/client-encrypt/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
181
packages/client-encrypt/src/aes-gcm.test.ts
Normal file
181
packages/client-encrypt/src/aes-gcm.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
215
packages/client-encrypt/src/aes-gcm.ts
Normal file
215
packages/client-encrypt/src/aes-gcm.ts
Normal 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']
|
||||||
|
);
|
||||||
|
}
|
||||||
22
packages/client-encrypt/src/guards.ts
Normal file
22
packages/client-encrypt/src/guards.ts
Normal 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
28
packages/client-encrypt/src/hex.ts
Normal file
28
packages/client-encrypt/src/hex.ts
Normal 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;
|
||||||
|
}
|
||||||
36
packages/client-encrypt/src/index.ts
Normal file
36
packages/client-encrypt/src/index.ts
Normal 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';
|
||||||
33
packages/client-encrypt/src/types.ts
Normal file
33
packages/client-encrypt/src/types.ts
Normal 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;
|
||||||
|
}
|
||||||
10
packages/client-encrypt/tsconfig.json
Normal file
10
packages/client-encrypt/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"lib": ["ES2022", "DOM"]
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user