Slave/client crypto #7
@@ -0,0 +1 @@
|
||||
export const ALTCHA_REQUEST_HEADER = 'x-altcha';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user