From e89f50f4d6969593c7f08813cf0a892bd46fb4bc Mon Sep 17 00:00:00 2001 From: Dirk Holtwick Date: Wed, 11 Dec 2024 16:51:27 +0100 Subject: [PATCH 1/5] feat: experimenting with aes-gcm --- src/common/crypto/aes-sealed.spec.ts | 58 ++++++++++++++++++++++++++++ src/common/crypto/aes-sealed.ts | 34 ++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 src/common/crypto/aes-sealed.spec.ts create mode 100644 src/common/crypto/aes-sealed.ts diff --git a/src/common/crypto/aes-sealed.spec.ts b/src/common/crypto/aes-sealed.spec.ts new file mode 100644 index 0000000..9685a3b --- /dev/null +++ b/src/common/crypto/aes-sealed.spec.ts @@ -0,0 +1,58 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { deriveKeyPbkdf2CBC } from '../crypto' +import { fromBase64 } from '../data' +import { hxDecrypt, hxEncrypt } from './aes-sealed' + +describe('aES Encryption and Decryption', () => { + let key: CryptoKey + + beforeAll(async () => { + key = await crypto.subtle.generateKey( + { + name: 'AES-GCM', + length: 256, + }, + true, + ['encrypt', 'decrypt'], + ) + }) + + it('should encrypt and decrypt data correctly', async () => { + const data = new TextEncoder().encode('Hello, World!') + const encryptedData = await hxEncrypt(data, key) + const decryptedData = await hxDecrypt(encryptedData, key) + expect(new TextDecoder().decode(decryptedData)).toBe('Hello, World!') + }) + + it('should produce different ciphertexts for the same plaintext', async () => { + const data = new TextEncoder().encode('Hello, World!') + const encryptedData1 = await hxEncrypt(data, key) + const encryptedData2 = await hxEncrypt(data, key) + expect(encryptedData1).not.toEqual(encryptedData2) + }) + + it('should fail to decrypt with a different key', async () => { + const data = new TextEncoder().encode('Hello, World!') + const encryptedData = await hxEncrypt(data, key) + const differentKey = await crypto.subtle.generateKey( + { + name: 'AES-GCM', + length: 256, + }, + true, + ['encrypt', 'decrypt'], + ) + await expect(hxDecrypt(encryptedData, differentKey)).rejects.toThrow() + }) + + it('should decrypt a sample that was generated by Swift code', async () => { + const key = await deriveKeyPbkdf2CBC(new Uint8Array([1, 2, 3]), { + salt: new Uint8Array([1, 2, 3]), + }) + // expect(toBase64(key)).toMatchInlineSnapshot() + const sample = new Uint8Array([9, 8, 7, 6, 5, 4, 3, 2, 1, 0]) + const encryptedData = fromBase64('br6sc+pnZaIXcV1fTygAs/UJlDZIIBY50i56MMGNampZTcSakt0=') + const decryptedData = await hxDecrypt(encryptedData, key) + expect(decryptedData).toMatchInlineSnapshot() + }) +}) diff --git a/src/common/crypto/aes-sealed.ts b/src/common/crypto/aes-sealed.ts new file mode 100644 index 0000000..b92df49 --- /dev/null +++ b/src/common/crypto/aes-sealed.ts @@ -0,0 +1,34 @@ +export async function hxEncrypt(data: Uint8Array, key: CryptoKey): Promise { + const iv = crypto.getRandomValues(new Uint8Array(12)) // AES-GCM requires a 12-byte IV + const encrypted = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv, + }, + key, + data, + ) + + const encryptedArray = new Uint8Array(encrypted) + const combined = new Uint8Array(iv.length + encryptedArray.length) + combined.set(iv) + combined.set(encryptedArray, iv.length) + return combined +} + +export async function hxDecrypt(data: Uint8Array, key: CryptoKey): Promise { + // The data layout of the combined representation is nonce, ciphertext, then tag. + // The nonce is 12 bytes, the tag is 16 bytes, and the ciphertext is the rest of the data. + const iv = data.slice(0, 12) // nonce is the first 12 bytes + const encrypted = data.slice(12, -16) // The ciphertext is everything between the nonce and the tag. + const tag = data.slice(-16) // The authentication tag has a length of 16 bytes. + // console.log({ iv, encrypted, tag }) + + const decrypted = await crypto.subtle.decrypt({ + name: 'AES-GCM', + iv, + tagLength: 128, + additionalData: tag, + }, key, encrypted) + return new Uint8Array(decrypted) +} From 669d6ab6f04da004cc6031146f82973f7bfedb72 Mon Sep 17 00:00:00 2001 From: Dirk Holtwick Date: Wed, 11 Dec 2024 16:51:57 +0100 Subject: [PATCH 2/5] fix: import and copilot --- .github/copilot-instructions.md | 5 +++++ src/common/data/index.ts | 1 + 2 files changed, 6 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..11e325a --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,5 @@ +Test files should always end on `.spec.ts`. + +Indentation is 2 chars. + +Prefer private variable to start with `_`. diff --git a/src/common/data/index.ts b/src/common/data/index.ts index c1b5cb1..74c76a4 100644 --- a/src/common/data/index.ts +++ b/src/common/data/index.ts @@ -28,6 +28,7 @@ export * from './signal' export * from './sortable' export * from './sorted' export * from './string-deburr' +export * from './string-hash-fnv' export * from './string-hash-pool' export * from './url' export * from './utils' From 518851e9b798837e5d6e532bda5bc7b9cfd2f39a Mon Sep 17 00:00:00 2001 From: Dirk Holtwick Date: Wed, 11 Dec 2024 16:56:35 +0100 Subject: [PATCH 3/5] feat: better crypto tests --- src/common/crypto.spec.ts | 95 ++-------------------------- src/common/crypto.ts | 32 ++++++++++ src/common/crypto/aes-sealed.spec.ts | 24 ++++--- src/common/crypto/aes-sealed.ts | 11 +++- 4 files changed, 58 insertions(+), 104 deletions(-) diff --git a/src/common/crypto.spec.ts b/src/common/crypto.spec.ts index d4b2e47..f86f440 100644 --- a/src/common/crypto.spec.ts +++ b/src/common/crypto.spec.ts @@ -3,7 +3,7 @@ import { DefaultLogger } from '.' import { decrypt, deriveKeyPbkdf2, digest, encrypt, randomUint8Array } from './crypto' -import { equalBinary, toHex } from './data/bin' +import { equalBinary, fromBase64, toBase64 } from './data/bin' const log = DefaultLogger('crypto.spec') @@ -16,54 +16,15 @@ describe('crypto', () => { let id: Uint8Array | undefined while ((id = list.pop())) { // console.log(id) - expect(equalBinary(id, new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0]))).toBe( - false, - ) + expect(equalBinary(id, new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0]))).toBe(false) expect(id?.length).toBe(8) expect(list).not.toContain(id) } }) it('should digest', async () => { - expect(toHex(await digest('abc'))).toBe( - 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', - ) - expect(await digest(new Uint8Array([1, 2, 3]))).toMatchInlineSnapshot(` -Uint8Array [ - 3, - 144, - 88, - 198, - 242, - 192, - 203, - 73, - 44, - 83, - 59, - 10, - 77, - 20, - 239, - 119, - 204, - 15, - 120, - 171, - 204, - 206, - 213, - 40, - 125, - 132, - 161, - 162, - 1, - 28, - 251, - 129, -] -`) + expect(toBase64(await digest('abc'))).toBe('ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=') + expect(toBase64(await digest(new Uint8Array([1, 2, 3])))).toMatchInlineSnapshot(`"A5BYxvLAy0ksUzsKTRTvd8wPeKvMztUofYShogEc+4E="`) }) // it("should derive key", async () => { @@ -92,56 +53,12 @@ Uint8Array [ }) const sample = new Uint8Array([9, 8, 7, 6, 5, 4, 3, 2, 1, 0]) const cipher = await encrypt(sample, key) - // log("cipher", cipher) + log('cipher', toBase64(cipher)) const bin = await decrypt(cipher, key) expect(equalBinary(sample, bin)).toBe(true) - const binFix = await decrypt( - new Uint8Array([ - 1, - 1, - 27, - 108, - 252, - 31, - 238, - 192, - 61, - 168, - 45, - 29, - 128, - 212, - 215, - 222, - 205, - 105, - 178, - 193, - 150, - 36, - 24, - 216, - 180, - 75, - 168, - 133, - 37, - 25, - 124, - 137, - 221, - 103, - 214, - 97, - 218, - 232, - 248, - 93, - ]), - key, - ) + const binFix = await decrypt(fromBase64('AQELynGCxvLXKwLM/oHjOaM4R6d7oAzxJpgpCZnKmWwhkwIDzpPMUQ=='), key) expect(binFix).toEqual(sample) }) diff --git a/src/common/crypto.ts b/src/common/crypto.ts index de386a0..40fbb7e 100644 --- a/src/common/crypto.ts +++ b/src/common/crypto.ts @@ -66,6 +66,38 @@ export async function deriveKeyPbkdf2( ) } +export async function deriveKeyPbkdf2CBC( + secret: BinInput, + opt: { + iterations?: number + salt?: BinInput + } = {}, +): Promise { + const secretBuffer = toUint8Array(secret) + const keyMaterial = await crypto.subtle.importKey( + 'raw', + secretBuffer, + CRYPTO_DEFAULT_DERIVE_ALG, + false, + ['deriveKey'], + ) + return await crypto.subtle.deriveKey( + { + name: CRYPTO_DEFAULT_DERIVE_ALG, + salt: opt.salt ? toUint8Array(opt.salt) : new Uint8Array(0), + iterations: opt.iterations ?? CRYPTO_DEFAULT_DERIVE_ITERATIONS, + hash: CRYPTO_DEFAULT_HASH_ALG, + }, + keyMaterial, + { + name: CRYPTO_DEFAULT_ALG, + length: 256, + }, + true, + ['encrypt', 'decrypt'], + ) +} + function getMagicId() { return new Uint8Array([1, 1]) } diff --git a/src/common/crypto/aes-sealed.spec.ts b/src/common/crypto/aes-sealed.spec.ts index 9685a3b..6034ce8 100644 --- a/src/common/crypto/aes-sealed.spec.ts +++ b/src/common/crypto/aes-sealed.spec.ts @@ -1,9 +1,7 @@ import { beforeAll, describe, expect, it } from 'vitest' -import { deriveKeyPbkdf2CBC } from '../crypto' -import { fromBase64 } from '../data' import { hxDecrypt, hxEncrypt } from './aes-sealed' -describe('aES Encryption and Decryption', () => { +describe('aes Encryption and Decryption', () => { let key: CryptoKey beforeAll(async () => { @@ -45,14 +43,14 @@ describe('aES Encryption and Decryption', () => { await expect(hxDecrypt(encryptedData, differentKey)).rejects.toThrow() }) - it('should decrypt a sample that was generated by Swift code', async () => { - const key = await deriveKeyPbkdf2CBC(new Uint8Array([1, 2, 3]), { - salt: new Uint8Array([1, 2, 3]), - }) - // expect(toBase64(key)).toMatchInlineSnapshot() - const sample = new Uint8Array([9, 8, 7, 6, 5, 4, 3, 2, 1, 0]) - const encryptedData = fromBase64('br6sc+pnZaIXcV1fTygAs/UJlDZIIBY50i56MMGNampZTcSakt0=') - const decryptedData = await hxDecrypt(encryptedData, key) - expect(decryptedData).toMatchInlineSnapshot() - }) + // it('should decrypt a sample that was generated by Swift code', async () => { + // const key = await deriveKeyPbkdf2CBC(new Uint8Array([1, 2, 3]), { + // salt: new Uint8Array([1, 2, 3]), + // }) + // // expect(toBase64(key)).toMatchInlineSnapshot() + // const sample = new Uint8Array([9, 8, 7, 6, 5, 4, 3, 2, 1, 0]) + // const encryptedData = fromBase64('br6sc+pnZaIXcV1fTygAs/UJlDZIIBY50i56MMGNampZTcSakt0=') + // const decryptedData = await hxDecrypt(encryptedData, key) + // expect(decryptedData).toMatchInlineSnapshot() + // }) }) diff --git a/src/common/crypto/aes-sealed.ts b/src/common/crypto/aes-sealed.ts index b92df49..fae29b1 100644 --- a/src/common/crypto/aes-sealed.ts +++ b/src/common/crypto/aes-sealed.ts @@ -1,18 +1,25 @@ -export async function hxEncrypt(data: Uint8Array, key: CryptoKey): Promise { +export async function hxEncrypt(data: Uint8Array, key: CryptoKey, tag?: Uint8Array): Promise { const iv = crypto.getRandomValues(new Uint8Array(12)) // AES-GCM requires a 12-byte IV + if (!tag) { + tag = crypto.getRandomValues(new Uint8Array(16)) + } + const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv, + tagLength: 128, + additionalData: tag, }, key, data, ) const encryptedArray = new Uint8Array(encrypted) - const combined = new Uint8Array(iv.length + encryptedArray.length) + const combined = new Uint8Array(iv.length + encryptedArray.length + tag.length) combined.set(iv) combined.set(encryptedArray, iv.length) + combined.set(tag, encryptedArray.length + iv.length) return combined } From 7d8169a2e16ea7cc6b69571af445dd00c9249baf Mon Sep 17 00:00:00 2001 From: Dirk Holtwick Date: Wed, 11 Dec 2024 16:56:52 +0100 Subject: [PATCH 4/5] chore: update devDependencies for @types/node, playwright, and vite --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 63c624b..add57d5 100644 --- a/package.json +++ b/package.json @@ -71,16 +71,16 @@ "devDependencies": { "@antfu/eslint-config": "^3.11", "@antfu/ni": "^0.23.1", - "@types/node": "^22.10.1", + "@types/node": "^22.10.2", "@vitejs/plugin-vue": "^5.2.1", "@vitest/browser": "^2.1.8", "@vitest/coverage-v8": "^2.1.8", "esbuild": "^0.24.0", "eslint": "^9.16.0", - "playwright": "^1.49.0", + "playwright": "^1.49.1", "tsup": "^8.3.5", "typescript": "^5.7.2", - "vite": "^6.0.2", + "vite": "^6.0.3", "vitest": "^2.1.8" }, "pnpm": { From 554591475cc27f92b94d9f094dc97de75d729bed Mon Sep 17 00:00:00 2001 From: Dirk Holtwick Date: Wed, 11 Dec 2024 16:57:18 +0100 Subject: [PATCH 5/5] 0.26.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index add57d5..2056805 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zeed", "type": "module", - "version": "0.26.2", + "version": "0.26.3", "description": "🌱 Simple foundation library", "author": { "name": "Dirk Holtwick",