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