From afbd5e204cbe219b7c20d4c73ba46d138719e7bf Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Mon, 19 Oct 2026 23:05:33 +0200 Subject: [PATCH] Add client-side ZK and hotlink upload crypto with Pinia store. Encrypt image bytes in the browser, keep keys out of POST bodies (URL fragment or path secret), and POST ciphertext via typed upload API helpers. --- frontend/src/consts/altchaRequestHeader.ts | 1 + frontend/src/stores/upload.ts | 34 +++++++++++ .../upload/CreateHotlinkUploadRequest.ts | 7 +++ .../src/types/upload/CreateZkUploadRequest.ts | 10 +++ .../src/types/upload/CreatedUploadResult.ts | 7 +++ .../types/upload/EncryptedHotlinkUpload.ts | 5 ++ .../src/types/upload/EncryptedZkUpload.ts | 5 ++ frontend/src/types/upload/UploadMode.ts | 1 + .../src/types/upload/UploadOptionsRequest.ts | 4 ++ .../src/utils/share/buildHotlinkImagePath.ts | 1 + frontend/src/utils/share/buildZkViewerPath.ts | 1 + .../src/utils/upload/decryptZkUpload.spec.ts | 23 +++++++ frontend/src/utils/upload/decryptZkUpload.ts | 14 +++++ .../utils/upload/encryptHotlinkUpload.spec.ts | 34 +++++++++++ .../src/utils/upload/encryptHotlinkUpload.ts | 20 ++++++ .../src/utils/upload/encryptZkUpload.spec.ts | 21 +++++++ frontend/src/utils/upload/encryptZkUpload.ts | 19 ++++++ .../prepareHotlinkUploadRequest.spec.ts | 59 ++++++++++++++++++ .../upload/prepareHotlinkUploadRequest.ts | 22 +++++++ .../upload/prepareZkUploadRequest.spec.ts | 61 +++++++++++++++++++ .../utils/upload/prepareZkUploadRequest.ts | 21 +++++++ 21 files changed, 370 insertions(+) create mode 100644 frontend/src/consts/altchaRequestHeader.ts create mode 100644 frontend/src/stores/upload.ts create mode 100644 frontend/src/types/upload/CreateHotlinkUploadRequest.ts create mode 100644 frontend/src/types/upload/CreateZkUploadRequest.ts create mode 100644 frontend/src/types/upload/CreatedUploadResult.ts create mode 100644 frontend/src/types/upload/EncryptedHotlinkUpload.ts create mode 100644 frontend/src/types/upload/EncryptedZkUpload.ts create mode 100644 frontend/src/types/upload/UploadMode.ts create mode 100644 frontend/src/types/upload/UploadOptionsRequest.ts create mode 100644 frontend/src/utils/share/buildHotlinkImagePath.ts create mode 100644 frontend/src/utils/share/buildZkViewerPath.ts create mode 100644 frontend/src/utils/upload/decryptZkUpload.spec.ts create mode 100644 frontend/src/utils/upload/decryptZkUpload.ts create mode 100644 frontend/src/utils/upload/encryptHotlinkUpload.spec.ts create mode 100644 frontend/src/utils/upload/encryptHotlinkUpload.ts create mode 100644 frontend/src/utils/upload/encryptZkUpload.spec.ts create mode 100644 frontend/src/utils/upload/encryptZkUpload.ts create mode 100644 frontend/src/utils/upload/prepareHotlinkUploadRequest.spec.ts create mode 100644 frontend/src/utils/upload/prepareHotlinkUploadRequest.ts create mode 100644 frontend/src/utils/upload/prepareZkUploadRequest.spec.ts create mode 100644 frontend/src/utils/upload/prepareZkUploadRequest.ts diff --git a/frontend/src/consts/altchaRequestHeader.ts b/frontend/src/consts/altchaRequestHeader.ts new file mode 100644 index 0000000..1dbde6e --- /dev/null +++ b/frontend/src/consts/altchaRequestHeader.ts @@ -0,0 +1 @@ +export const ALTCHA_REQUEST_HEADER = 'x-altcha'; diff --git a/frontend/src/stores/upload.ts b/frontend/src/stores/upload.ts new file mode 100644 index 0000000..977f66a --- /dev/null +++ b/frontend/src/stores/upload.ts @@ -0,0 +1,34 @@ +import { defineStore } from 'pinia'; +import { ALTCHA_REQUEST_HEADER } from '@/consts/altchaRequestHeader'; +import { api } from '@/plugins/axios'; +import type { CreateHotlinkUploadRequest } from '@/types/upload/CreateHotlinkUploadRequest'; +import type { CreateZkUploadRequest } from '@/types/upload/CreateZkUploadRequest'; +import type { CreatedUploadResult } from '@/types/upload/CreatedUploadResult'; + +export const useUploadStore = defineStore('upload', () => { + const createZkUpload = async (body: CreateZkUploadRequest, altcha?: string): Promise => { + const { data } = await api.post('/uploads/zk', body, { + headers: getUploadRequestHeaders(altcha) + }); + + return data; + }; + + const createHotlinkUpload = async ( + body: CreateHotlinkUploadRequest, + altcha?: string + ): Promise => { + const { data } = await api.post('/uploads/hotlink', body, { + headers: getUploadRequestHeaders(altcha) + }); + + return data; + }; + + const getUploadRequestHeaders = (altcha?: string) => (altcha ? { [ALTCHA_REQUEST_HEADER]: altcha } : undefined); + + return { + createZkUpload, + createHotlinkUpload + }; +}); diff --git a/frontend/src/types/upload/CreateHotlinkUploadRequest.ts b/frontend/src/types/upload/CreateHotlinkUploadRequest.ts new file mode 100644 index 0000000..c53a50e --- /dev/null +++ b/frontend/src/types/upload/CreateHotlinkUploadRequest.ts @@ -0,0 +1,7 @@ +import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest'; + +export type CreateHotlinkUploadRequest = UploadOptionsRequest & { + iv: string; + ciphertext: string; + mimeType: string; +}; diff --git a/frontend/src/types/upload/CreateZkUploadRequest.ts b/frontend/src/types/upload/CreateZkUploadRequest.ts new file mode 100644 index 0000000..2057623 --- /dev/null +++ b/frontend/src/types/upload/CreateZkUploadRequest.ts @@ -0,0 +1,10 @@ +import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest'; + +export type CreateZkUploadRequest = UploadOptionsRequest & { + iv: string; + ciphertext: string; + passwordProtected: boolean; + wrappedDek?: string; + wrapIv?: string; + salt?: string; +}; diff --git a/frontend/src/types/upload/CreatedUploadResult.ts b/frontend/src/types/upload/CreatedUploadResult.ts new file mode 100644 index 0000000..e8ef58f --- /dev/null +++ b/frontend/src/types/upload/CreatedUploadResult.ts @@ -0,0 +1,7 @@ +import type { UploadMode } from '@/types/upload/UploadMode'; + +export type CreatedUploadResult = { + id: string; + mode: UploadMode; + deleteToken: string; +}; diff --git a/frontend/src/types/upload/EncryptedHotlinkUpload.ts b/frontend/src/types/upload/EncryptedHotlinkUpload.ts new file mode 100644 index 0000000..90a1e3e --- /dev/null +++ b/frontend/src/types/upload/EncryptedHotlinkUpload.ts @@ -0,0 +1,5 @@ +export type EncryptedHotlinkUpload = { + secret: string; + iv: Uint8Array; + ciphertext: Uint8Array; +}; diff --git a/frontend/src/types/upload/EncryptedZkUpload.ts b/frontend/src/types/upload/EncryptedZkUpload.ts new file mode 100644 index 0000000..a128d3a --- /dev/null +++ b/frontend/src/types/upload/EncryptedZkUpload.ts @@ -0,0 +1,5 @@ +export type EncryptedZkUpload = { + fragmentKey: string; + iv: Uint8Array; + ciphertext: Uint8Array; +}; diff --git a/frontend/src/types/upload/UploadMode.ts b/frontend/src/types/upload/UploadMode.ts new file mode 100644 index 0000000..b9d403e --- /dev/null +++ b/frontend/src/types/upload/UploadMode.ts @@ -0,0 +1 @@ +export type UploadMode = 'zk' | 'hotlink'; diff --git a/frontend/src/types/upload/UploadOptionsRequest.ts b/frontend/src/types/upload/UploadOptionsRequest.ts new file mode 100644 index 0000000..b033432 --- /dev/null +++ b/frontend/src/types/upload/UploadOptionsRequest.ts @@ -0,0 +1,4 @@ +export type UploadOptionsRequest = { + expiresAt: string | null; + maxViews: number | null; +}; diff --git a/frontend/src/utils/share/buildHotlinkImagePath.ts b/frontend/src/utils/share/buildHotlinkImagePath.ts new file mode 100644 index 0000000..0c4e6ce --- /dev/null +++ b/frontend/src/utils/share/buildHotlinkImagePath.ts @@ -0,0 +1 @@ +export const buildHotlinkImagePath = (uploadId: string, secret: string): string => `/i/${uploadId}/${secret}`; diff --git a/frontend/src/utils/share/buildZkViewerPath.ts b/frontend/src/utils/share/buildZkViewerPath.ts new file mode 100644 index 0000000..3161907 --- /dev/null +++ b/frontend/src/utils/share/buildZkViewerPath.ts @@ -0,0 +1 @@ +export const buildZkViewerPath = (uploadId: string, fragmentKey: string): string => `/v/${uploadId}#${fragmentKey}`; diff --git a/frontend/src/utils/upload/decryptZkUpload.spec.ts b/frontend/src/utils/upload/decryptZkUpload.spec.ts new file mode 100644 index 0000000..e164164 --- /dev/null +++ b/frontend/src/utils/upload/decryptZkUpload.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { generateDek } from '@/utils/crypto/generateDek'; +import { bytesToBase64Url } from '@/utils/encoding/bytesToBase64Url'; +import { decryptZkUpload } from '@/utils/upload/decryptZkUpload'; +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 recovered = await decryptZkUpload(ciphertext, iv, fragmentKey); + + expect(recovered).toEqual(plaintext); + }); + + it('rejects decryption with a mismatched fragment key', async () => { + const { ciphertext, iv } = await encryptZkUpload(new Uint8Array([7])); + const otherFragmentKey = bytesToBase64Url(generateDek()); + + await expect(decryptZkUpload(ciphertext, iv, otherFragmentKey)).rejects.toThrow(); + }); +}); diff --git a/frontend/src/utils/upload/decryptZkUpload.ts b/frontend/src/utils/upload/decryptZkUpload.ts new file mode 100644 index 0000000..c1f738c --- /dev/null +++ b/frontend/src/utils/upload/decryptZkUpload.ts @@ -0,0 +1,14 @@ +import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm'; +import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey'; +import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes'; + +export const decryptZkUpload = async ( + ciphertext: Uint8Array, + iv: Uint8Array, + fragmentKey: string +): Promise => { + const dek = base64UrlToBytes(fragmentKey); + const key = await importAesGcmKey(dek); + + return decryptAesGcm(ciphertext, key, iv); +}; diff --git a/frontend/src/utils/upload/encryptHotlinkUpload.spec.ts b/frontend/src/utils/upload/encryptHotlinkUpload.spec.ts new file mode 100644 index 0000000..bffc0c0 --- /dev/null +++ b/frontend/src/utils/upload/encryptHotlinkUpload.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm'; +import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey'; +import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes'; +import { bytesToBase64 } from '@/utils/encoding/bytesToBase64'; +import { encryptHotlinkUpload } from '@/utils/upload/encryptHotlinkUpload'; + +describe('encryptHotlinkUpload', () => { + it('roundtrips plaintext using the path secret as the DEK', async () => { + const plaintext = new TextEncoder().encode('hotlink-image'); + const encrypted = await encryptHotlinkUpload(plaintext); + const dek = base64UrlToBytes(encrypted.secret); + const key = await importAesGcmKey(dek); + + const recovered = await decryptAesGcm(encrypted.ciphertext, key, encrypted.iv); + + expect(recovered).toEqual(plaintext); + }); + + it('returns a distinct secret per call', async () => { + const plaintext = new Uint8Array([0]); + const first = await encryptHotlinkUpload(plaintext); + const second = await encryptHotlinkUpload(plaintext); + + expect(first.secret).not.toEqual(second.secret); + }); + + it('serializes iv and ciphertext for the upload API', async () => { + const encrypted = await encryptHotlinkUpload(new Uint8Array([1, 2, 3])); + + expect(bytesToBase64(encrypted.iv)).toMatch(/^[A-Za-z0-9+/]+=*$/); + expect(bytesToBase64(encrypted.ciphertext)).toMatch(/^[A-Za-z0-9+/]+=*$/); + }); +}); diff --git a/frontend/src/utils/upload/encryptHotlinkUpload.ts b/frontend/src/utils/upload/encryptHotlinkUpload.ts new file mode 100644 index 0000000..264d7f9 --- /dev/null +++ b/frontend/src/utils/upload/encryptHotlinkUpload.ts @@ -0,0 +1,20 @@ +import type { EncryptedHotlinkUpload } from '@/types/upload/EncryptedHotlinkUpload'; +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 encryptHotlinkUpload = async (plaintext: Uint8Array): Promise => { + const dek = generateDek(); + const secret = bytesToBase64Url(dek); + const iv = generateIv(); + const key = await importAesGcmKey(dek); + const ciphertext = await encryptAesGcm(plaintext, key, iv); + + return { + secret, + iv, + ciphertext + }; +}; diff --git a/frontend/src/utils/upload/encryptZkUpload.spec.ts b/frontend/src/utils/upload/encryptZkUpload.spec.ts new file mode 100644 index 0000000..2e91404 --- /dev/null +++ b/frontend/src/utils/upload/encryptZkUpload.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +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 () => { + 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); + }); + + it('serializes iv and ciphertext for the upload API', async () => { + const encrypted = await encryptZkUpload(new Uint8Array([1, 2, 3])); + + expect(bytesToBase64(encrypted.iv)).toMatch(/^[A-Za-z0-9+/]+=*$/); + expect(bytesToBase64(encrypted.ciphertext)).toMatch(/^[A-Za-z0-9+/]+=*$/); + }); +}); diff --git a/frontend/src/utils/upload/encryptZkUpload.ts b/frontend/src/utils/upload/encryptZkUpload.ts new file mode 100644 index 0000000..637a324 --- /dev/null +++ b/frontend/src/utils/upload/encryptZkUpload.ts @@ -0,0 +1,19 @@ +import type { EncryptedZkUpload } from '@/types/upload/EncryptedZkUpload'; +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(); + const iv = generateIv(); + const key = await importAesGcmKey(dek); + const ciphertext = await encryptAesGcm(plaintext, key, iv); + + return { + fragmentKey: bytesToBase64Url(dek), + iv, + ciphertext + }; +}; diff --git a/frontend/src/utils/upload/prepareHotlinkUploadRequest.spec.ts b/frontend/src/utils/upload/prepareHotlinkUploadRequest.spec.ts new file mode 100644 index 0000000..0453cab --- /dev/null +++ b/frontend/src/utils/upload/prepareHotlinkUploadRequest.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength'; +import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm'; +import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey'; +import { base64ToBytes } from '@/utils/encoding/base64ToBytes'; +import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes'; +import { buildHotlinkImagePath } from '@/utils/share/buildHotlinkImagePath'; +import { prepareHotlinkUploadRequest } from '@/utils/upload/prepareHotlinkUploadRequest'; + +describe('prepareHotlinkUploadRequest', () => { + const options = { expiresAt: '2026-12-31T00:00:00.000Z', maxViews: 5 }; + + it('returns secret separately from the POST request', async () => { + const { request, secret } = await prepareHotlinkUploadRequest( + new Uint8Array([9]), + 'image/png', + options + ); + + expect(secret).toMatch(/^[A-Za-z0-9_-]+$/); + expect(request).toEqual({ + ...options, + mimeType: 'image/png', + iv: request.iv, + ciphertext: request.ciphertext + }); + expect(request).not.toHaveProperty('secret'); + expect(Object.keys(request).sort()).toEqual(['ciphertext', 'expiresAt', 'iv', 'maxViews', 'mimeType']); + }); + + it('roundtrips plaintext using request ciphertext and path secret', async () => { + const plaintext = new TextEncoder().encode('hotlink-payload'); + const { request, secret } = await prepareHotlinkUploadRequest(plaintext, 'image/jpeg', options); + + const dek = base64UrlToBytes(secret); + expect(dek).toHaveLength(AES_GCM_KEY_BYTE_LENGTH); + + const key = await importAesGcmKey(dek); + const iv = base64ToBytes(request.iv); + const ciphertext = base64ToBytes(request.ciphertext); + const recovered = await decryptAesGcm(ciphertext, key, iv); + + expect(recovered).toEqual(plaintext); + }); + + it('wires secret into the hotlink path segment', async () => { + const uploadId = 'hotlink-upload-id'; + const { request, secret } = await prepareHotlinkUploadRequest( + new Uint8Array([1]), + 'image/png', + options + ); + const path = buildHotlinkImagePath(uploadId, secret); + + expect(path).toBe(`/i/${uploadId}/${secret}`); + expect(JSON.stringify(request)).not.toContain(secret); + expect(path.endsWith(`/${secret}`)).toBe(true); + }); +}); diff --git a/frontend/src/utils/upload/prepareHotlinkUploadRequest.ts b/frontend/src/utils/upload/prepareHotlinkUploadRequest.ts new file mode 100644 index 0000000..0e6c508 --- /dev/null +++ b/frontend/src/utils/upload/prepareHotlinkUploadRequest.ts @@ -0,0 +1,22 @@ +import type { CreateHotlinkUploadRequest } from '@/types/upload/CreateHotlinkUploadRequest'; +import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest'; +import { bytesToBase64 } from '@/utils/encoding/bytesToBase64'; +import { encryptHotlinkUpload } from '@/utils/upload/encryptHotlinkUpload'; + +export const prepareHotlinkUploadRequest = async ( + plaintext: Uint8Array, + mimeType: string, + options: UploadOptionsRequest +): Promise<{ request: CreateHotlinkUploadRequest; secret: string }> => { + const encrypted = await encryptHotlinkUpload(plaintext); + + return { + secret: encrypted.secret, + request: { + ...options, + iv: bytesToBase64(encrypted.iv), + ciphertext: bytesToBase64(encrypted.ciphertext), + mimeType + } + }; +}; diff --git a/frontend/src/utils/upload/prepareZkUploadRequest.spec.ts b/frontend/src/utils/upload/prepareZkUploadRequest.spec.ts new file mode 100644 index 0000000..8de72a7 --- /dev/null +++ b/frontend/src/utils/upload/prepareZkUploadRequest.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { base64ToBytes } from '@/utils/encoding/base64ToBytes'; +import { buildZkViewerPath } from '@/utils/share/buildZkViewerPath'; +import { decryptZkUpload } from '@/utils/upload/decryptZkUpload'; +import { prepareZkUploadRequest } from '@/utils/upload/prepareZkUploadRequest'; + +describe('prepareZkUploadRequest', () => { + const options = { expiresAt: null, maxViews: null, passwordProtected: false as const }; + + it('returns fragmentKey separately from the POST request', async () => { + const { request, fragmentKey } = await prepareZkUploadRequest(new Uint8Array([9]), options); + + expect(fragmentKey).toMatch(/^[A-Za-z0-9_-]+$/); + expect(request).toEqual({ + ...options, + iv: request.iv, + ciphertext: request.ciphertext, + passwordProtected: false + }); + expect(request).not.toHaveProperty('fragmentKey'); + expect(Object.keys(request).sort()).toEqual([ + 'ciphertext', + 'expiresAt', + 'iv', + 'maxViews', + 'passwordProtected' + ]); + }); + + it('roundtrips plaintext using request fields and URL fragment key', async () => { + const plaintext = new TextEncoder().encode('zk-payload'); + const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, options); + + const recovered = await decryptZkUpload( + base64ToBytes(request.ciphertext), + base64ToBytes(request.iv), + fragmentKey + ); + + expect(recovered).toEqual(plaintext); + }); + + it('wires fragmentKey into the viewer URL hash for decryption', async () => { + const plaintext = new TextEncoder().encode('share-link'); + const uploadId = '7f3e2a1b-0000-4000-8000-000000000001'; + const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, options); + const path = buildZkViewerPath(uploadId, fragmentKey); + + const [pathWithoutHash, hashKey] = path.split('#'); + + expect(pathWithoutHash).toBe(`/v/${uploadId}`); + expect(hashKey).toBe(fragmentKey); + const recovered = await decryptZkUpload( + base64ToBytes(request.ciphertext), + base64ToBytes(request.iv), + hashKey + ); + + expect(recovered).toEqual(plaintext); + }); +}); diff --git a/frontend/src/utils/upload/prepareZkUploadRequest.ts b/frontend/src/utils/upload/prepareZkUploadRequest.ts new file mode 100644 index 0000000..ee09be7 --- /dev/null +++ b/frontend/src/utils/upload/prepareZkUploadRequest.ts @@ -0,0 +1,21 @@ +import type { CreateZkUploadRequest } from '@/types/upload/CreateZkUploadRequest'; +import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest'; +import { bytesToBase64 } from '@/utils/encoding/bytesToBase64'; +import { encryptZkUpload } from '@/utils/upload/encryptZkUpload'; + +export const prepareZkUploadRequest = async ( + plaintext: Uint8Array, + options: UploadOptionsRequest & { passwordProtected: false } +): Promise<{ request: CreateZkUploadRequest; fragmentKey: string }> => { + const encrypted = await encryptZkUpload(plaintext); + + return { + fragmentKey: encrypted.fragmentKey, + request: { + ...options, + iv: bytesToBase64(encrypted.iv), + ciphertext: bytesToBase64(encrypted.ciphertext), + passwordProtected: false + } + }; +};