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.
This commit is contained in:
2026-10-19 23:05:33 +02:00
parent 0238858c8b
commit afbd5e204c
21 changed files with 370 additions and 0 deletions
@@ -0,0 +1 @@
export const ALTCHA_REQUEST_HEADER = 'x-altcha';
+34
View File
@@ -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<CreatedUploadResult> => {
const { data } = await api.post<CreatedUploadResult>('/uploads/zk', body, {
headers: getUploadRequestHeaders(altcha)
});
return data;
};
const createHotlinkUpload = async (
body: CreateHotlinkUploadRequest,
altcha?: string
): Promise<CreatedUploadResult> => {
const { data } = await api.post<CreatedUploadResult>('/uploads/hotlink', body, {
headers: getUploadRequestHeaders(altcha)
});
return data;
};
const getUploadRequestHeaders = (altcha?: string) => (altcha ? { [ALTCHA_REQUEST_HEADER]: altcha } : undefined);
return {
createZkUpload,
createHotlinkUpload
};
});
@@ -0,0 +1,7 @@
import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest';
export type CreateHotlinkUploadRequest = UploadOptionsRequest & {
iv: string;
ciphertext: string;
mimeType: string;
};
@@ -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;
};
@@ -0,0 +1,7 @@
import type { UploadMode } from '@/types/upload/UploadMode';
export type CreatedUploadResult = {
id: string;
mode: UploadMode;
deleteToken: string;
};
@@ -0,0 +1,5 @@
export type EncryptedHotlinkUpload = {
secret: string;
iv: Uint8Array;
ciphertext: Uint8Array;
};
@@ -0,0 +1,5 @@
export type EncryptedZkUpload = {
fragmentKey: string;
iv: Uint8Array;
ciphertext: Uint8Array;
};
+1
View File
@@ -0,0 +1 @@
export type UploadMode = 'zk' | 'hotlink';
@@ -0,0 +1,4 @@
export type UploadOptionsRequest = {
expiresAt: string | null;
maxViews: number | null;
};
@@ -0,0 +1 @@
export const buildHotlinkImagePath = (uploadId: string, secret: string): string => `/i/${uploadId}/${secret}`;
@@ -0,0 +1 @@
export const buildZkViewerPath = (uploadId: string, fragmentKey: string): string => `/v/${uploadId}#${fragmentKey}`;
@@ -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();
});
});
@@ -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<Uint8Array> => {
const dek = base64UrlToBytes(fragmentKey);
const key = await importAesGcmKey(dek);
return decryptAesGcm(ciphertext, key, iv);
};
@@ -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+/]+=*$/);
});
});
@@ -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<EncryptedHotlinkUpload> => {
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
};
};
@@ -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+/]+=*$/);
});
});
@@ -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<EncryptedZkUpload> => {
const dek = generateDek();
const iv = generateIv();
const key = await importAesGcmKey(dek);
const ciphertext = await encryptAesGcm(plaintext, key, iv);
return {
fragmentKey: bytesToBase64Url(dek),
iv,
ciphertext
};
};
@@ -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);
});
});
@@ -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
}
};
};
@@ -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);
});
});
@@ -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
}
};
};