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 + } + }; +};