From 47361a83b9e519ec1046827e995a2b02e5dcee30 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 20 Oct 2026 18:31:33 +0200 Subject: [PATCH] add optional Argon2id password wrap for zk uploads. Wrap the image DEK for password-protected batches, keep the URL fragment as a separate secret, and extend prepare/decrypt helpers with tests. --- frontend/package-lock.json | 7 +++ frontend/package.json | 1 + .../src/types/upload/EncryptedZkUpload.ts | 2 +- frontend/src/types/upload/WrappedZkDek.ts | 5 ++ .../types/upload/ZkUploadPrepareOptions.ts | 5 ++ .../utils/crypto/deriveZkWrapKeyBytes.spec.ts | 31 +++++++++++ .../src/utils/crypto/deriveZkWrapKeyBytes.ts | 17 +++++++ .../utils/crypto/hashPasswordArgon2id.spec.ts | 30 +++++++++++ .../src/utils/crypto/hashPasswordArgon2id.ts | 20 ++++++++ .../decryptPasswordProtectedZkUpload.spec.ts | 47 +++++++++++++++++ .../decryptPasswordProtectedZkUpload.ts | 20 ++++++++ .../src/utils/upload/decryptZkUpload.spec.ts | 3 +- .../src/utils/upload/encryptZkUpload.spec.ts | 8 +-- frontend/src/utils/upload/encryptZkUpload.ts | 7 +-- .../upload/prepareZkUploadRequest.spec.ts | 51 +++++++++++++++++++ .../utils/upload/prepareZkUploadRequest.ts | 39 +++++++++++--- frontend/src/utils/upload/unwrapZkDek.spec.ts | 17 +++++++ frontend/src/utils/upload/unwrapZkDek.ts | 16 ++++++ frontend/src/utils/upload/wrapZkDek.spec.ts | 33 ++++++++++++ frontend/src/utils/upload/wrapZkDek.ts | 21 ++++++++ 20 files changed, 360 insertions(+), 20 deletions(-) create mode 100644 frontend/src/types/upload/WrappedZkDek.ts create mode 100644 frontend/src/types/upload/ZkUploadPrepareOptions.ts create mode 100644 frontend/src/utils/crypto/deriveZkWrapKeyBytes.spec.ts create mode 100644 frontend/src/utils/crypto/deriveZkWrapKeyBytes.ts create mode 100644 frontend/src/utils/crypto/hashPasswordArgon2id.spec.ts create mode 100644 frontend/src/utils/crypto/hashPasswordArgon2id.ts create mode 100644 frontend/src/utils/upload/decryptPasswordProtectedZkUpload.spec.ts create mode 100644 frontend/src/utils/upload/decryptPasswordProtectedZkUpload.ts create mode 100644 frontend/src/utils/upload/unwrapZkDek.spec.ts create mode 100644 frontend/src/utils/upload/unwrapZkDek.ts create mode 100644 frontend/src/utils/upload/wrapZkDek.spec.ts create mode 100644 frontend/src/utils/upload/wrapZkDek.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c571614..ca7d2ae 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@tailwindcss/vite": "^4.3.3", "@uswriting/exiftool": "1.0.9", "axios": "^1.20.0", + "hash-wasm": "^4.12.0", "pinia": "^4.0.3", "pretty-bytes": "^7.2.0", "tailwindcss": "^4.3.3", @@ -2513,6 +2514,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash-wasm": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.12.0.tgz", + "integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==", + "license": "MIT" + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 86bbf3e..172b509 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@tailwindcss/vite": "^4.3.3", "@uswriting/exiftool": "1.0.9", "axios": "^1.20.0", + "hash-wasm": "^4.12.0", "pinia": "^4.0.3", "pretty-bytes": "^7.2.0", "tailwindcss": "^4.3.3", diff --git a/frontend/src/types/upload/EncryptedZkUpload.ts b/frontend/src/types/upload/EncryptedZkUpload.ts index a128d3a..1e0b892 100644 --- a/frontend/src/types/upload/EncryptedZkUpload.ts +++ b/frontend/src/types/upload/EncryptedZkUpload.ts @@ -1,5 +1,5 @@ export type EncryptedZkUpload = { - fragmentKey: string; + dek: Uint8Array; iv: Uint8Array; ciphertext: Uint8Array; }; diff --git a/frontend/src/types/upload/WrappedZkDek.ts b/frontend/src/types/upload/WrappedZkDek.ts new file mode 100644 index 0000000..550bc96 --- /dev/null +++ b/frontend/src/types/upload/WrappedZkDek.ts @@ -0,0 +1,5 @@ +export type WrappedZkDek = { + wrappedDek: Uint8Array; + wrapIv: Uint8Array; + salt: Uint8Array; +}; diff --git a/frontend/src/types/upload/ZkUploadPrepareOptions.ts b/frontend/src/types/upload/ZkUploadPrepareOptions.ts new file mode 100644 index 0000000..ee90188 --- /dev/null +++ b/frontend/src/types/upload/ZkUploadPrepareOptions.ts @@ -0,0 +1,5 @@ +import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest'; + +export type ZkUploadPrepareOptions = + | (UploadOptionsRequest & { passwordProtected: false }) + | (UploadOptionsRequest & { passwordProtected: true; password: string }); diff --git a/frontend/src/utils/crypto/deriveZkWrapKeyBytes.spec.ts b/frontend/src/utils/crypto/deriveZkWrapKeyBytes.spec.ts new file mode 100644 index 0000000..07fdf4a --- /dev/null +++ b/frontend/src/utils/crypto/deriveZkWrapKeyBytes.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { deriveZkWrapKeyBytes } from '@/utils/crypto/deriveZkWrapKeyBytes'; +import { generateDek } from '@/utils/crypto/generateDek'; + +describe('deriveZkWrapKeyBytes', () => { + const salt = new Uint8Array([8, 7, 6, 5, 4, 3, 2, 1, 0, 9, 8, 7, 6, 5, 4, 3]); + const fragmentSecret = generateDek(); + const password = 'wrap-password'; + + it('returns a 32-byte wrap key', async () => { + const wrapKey = await deriveZkWrapKeyBytes(password, salt, fragmentSecret); + + expect(wrapKey.byteLength).toBe(32); + }); + + it('is deterministic for the same inputs', async () => { + const first = await deriveZkWrapKeyBytes(password, salt, fragmentSecret); + const second = await deriveZkWrapKeyBytes(password, salt, fragmentSecret); + + expect(first).toEqual(second); + }); + + it('binds the URL fragment secret into the wrap key', async () => { + const otherFragment = generateDek(); + + const wrapKeyA = await deriveZkWrapKeyBytes(password, salt, fragmentSecret); + const wrapKeyB = await deriveZkWrapKeyBytes(password, salt, otherFragment); + + expect(wrapKeyA).not.toEqual(wrapKeyB); + }); +}); diff --git a/frontend/src/utils/crypto/deriveZkWrapKeyBytes.ts b/frontend/src/utils/crypto/deriveZkWrapKeyBytes.ts new file mode 100644 index 0000000..4ce9520 --- /dev/null +++ b/frontend/src/utils/crypto/deriveZkWrapKeyBytes.ts @@ -0,0 +1,17 @@ +import { hashPasswordArgon2id } from '@/utils/crypto/hashPasswordArgon2id'; +import { toArrayBuffer } from '@/utils/toArrayBuffer'; + +export const deriveZkWrapKeyBytes = async ( + password: string, + salt: Uint8Array, + fragmentSecret: Uint8Array +): Promise => { + const kek = await hashPasswordArgon2id(password, salt); + const combined = new Uint8Array(kek.length + fragmentSecret.length); + combined.set(kek, 0); + combined.set(fragmentSecret, kek.length); + + const digest = await crypto.subtle.digest('SHA-256', toArrayBuffer(combined)); + + return new Uint8Array(digest); +}; diff --git a/frontend/src/utils/crypto/hashPasswordArgon2id.spec.ts b/frontend/src/utils/crypto/hashPasswordArgon2id.spec.ts new file mode 100644 index 0000000..06fd45a --- /dev/null +++ b/frontend/src/utils/crypto/hashPasswordArgon2id.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { hashPasswordArgon2id } from '@/utils/crypto/hashPasswordArgon2id'; + +describe('hashPasswordArgon2id', () => { + const salt = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); + + it('returns a 32-byte hash', async () => { + const hash = await hashPasswordArgon2id('test-password', salt); + + expect(hash).toBeInstanceOf(Uint8Array); + expect(hash.byteLength).toBe(32); + }); + + it('is deterministic for the same password and salt', async () => { + const first = await hashPasswordArgon2id('same', salt); + const second = await hashPasswordArgon2id('same', salt); + + expect(first).toEqual(second); + }); + + it('changes when the salt changes', async () => { + const otherSalt = new Uint8Array(salt); + otherSalt[0] ^= 0xff; + + const hashA = await hashPasswordArgon2id('same', salt); + const hashB = await hashPasswordArgon2id('same', otherSalt); + + expect(hashA).not.toEqual(hashB); + }); +}); diff --git a/frontend/src/utils/crypto/hashPasswordArgon2id.ts b/frontend/src/utils/crypto/hashPasswordArgon2id.ts new file mode 100644 index 0000000..6c029c5 --- /dev/null +++ b/frontend/src/utils/crypto/hashPasswordArgon2id.ts @@ -0,0 +1,20 @@ +import { argon2id } from 'hash-wasm'; + +export const hashPasswordArgon2id = async (password: string, salt: Uint8Array): Promise => { + const iterations = 3; + const memorySizeKib = 65536; + const parallelism = 1; + const hashByteLength = 32; + + const hash = await argon2id({ + password, + salt, + parallelism, + iterations, + memorySize: memorySizeKib, + hashLength: hashByteLength, + outputType: 'binary' + }); + + return new Uint8Array(hash); +}; diff --git a/frontend/src/utils/upload/decryptPasswordProtectedZkUpload.spec.ts b/frontend/src/utils/upload/decryptPasswordProtectedZkUpload.spec.ts new file mode 100644 index 0000000..5eede08 --- /dev/null +++ b/frontend/src/utils/upload/decryptPasswordProtectedZkUpload.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { base64ToBytes } from '@/utils/encoding/base64ToBytes'; +import { decryptPasswordProtectedZkUpload } from '@/utils/upload/decryptPasswordProtectedZkUpload'; +import { prepareZkUploadRequest } from '@/utils/upload/prepareZkUploadRequest'; + +describe('decryptPasswordProtectedZkUpload', () => { + const passwordOptions = { + expiresAt: null, + maxViews: null, + passwordProtected: true as const, + password: 'viewer-secret' + }; + + it('decrypts ciphertext when fragment key and password match upload', async () => { + const plaintext = new TextEncoder().encode('password-protected image'); + const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, passwordOptions); + + const recovered = await decryptPasswordProtectedZkUpload( + base64ToBytes(request.ciphertext), + base64ToBytes(request.iv), + fragmentKey, + passwordOptions.password, + base64ToBytes(request.wrappedDek!), + base64ToBytes(request.wrapIv!), + base64ToBytes(request.salt!) + ); + + expect(recovered).toEqual(plaintext); + }); + + it('rejects decryption with the wrong password', async () => { + const plaintext = new Uint8Array([42]); + const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, passwordOptions); + + await expect( + decryptPasswordProtectedZkUpload( + base64ToBytes(request.ciphertext), + base64ToBytes(request.iv), + fragmentKey, + 'wrong-password', + base64ToBytes(request.wrappedDek!), + base64ToBytes(request.wrapIv!), + base64ToBytes(request.salt!) + ) + ).rejects.toThrow(); + }); +}); diff --git a/frontend/src/utils/upload/decryptPasswordProtectedZkUpload.ts b/frontend/src/utils/upload/decryptPasswordProtectedZkUpload.ts new file mode 100644 index 0000000..09ebbf8 --- /dev/null +++ b/frontend/src/utils/upload/decryptPasswordProtectedZkUpload.ts @@ -0,0 +1,20 @@ +import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm'; +import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey'; +import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes'; +import { unwrapZkDek } from '@/utils/upload/unwrapZkDek'; + +export const decryptPasswordProtectedZkUpload = async ( + ciphertext: Uint8Array, + iv: Uint8Array, + fragmentKey: string, + password: string, + wrappedDek: Uint8Array, + wrapIv: Uint8Array, + salt: Uint8Array +): Promise => { + const fragmentSecret = base64UrlToBytes(fragmentKey); + const dek = await unwrapZkDek(wrappedDek, wrapIv, salt, password, fragmentSecret); + const key = await importAesGcmKey(dek); + + return decryptAesGcm(ciphertext, key, iv); +}; diff --git a/frontend/src/utils/upload/decryptZkUpload.spec.ts b/frontend/src/utils/upload/decryptZkUpload.spec.ts index e164164..0e46d83 100644 --- a/frontend/src/utils/upload/decryptZkUpload.spec.ts +++ b/frontend/src/utils/upload/decryptZkUpload.spec.ts @@ -7,7 +7,8 @@ import { encryptZkUpload } from '@/utils/upload/encryptZkUpload'; describe('decryptZkUpload', () => { it('decrypts using the URL fragment key (base64url-encoded DEK)', async () => { const plaintext = new TextEncoder().encode('viewer-payload'); - const { ciphertext, iv, fragmentKey } = await encryptZkUpload(plaintext); + const { dek, ciphertext, iv } = await encryptZkUpload(plaintext); + const fragmentKey = bytesToBase64Url(dek); const recovered = await decryptZkUpload(ciphertext, iv, fragmentKey); diff --git a/frontend/src/utils/upload/encryptZkUpload.spec.ts b/frontend/src/utils/upload/encryptZkUpload.spec.ts index 2e91404..8f6f429 100644 --- a/frontend/src/utils/upload/encryptZkUpload.spec.ts +++ b/frontend/src/utils/upload/encryptZkUpload.spec.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from 'vitest'; +import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength'; import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength'; -import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes'; import { bytesToBase64 } from '@/utils/encoding/bytesToBase64'; import { encryptZkUpload } from '@/utils/upload/encryptZkUpload'; describe('encryptZkUpload', () => { - it('exposes fragmentKey as base64url-encoded DEK', async () => { + it('returns a random DEK and IV', async () => { const encrypted = await encryptZkUpload(new Uint8Array([1])); - expect(encrypted.fragmentKey).toMatch(/^[A-Za-z0-9_-]+$/); - expect(base64UrlToBytes(encrypted.fragmentKey)).toHaveLength(AES_GCM_KEY_BYTE_LENGTH); + expect(encrypted.dek).toHaveLength(AES_GCM_KEY_BYTE_LENGTH); + expect(encrypted.iv).toHaveLength(AES_GCM_IV_BYTE_LENGTH); }); it('serializes iv and ciphertext for the upload API', async () => { diff --git a/frontend/src/utils/upload/encryptZkUpload.ts b/frontend/src/utils/upload/encryptZkUpload.ts index 637a324..798591a 100644 --- a/frontend/src/utils/upload/encryptZkUpload.ts +++ b/frontend/src/utils/upload/encryptZkUpload.ts @@ -3,7 +3,6 @@ import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm'; import { generateDek } from '@/utils/crypto/generateDek'; import { generateIv } from '@/utils/crypto/generateIv'; import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey'; -import { bytesToBase64Url } from '@/utils/encoding/bytesToBase64Url'; export const encryptZkUpload = async (plaintext: Uint8Array): Promise => { const dek = generateDek(); @@ -11,9 +10,5 @@ export const encryptZkUpload = async (plaintext: Uint8Array): Promise { expect(recovered).toEqual(plaintext); }); + + describe('password protected', () => { + const passwordOptions = { + expiresAt: null, + maxViews: null, + passwordProtected: true as const, + password: 'batch-secret' + }; + + it('includes wrap fields and omits password from the POST body', async () => { + const { request, fragmentKey } = await prepareZkUploadRequest(new Uint8Array([1]), passwordOptions); + + expect(request.passwordProtected).toBe(true); + expect(request.wrappedDek).toMatch(/^[A-Za-z0-9+/]+=*$/); + expect(request.wrapIv).toMatch(/^[A-Za-z0-9+/]+=*$/); + expect(request.salt).toMatch(/^[A-Za-z0-9+/]+=*$/); + expect(request).not.toHaveProperty('password'); + expect(fragmentKey).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('does not decrypt with URL fragment alone', async () => { + const plaintext = new TextEncoder().encode('protected'); + const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, passwordOptions); + + await expect( + decryptZkUpload( + base64ToBytes(request.ciphertext), + base64ToBytes(request.iv), + fragmentKey + ) + ).rejects.toThrow(); + }); + + it('roundtrips with fragment key and password', async () => { + const plaintext = new TextEncoder().encode('protected-payload'); + const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, passwordOptions); + + const recovered = await decryptPasswordProtectedZkUpload( + base64ToBytes(request.ciphertext), + base64ToBytes(request.iv), + fragmentKey, + passwordOptions.password, + base64ToBytes(request.wrappedDek!), + base64ToBytes(request.wrapIv!), + base64ToBytes(request.salt!) + ); + + expect(recovered).toEqual(plaintext); + }); + }); }); diff --git a/frontend/src/utils/upload/prepareZkUploadRequest.ts b/frontend/src/utils/upload/prepareZkUploadRequest.ts index ee09be7..fe834d2 100644 --- a/frontend/src/utils/upload/prepareZkUploadRequest.ts +++ b/frontend/src/utils/upload/prepareZkUploadRequest.ts @@ -1,21 +1,44 @@ import type { CreateZkUploadRequest } from '@/types/upload/CreateZkUploadRequest'; -import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest'; +import type { ZkUploadPrepareOptions } from '@/types/upload/ZkUploadPrepareOptions'; import { bytesToBase64 } from '@/utils/encoding/bytesToBase64'; +import { bytesToBase64Url } from '@/utils/encoding/bytesToBase64Url'; +import { generateDek } from '@/utils/crypto/generateDek'; import { encryptZkUpload } from '@/utils/upload/encryptZkUpload'; +import { wrapZkDek } from '@/utils/upload/wrapZkDek'; export const prepareZkUploadRequest = async ( plaintext: Uint8Array, - options: UploadOptionsRequest & { passwordProtected: false } + options: ZkUploadPrepareOptions ): Promise<{ request: CreateZkUploadRequest; fragmentKey: string }> => { - const encrypted = await encryptZkUpload(plaintext); + if (!options.passwordProtected) { + const { dek, iv, ciphertext } = await encryptZkUpload(plaintext); + + return { + fragmentKey: bytesToBase64Url(dek), + request: { + ...options, + iv: bytesToBase64(iv), + ciphertext: bytesToBase64(ciphertext), + passwordProtected: false + } + }; + } + + const { password, ...uploadOptions } = options; + const { dek, iv, ciphertext } = await encryptZkUpload(plaintext); + const fragmentSecret = generateDek(); + const { wrappedDek, wrapIv, salt } = await wrapZkDek(dek, password, fragmentSecret); return { - fragmentKey: encrypted.fragmentKey, + fragmentKey: bytesToBase64Url(fragmentSecret), request: { - ...options, - iv: bytesToBase64(encrypted.iv), - ciphertext: bytesToBase64(encrypted.ciphertext), - passwordProtected: false + ...uploadOptions, + iv: bytesToBase64(iv), + ciphertext: bytesToBase64(ciphertext), + passwordProtected: true, + wrappedDek: bytesToBase64(wrappedDek), + wrapIv: bytesToBase64(wrapIv), + salt: bytesToBase64(salt) } }; }; diff --git a/frontend/src/utils/upload/unwrapZkDek.spec.ts b/frontend/src/utils/upload/unwrapZkDek.spec.ts new file mode 100644 index 0000000..b6c09f3 --- /dev/null +++ b/frontend/src/utils/upload/unwrapZkDek.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { generateDek } from '@/utils/crypto/generateDek'; +import { unwrapZkDek } from '@/utils/upload/unwrapZkDek'; +import { wrapZkDek } from '@/utils/upload/wrapZkDek'; + +describe('unwrapZkDek', () => { + it('fails unwrap with a mismatched fragment secret', async () => { + const dek = generateDek(); + const fragmentSecret = generateDek(); + const wrapped = await wrapZkDek(dek, 'password', fragmentSecret); + const otherFragment = generateDek(); + + await expect( + unwrapZkDek(wrapped.wrappedDek, wrapped.wrapIv, wrapped.salt, 'password', otherFragment) + ).rejects.toThrow(); + }); +}); diff --git a/frontend/src/utils/upload/unwrapZkDek.ts b/frontend/src/utils/upload/unwrapZkDek.ts new file mode 100644 index 0000000..df6312e --- /dev/null +++ b/frontend/src/utils/upload/unwrapZkDek.ts @@ -0,0 +1,16 @@ +import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm'; +import { deriveZkWrapKeyBytes } from '@/utils/crypto/deriveZkWrapKeyBytes'; +import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey'; + +export const unwrapZkDek = async ( + wrappedDek: Uint8Array, + wrapIv: Uint8Array, + salt: Uint8Array, + password: string, + fragmentSecret: Uint8Array +): Promise => { + const wrapKeyBytes = await deriveZkWrapKeyBytes(password, salt, fragmentSecret); + const wrapKey = await importAesGcmKey(wrapKeyBytes); + + return decryptAesGcm(wrappedDek, wrapKey, wrapIv); +}; diff --git a/frontend/src/utils/upload/wrapZkDek.spec.ts b/frontend/src/utils/upload/wrapZkDek.spec.ts new file mode 100644 index 0000000..484d4f1 --- /dev/null +++ b/frontend/src/utils/upload/wrapZkDek.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { generateDek } from '@/utils/crypto/generateDek'; +import { unwrapZkDek } from '@/utils/upload/unwrapZkDek'; +import { wrapZkDek } from '@/utils/upload/wrapZkDek'; + +describe('wrapZkDek', () => { + it('roundtrips the DEK with password and fragment secret', async () => { + const dek = generateDek(); + const fragmentSecret = generateDek(); + const password = 'viewer-password'; + + const wrapped = await wrapZkDek(dek, password, fragmentSecret); + const recovered = await unwrapZkDek( + wrapped.wrappedDek, + wrapped.wrapIv, + wrapped.salt, + password, + fragmentSecret + ); + + expect(recovered).toEqual(dek); + }); + + it('fails unwrap with wrong password', async () => { + const dek = generateDek(); + const fragmentSecret = generateDek(); + const wrapped = await wrapZkDek(dek, 'correct', fragmentSecret); + + await expect( + unwrapZkDek(wrapped.wrappedDek, wrapped.wrapIv, wrapped.salt, 'wrong', fragmentSecret) + ).rejects.toThrow(); + }); +}); diff --git a/frontend/src/utils/upload/wrapZkDek.ts b/frontend/src/utils/upload/wrapZkDek.ts new file mode 100644 index 0000000..a373d9d --- /dev/null +++ b/frontend/src/utils/upload/wrapZkDek.ts @@ -0,0 +1,21 @@ +import type { WrappedZkDek } from '@/types/upload/WrappedZkDek'; +import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm'; +import { deriveZkWrapKeyBytes } from '@/utils/crypto/deriveZkWrapKeyBytes'; +import { generateIv } from '@/utils/crypto/generateIv'; +import { generateRandomBytes } from '@/utils/crypto/generateRandomBytes'; +import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey'; + +export const wrapZkDek = async ( + dek: Uint8Array, + password: string, + fragmentSecret: Uint8Array +): Promise => { + const saltByteLength = 16; + const salt = generateRandomBytes(saltByteLength); + const wrapKeyBytes = await deriveZkWrapKeyBytes(password, salt, fragmentSecret); + const wrapIv = generateIv(); + const wrapKey = await importAesGcmKey(wrapKeyBytes); + const wrappedDek = await encryptAesGcm(dek, wrapKey, wrapIv); + + return { wrappedDek, wrapIv, salt }; +};