Slave/client crypto #7

Merged
nobswebdev merged 5 commits from slave/client-crypto into master 2026-10-20 17:30:03 +00:00
21 changed files with 370 additions and 0 deletions
Showing only changes of commit afbd5e204c - Show all commits
@@ -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
}
};
};