Merge pull request 'Slave/client crypto' (#7) from slave/client-crypto into master

Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2026-10-20 17:30:03 +00:00
72 changed files with 2912 additions and 6 deletions
+2 -2
View File
@@ -1,3 +1,4 @@
import { AES_GCM_AUTH_TAG_BYTE_LENGTH } from '../consts/crypto/aesGcmAuthTagByteLength';
import { AltchaConfig } from '../types/config/AltchaConfig';
import { AppConfig } from '../types/config/AppConfig';
import { NodeEnv } from '../types/NodeEnv';
@@ -34,7 +35,6 @@ export const getAltchaConfig = (): AltchaConfig => {
};
export const getAppConfig = (): AppConfig => {
const aesGcmCiphertextOverheadBytes = 16;
const jsonBase64ByteRatioNumerator = 4;
const jsonBase64ByteRatioDenominator = 3;
@@ -61,7 +61,7 @@ export const getAppConfig = (): AppConfig => {
upload: {
maxViewsMax: envInt('VALIDATION_UPLOAD_MAX_VIEWS_MAX'),
maxImageBytes,
maxCiphertextBytes: maxImageBytes + aesGcmCiphertextOverheadBytes,
maxCiphertextBytes: maxImageBytes + AES_GCM_AUTH_TAG_BYTE_LENGTH,
maxRequestBodyBytes:
Math.ceil((maxImageBytes * jsonBase64ByteRatioNumerator) / jsonBase64ByteRatioDenominator) +
jsonBodyPaddingBytes
@@ -0,0 +1 @@
export const AES_GCM_AUTH_TAG_BYTE_LENGTH = 16;
@@ -0,0 +1 @@
export const AES_GCM_IV_BYTE_LENGTH = 12;
@@ -1,4 +1,5 @@
import { IsBoolean, IsNotEmpty, ValidateIf } from 'class-validator';
import { AES_GCM_IV_BYTE_LENGTH } from '../../../consts/crypto/aesGcmIvByteLength';
import { IsBase64 } from '../../../validation/decorators/isBase64';
import { UploadCiphertextOptionsDto } from './UploadCiphertextOptionsDto';
@@ -14,7 +15,7 @@ export class CreateZkUploadDto extends UploadCiphertextOptionsDto {
@ValidateIf((dto: CreateZkUploadDto) => dto.passwordProtected === true)
@IsNotEmpty()
@IsBase64({ byteLength: 12 })
@IsBase64({ byteLength: AES_GCM_IV_BYTE_LENGTH })
wrapIv?: string;
@ValidateIf((dto: CreateZkUploadDto) => dto.passwordProtected === true)
@@ -1,4 +1,5 @@
import { IsNotEmpty } from 'class-validator';
import { AES_GCM_IV_BYTE_LENGTH } from '../../../consts/crypto/aesGcmIvByteLength';
import { getAppConfig } from '../../../config';
import { IsBase64 } from '../../../validation/decorators/isBase64';
import { UploadOptionsDto } from './UploadOptionsDto';
@@ -11,7 +12,7 @@ const {
export class UploadCiphertextOptionsDto extends UploadOptionsDto {
@IsNotEmpty()
@IsBase64({ byteLength: 12 })
@IsBase64({ byteLength: AES_GCM_IV_BYTE_LENGTH })
iv: string;
@IsNotEmpty()
+1501
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -7,12 +7,14 @@
"dev": "vite --host 0.0.0.0",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"postinstall": "node patches/patch-exiftool-stderr.mjs && node patches/patch-zeroperl-worker-fetch.mjs"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"@uswriting/exiftool": "1.0.9",
"axios": "^1.20.0",
"hash-wasm": "^4.12.0",
"pinia": "^4.0.3",
"pretty-bytes": "^7.2.0",
"tailwindcss": "^4.3.3",
@@ -25,6 +27,7 @@
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"vite": "^8.0.4",
"vitest": "^3.2.7",
"vue-tsc": "^3.2.6"
}
}
@@ -0,0 +1 @@
export const ALTCHA_REQUEST_HEADER = 'x-altcha';
@@ -0,0 +1 @@
export const AES_GCM_AUTH_TAG_BYTE_LENGTH = 16;
@@ -0,0 +1 @@
export const AES_GCM_IV_BYTE_LENGTH = 12;
@@ -0,0 +1 @@
export const AES_GCM_KEY_BYTE_LENGTH = 32;
@@ -1,4 +1,4 @@
import { base64ToBytes } from '@/utils/base64ToBytes';
import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
const EXIFTOOL_WORKER_INIT_JPEG_BASE64 =
'/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AL+AD//Z';
+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 = {
dek: Uint8Array;
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,5 @@
export type WrappedZkDek = {
wrappedDek: Uint8Array;
wrapIv: Uint8Array;
salt: Uint8Array;
};
@@ -0,0 +1,5 @@
import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest';
export type ZkUploadPrepareOptions =
| (UploadOptionsRequest & { passwordProtected: false })
| (UploadOptionsRequest & { passwordProtected: true; password: string });
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { AES_GCM_AUTH_TAG_BYTE_LENGTH } from '@/consts/crypto/aesGcmAuthTagByteLength';
import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength';
import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm';
import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm';
import { generateDek } from '@/utils/crypto/generateDek';
import { generateIv } from '@/utils/crypto/generateIv';
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
describe('decryptAesGcm', () => {
it('rejects wrong-length IVs before calling Web Crypto', async () => {
const dek = generateDek();
const key = await importAesGcmKey(dek);
const ciphertext = new Uint8Array(AES_GCM_AUTH_TAG_BYTE_LENGTH);
await expect(decryptAesGcm(ciphertext, key, new Uint8Array(AES_GCM_IV_BYTE_LENGTH - 1))).rejects.toThrow(
`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`
);
await expect(decryptAesGcm(ciphertext, key, new Uint8Array(AES_GCM_IV_BYTE_LENGTH + 1))).rejects.toThrow(
`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`
);
});
it('rejects tampered ciphertext (auth tag failure)', async () => {
const plaintext = new TextEncoder().encode('integrity-checked');
const dek = generateDek();
const iv = generateIv();
const key = await importAesGcmKey(dek);
const ciphertext = await encryptAesGcm(plaintext, key, iv);
const tampered = new Uint8Array(ciphertext);
tampered[tampered.length - 1] ^= 0x01;
await expect(decryptAesGcm(tampered, key, iv)).rejects.toThrow();
});
it('rejects decryption with the wrong key', async () => {
const plaintext = new TextEncoder().encode('wrong-key');
const iv = generateIv();
const encryptKey = await importAesGcmKey(generateDek());
const decryptKey = await importAesGcmKey(generateDek());
const ciphertext = await encryptAesGcm(plaintext, encryptKey, iv);
await expect(decryptAesGcm(ciphertext, decryptKey, iv)).rejects.toThrow();
});
it('rejects decryption with the wrong IV', async () => {
const plaintext = new TextEncoder().encode('wrong-iv');
const dek = generateDek();
const key = await importAesGcmKey(dek);
const ciphertext = await encryptAesGcm(plaintext, key, generateIv());
await expect(decryptAesGcm(ciphertext, key, generateIv())).rejects.toThrow();
});
it('rejects truncated ciphertext', async () => {
const plaintext = new TextEncoder().encode('truncated');
const dek = generateDek();
const iv = generateIv();
const key = await importAesGcmKey(dek);
const ciphertext = await encryptAesGcm(plaintext, key, iv);
const truncated = ciphertext.slice(0, ciphertext.length - 1);
await expect(decryptAesGcm(truncated, key, iv)).rejects.toThrow();
});
});
@@ -0,0 +1,19 @@
import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength';
import { toArrayBuffer } from '@/utils/toArrayBuffer';
export const decryptAesGcm = async (ciphertext: Uint8Array, key: CryptoKey, iv: Uint8Array): Promise<Uint8Array> => {
if (iv.byteLength !== AES_GCM_IV_BYTE_LENGTH) {
throw new Error(`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`);
}
const plaintext = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: toArrayBuffer(iv)
},
key,
toArrayBuffer(ciphertext)
);
return new Uint8Array(plaintext);
};
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { deriveZkWrapKeyBytes } from '@/utils/crypto/deriveZkWrapKeyBytes';
import { generateDek } from '@/utils/crypto/generateDek';
describe('deriveZkWrapKeyBytes', () => {
const salt = new Uint8Array([8, 7, 6, 5, 4, 3, 2, 1, 0, 9, 8, 7, 6, 5, 4, 3]);
const fragmentSecret = generateDek();
const password = 'wrap-password';
it('returns a 32-byte wrap key', async () => {
const wrapKey = await deriveZkWrapKeyBytes(password, salt, fragmentSecret);
expect(wrapKey.byteLength).toBe(32);
});
it('is deterministic for the same inputs', async () => {
const first = await deriveZkWrapKeyBytes(password, salt, fragmentSecret);
const second = await deriveZkWrapKeyBytes(password, salt, fragmentSecret);
expect(first).toEqual(second);
});
it('binds the URL fragment secret into the wrap key', async () => {
const otherFragment = generateDek();
const wrapKeyA = await deriveZkWrapKeyBytes(password, salt, fragmentSecret);
const wrapKeyB = await deriveZkWrapKeyBytes(password, salt, otherFragment);
expect(wrapKeyA).not.toEqual(wrapKeyB);
});
});
@@ -0,0 +1,17 @@
import { hashPasswordArgon2id } from '@/utils/crypto/hashPasswordArgon2id';
import { toArrayBuffer } from '@/utils/toArrayBuffer';
export const deriveZkWrapKeyBytes = async (
password: string,
salt: Uint8Array,
fragmentSecret: Uint8Array
): Promise<Uint8Array> => {
const kek = await hashPasswordArgon2id(password, salt);
const combined = new Uint8Array(kek.length + fragmentSecret.length);
combined.set(kek, 0);
combined.set(fragmentSecret, kek.length);
const digest = await crypto.subtle.digest('SHA-256', toArrayBuffer(combined));
return new Uint8Array(digest);
};
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import { AES_GCM_AUTH_TAG_BYTE_LENGTH } from '@/consts/crypto/aesGcmAuthTagByteLength';
import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength';
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm';
import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm';
import { generateDek } from '@/utils/crypto/generateDek';
import { generateIv } from '@/utils/crypto/generateIv';
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
const UPLOAD_API_BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
describe('encryptAesGcm', () => {
it('roundtrips plaintext', async () => {
const plaintext = new TextEncoder().encode('stripped-image-bytes');
const dek = generateDek();
const iv = generateIv();
const key = await importAesGcmKey(dek);
const ciphertext = await encryptAesGcm(plaintext, key, iv);
const decrypted = await decryptAesGcm(ciphertext, key, iv);
expect(decrypted).toEqual(plaintext);
});
it('roundtrips empty plaintext and large payloads', async () => {
const dek = generateDek();
const key = await importAesGcmKey(dek);
const empty = new Uint8Array(0);
const large = new Uint8Array(65_536);
for (let i = 0; i < large.length; i++) {
large[i] = i % 256;
}
for (const plaintext of [empty, large]) {
const iv = generateIv();
const ciphertext = await encryptAesGcm(plaintext, key, iv);
const decrypted = await decryptAesGcm(ciphertext, key, iv);
expect(decrypted).toEqual(plaintext);
expect(ciphertext.byteLength).toBe(plaintext.byteLength + AES_GCM_AUTH_TAG_BYTE_LENGTH);
}
});
it('accepts ArrayBuffer plaintext', async () => {
const plaintext = new TextEncoder().encode('array-buffer-plaintext').buffer;
const dek = generateDek();
const iv = generateIv();
const key = await importAesGcmKey(dek);
const ciphertext = await encryptAesGcm(plaintext, key, iv);
const decrypted = await decryptAesGcm(ciphertext, key, iv);
expect(decrypted).toEqual(new Uint8Array(plaintext));
});
it('rejects wrong-length IVs before calling Web Crypto', async () => {
const dek = generateDek();
const key = await importAesGcmKey(dek);
const plaintext = new Uint8Array([1, 2, 3]);
await expect(encryptAesGcm(plaintext, key, new Uint8Array(AES_GCM_IV_BYTE_LENGTH - 1))).rejects.toThrow(
`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`
);
await expect(encryptAesGcm(plaintext, key, new Uint8Array(AES_GCM_IV_BYTE_LENGTH + 1))).rejects.toThrow(
`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`
);
});
it('is deterministic for a fixed key, IV, and plaintext', async () => {
const plaintext = new TextEncoder().encode('deterministic-check');
const dek = generateDek();
const iv = generateIv();
const key = await importAesGcmKey(dek);
const first = await encryptAesGcm(plaintext, key, iv);
const second = await encryptAesGcm(plaintext, key, iv);
expect(first).toEqual(second);
});
it('changes ciphertext when the IV changes (nonce separation)', async () => {
const plaintext = new TextEncoder().encode('unique-nonce');
const dek = generateDek();
const key = await importAesGcmKey(dek);
const ciphertextA = await encryptAesGcm(plaintext, key, generateIv());
const ciphertextB = await encryptAesGcm(plaintext, key, generateIv());
expect(ciphertextA).not.toEqual(ciphertextB);
});
it('produces standard base64 iv and ciphertext suitable for the upload API', async () => {
const plaintext = new Uint8Array(64);
const dek = generateDek();
const iv = generateIv();
const key = await importAesGcmKey(dek);
const ciphertext = await encryptAesGcm(plaintext, key, iv);
const ivBase64 = bytesToBase64(iv);
const ciphertextBase64 = bytesToBase64(ciphertext);
expect(UPLOAD_API_BASE64_PATTERN.test(ivBase64)).toBe(true);
expect(UPLOAD_API_BASE64_PATTERN.test(ciphertextBase64)).toBe(true);
expect(ciphertext.byteLength).toBe(plaintext.byteLength + AES_GCM_AUTH_TAG_BYTE_LENGTH);
});
});
@@ -0,0 +1,23 @@
import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength';
import { toArrayBuffer } from '@/utils/toArrayBuffer';
export const encryptAesGcm = async (
plaintext: ArrayBuffer | Uint8Array,
key: CryptoKey,
iv: Uint8Array
): Promise<Uint8Array> => {
if (iv.byteLength !== AES_GCM_IV_BYTE_LENGTH) {
throw new Error(`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`);
}
const ciphertext = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: toArrayBuffer(iv)
},
key,
toArrayBuffer(plaintext)
);
return new Uint8Array(ciphertext);
};
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength';
import { generateDek } from '@/utils/crypto/generateDek';
describe('generateDek', () => {
it('returns a 32-byte raw AES-256 key', () => {
const dek = generateDek();
expect(dek).toBeInstanceOf(Uint8Array);
expect(dek.byteLength).toBe(AES_GCM_KEY_BYTE_LENGTH);
});
it('generates distinct DEKs across calls', () => {
const dekA = generateDek();
const dekB = generateDek();
expect(dekA).not.toEqual(dekB);
});
});
+4
View File
@@ -0,0 +1,4 @@
import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength';
import { generateRandomBytes } from '@/utils/crypto/generateRandomBytes';
export const generateDek = (): Uint8Array => generateRandomBytes(AES_GCM_KEY_BYTE_LENGTH);
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength';
import { generateIv } from '@/utils/crypto/generateIv';
describe('generateIv', () => {
it('returns a 12-byte IV for AES-GCM', () => {
const iv = generateIv();
expect(iv).toBeInstanceOf(Uint8Array);
expect(iv.byteLength).toBe(AES_GCM_IV_BYTE_LENGTH);
});
it('generates distinct IVs across calls', () => {
const ivA = generateIv();
const ivB = generateIv();
expect(ivA).not.toEqual(ivB);
});
});
+4
View File
@@ -0,0 +1,4 @@
import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength';
import { generateRandomBytes } from '@/utils/crypto/generateRandomBytes';
export const generateIv = (): Uint8Array => generateRandomBytes(AES_GCM_IV_BYTE_LENGTH);
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { generateRandomBytes } from '@/utils/crypto/generateRandomBytes';
describe('generateRandomBytes', () => {
it('returns a Uint8Array of the requested length', () => {
expect(generateRandomBytes(0)).toEqual(new Uint8Array(0));
expect(generateRandomBytes(1).byteLength).toBe(1);
expect(generateRandomBytes(4096).byteLength).toBe(4096);
});
it('does not return the same buffer for consecutive calls', () => {
const first = generateRandomBytes(32);
const second = generateRandomBytes(32);
expect(first).not.toEqual(second);
});
it('is not trivially constant for non-zero length', () => {
const bytes = generateRandomBytes(64);
const allSame = bytes.every(byte => byte === bytes[0]);
expect(allSame).toBe(false);
});
});
@@ -0,0 +1,5 @@
export const generateRandomBytes = (length: number): Uint8Array => {
const bytes = new Uint8Array(length);
return crypto.getRandomValues(bytes);
};
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { hashPasswordArgon2id } from '@/utils/crypto/hashPasswordArgon2id';
describe('hashPasswordArgon2id', () => {
const salt = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
it('returns a 32-byte hash', async () => {
const hash = await hashPasswordArgon2id('test-password', salt);
expect(hash).toBeInstanceOf(Uint8Array);
expect(hash.byteLength).toBe(32);
});
it('is deterministic for the same password and salt', async () => {
const first = await hashPasswordArgon2id('same', salt);
const second = await hashPasswordArgon2id('same', salt);
expect(first).toEqual(second);
});
it('changes when the salt changes', async () => {
const otherSalt = new Uint8Array(salt);
otherSalt[0] ^= 0xff;
const hashA = await hashPasswordArgon2id('same', salt);
const hashB = await hashPasswordArgon2id('same', otherSalt);
expect(hashA).not.toEqual(hashB);
});
});
@@ -0,0 +1,20 @@
import { argon2id } from 'hash-wasm';
export const hashPasswordArgon2id = async (password: string, salt: Uint8Array): Promise<Uint8Array> => {
const iterations = 3;
const memorySizeKib = 65536;
const parallelism = 1;
const hashByteLength = 32;
const hash = await argon2id({
password,
salt,
parallelism,
iterations,
memorySize: memorySizeKib,
hashLength: hashByteLength,
outputType: 'binary'
});
return new Uint8Array(hash);
};
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength';
import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm';
import { generateDek } from '@/utils/crypto/generateDek';
import { generateIv } from '@/utils/crypto/generateIv';
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
describe('importAesGcmKey', () => {
it('imports a raw 32-byte key for AES-GCM encrypt and decrypt', async () => {
const dek = generateDek();
const key = await importAesGcmKey(dek);
expect(key.algorithm.name).toBe('AES-GCM');
expect(key.type).toBe('secret');
expect(key.extractable).toBe(false);
expect(key.usages).toEqual(['encrypt', 'decrypt']);
const iv = generateIv();
const plaintext = new TextEncoder().encode('key-import-smoke');
await expect(encryptAesGcm(plaintext, key, iv)).resolves.toBeInstanceOf(Uint8Array);
});
it('rejects wrong-length raw keys', async () => {
const shortKey = new Uint8Array(AES_GCM_KEY_BYTE_LENGTH - 1);
const longKey = new Uint8Array(AES_GCM_KEY_BYTE_LENGTH + 1);
await expect(importAesGcmKey(shortKey)).rejects.toThrow(
`AES-GCM key must be ${AES_GCM_KEY_BYTE_LENGTH} bytes`
);
await expect(importAesGcmKey(longKey)).rejects.toThrow(
`AES-GCM key must be ${AES_GCM_KEY_BYTE_LENGTH} bytes`
);
});
});
@@ -0,0 +1,10 @@
import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength';
import { toArrayBuffer } from '@/utils/toArrayBuffer';
export const importAesGcmKey = async (rawKey: Uint8Array): Promise<CryptoKey> => {
if (rawKey.byteLength !== AES_GCM_KEY_BYTE_LENGTH) {
throw new Error(`AES-GCM key must be ${AES_GCM_KEY_BYTE_LENGTH} bytes`);
}
return crypto.subtle.importKey('raw', toArrayBuffer(rawKey), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
};
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
describe('base64ToBytes', () => {
it('decodes RFC 4648 examples', () => {
expect(base64ToBytes('Zg==')).toEqual(new Uint8Array([102]));
expect(base64ToBytes('Zm8=')).toEqual(new Uint8Array([102, 111]));
expect(base64ToBytes('Zm9v')).toEqual(new Uint8Array([102, 111, 111]));
expect(base64ToBytes('Zm9vYg==')).toEqual(new Uint8Array([102, 111, 111, 98]));
});
it('decodes empty input', () => {
expect(base64ToBytes('')).toEqual(new Uint8Array([]));
});
it('decodes every byte value via roundtrip with btoa', () => {
const bytes = new Uint8Array(256);
for (let i = 0; i < 256; i++) {
bytes[i] = i;
}
const binary = Array.from(bytes, byte => String.fromCharCode(byte)).join('');
const base64 = btoa(binary);
expect(base64ToBytes(base64)).toEqual(bytes);
});
it('rejects invalid base64', () => {
expect(() => base64ToBytes('not!!!valid')).toThrow();
});
});
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
import { bytesToBase64Url } from '@/utils/encoding/bytesToBase64Url';
import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes';
describe('base64UrlToBytes', () => {
it('decodes with and without restored padding', () => {
const bytes = new Uint8Array([1, 2, 3]);
const standard = bytesToBase64(bytes);
const url = bytesToBase64Url(bytes);
expect(base64UrlToBytes(url)).toEqual(bytes);
expect(base64UrlToBytes(standard.replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''))).toEqual(bytes);
});
it('matches standard base64 decode for padded input', () => {
const standard = 'AQIDBA==';
const url = 'AQIDBA';
expect(base64UrlToBytes(url)).toEqual(base64ToBytes(standard));
});
it('rejects invalid input', () => {
expect(() => base64UrlToBytes('%%%')).toThrow();
});
});
@@ -0,0 +1,13 @@
import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
export const base64UrlToBytes = (base64Url: string): Uint8Array => {
let base64 = base64Url.split('-').join('+').split('_').join('/');
const padding = base64.length % 4;
if (padding > 0) {
base64 += '='.repeat(4 - padding);
}
return base64ToBytes(base64);
};
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
const UPLOAD_API_BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
describe('bytesToBase64', () => {
it('encodes RFC 4648 examples', () => {
expect(bytesToBase64(new Uint8Array([102]))).toBe('Zg==');
expect(bytesToBase64(new Uint8Array([102, 111]))).toBe('Zm8=');
expect(bytesToBase64(new Uint8Array([102, 111, 111]))).toBe('Zm9v');
});
it('encodes empty input', () => {
expect(bytesToBase64(new Uint8Array([]))).toBe('');
});
it('roundtrips with base64ToBytes for random lengths', () => {
for (const length of [1, 2, 3, 4, 5, 12, 16, 32, 64, 1000]) {
const bytes = new Uint8Array(length);
for (let i = 0; i < length; i++) {
bytes[i] = (i * 17 + length) % 256;
}
expect(base64ToBytes(bytesToBase64(bytes))).toEqual(bytes);
}
});
it('produces strings accepted by the upload API base64 validator', () => {
const ivLike = new Uint8Array(12);
crypto.getRandomValues(ivLike);
const encoded = bytesToBase64(ivLike);
expect(UPLOAD_API_BASE64_PATTERN.test(encoded)).toBe(true);
expect(base64ToBytes(encoded)).toEqual(ivLike);
});
});
@@ -0,0 +1,5 @@
export const bytesToBase64 = (bytes: Uint8Array): string => {
const binary = Array.from(bytes, byte => String.fromCharCode(byte)).join('');
return btoa(binary);
};
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes';
import { bytesToBase64Url } from '@/utils/encoding/bytesToBase64Url';
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/;
describe('bytesToBase64Url', () => {
it('uses only URL-safe characters and omits padding', () => {
const bytes = new Uint8Array([255, 254, 253, 252, 251, 250]);
const encoded = bytesToBase64Url(bytes);
expect(BASE64URL_PATTERN.test(encoded)).toBe(true);
expect(encoded.includes('+')).toBe(false);
expect(encoded.includes('/')).toBe(false);
expect(encoded.includes('=')).toBe(false);
});
it('roundtrips with base64UrlToBytes', () => {
for (const length of [0, 1, 2, 3, 4, 12, 16, 32, 64]) {
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
expect(base64UrlToBytes(bytesToBase64Url(bytes))).toEqual(bytes);
}
});
it('differs from standard base64 when URL encoding is required', () => {
const bytes = new Uint8Array([251, 255, 255]);
expect(bytesToBase64Url(bytes)).not.toBe(
btoa(Array.from(bytes, b => String.fromCharCode(b)).join(''))
);
});
});
@@ -0,0 +1,11 @@
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
export const bytesToBase64Url = (bytes: Uint8Array): string => {
let base64 = bytesToBase64(bytes).split('+').join('-').split('/').join('_');
while (base64.endsWith('=')) {
base64 = base64.slice(0, -1);
}
return base64;
};
@@ -0,0 +1,82 @@
import { AxiosError, type InternalAxiosRequestConfig } from 'axios';
import { describe, expect, it } from 'vitest';
import { resolveAxiosErrorMessage } from '@/utils/http/resolveAxiosErrorMessage';
const fallback = 'Upload failed';
const axiosErrorWithResponse = (data: unknown, message = 'Request failed'): AxiosError => {
return new AxiosError(
message,
AxiosError.ERR_BAD_REQUEST,
{} as InternalAxiosRequestConfig,
{},
{
status: 400,
statusText: 'Bad Request',
headers: {},
config: {} as InternalAxiosRequestConfig,
data
}
);
};
describe('resolveAxiosErrorMessage', () => {
it('falls back for non-Axios errors', () => {
expect(resolveAxiosErrorMessage(new Error('network'), fallback)).toBe('network');
expect(resolveAxiosErrorMessage('oops', fallback)).toBe(fallback);
});
it('falls back when Axios error has no response', () => {
const error = new AxiosError('timeout', AxiosError.ETIMEDOUT);
expect(resolveAxiosErrorMessage(error, fallback)).toBe('timeout');
});
it('uses string response data', () => {
const error = axiosErrorWithResponse('Rate limit exceeded');
expect(resolveAxiosErrorMessage(error, fallback)).toBe('Rate limit exceeded');
});
it('uses object message string (Nest-style)', () => {
const error = axiosErrorWithResponse({ message: 'Invalid ciphertext' });
expect(resolveAxiosErrorMessage(error, fallback)).toBe('Invalid ciphertext');
});
it('uses a single string from message array', () => {
const error = axiosErrorWithResponse({ message: ['iv must be valid base64'] });
expect(resolveAxiosErrorMessage(error, fallback)).toBe('iv must be valid base64');
});
it('joins multiple strings in message array', () => {
const error = axiosErrorWithResponse({
message: ['expiresAt must be a date', 'maxViews must be a number']
});
expect(resolveAxiosErrorMessage(error, fallback)).toBe(
'expiresAt must be a date, maxViews must be a number'
);
});
it('ignores non-string entries in message array', () => {
const error = axiosErrorWithResponse({
message: ['valid', 42, null, 'also valid']
});
expect(resolveAxiosErrorMessage(error, fallback)).toBe('valid, also valid');
});
it('falls back when response data has no usable message', () => {
const error = axiosErrorWithResponse({ message: [] }, 'HTTP 400');
expect(resolveAxiosErrorMessage(error, fallback)).toBe('HTTP 400');
});
it('falls back when response data is an object without message', () => {
const error = axiosErrorWithResponse({ statusCode: 400 }, 'HTTP 400');
expect(resolveAxiosErrorMessage(error, fallback)).toBe('HTTP 400');
});
});
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { resolveErrorMessage } from '@/utils/resolveErrorMessage';
describe('resolveErrorMessage', () => {
it('returns Error.message when non-empty', () => {
expect(resolveErrorMessage(new Error('upload failed'))).toBe('upload failed');
});
it('uses fallback when Error.message is empty', () => {
expect(resolveErrorMessage(new Error(''), 'custom fallback')).toBe('custom fallback');
});
it('uses default fallback for non-Error values', () => {
expect(resolveErrorMessage(null)).toBe('Something went wrong');
expect(resolveErrorMessage('plain string')).toBe('Something went wrong');
expect(resolveErrorMessage({ message: 'not an Error' })).toBe('Something went wrong');
});
it('uses custom fallback for non-Error values', () => {
expect(resolveErrorMessage(undefined, 'worker failed')).toBe('worker failed');
});
});
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { buildHotlinkImagePath } from '@/utils/share/buildHotlinkImagePath';
describe('buildHotlinkImagePath', () => {
it('places upload id and secret in the path segments', () => {
const uploadId = 'upload-uuid';
const secret = 'url-safe-secret';
expect(buildHotlinkImagePath(uploadId, secret)).toBe(`/i/${uploadId}/${secret}`);
});
});
@@ -0,0 +1 @@
export const buildHotlinkImagePath = (uploadId: string, secret: string): string => `/i/${uploadId}/${secret}`;
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { buildZkViewerPath } from '@/utils/share/buildZkViewerPath';
describe('buildZkViewerPath', () => {
it('places upload id in the path and fragment key in the hash', () => {
const uploadId = 'abc-123';
const fragmentKey = 'dGVzdC1rZXk';
expect(buildZkViewerPath(uploadId, fragmentKey)).toBe(`/v/${uploadId}#${fragmentKey}`);
});
});
@@ -0,0 +1 @@
export const buildZkViewerPath = (uploadId: string, fragmentKey: string): string => `/v/${uploadId}#${fragmentKey}`;
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength';
import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm';
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 { toArrayBuffer } from '@/utils/toArrayBuffer';
describe('toArrayBuffer', () => {
it('returns the same ArrayBuffer instance', () => {
const buffer = new ArrayBuffer(8);
expect(toArrayBuffer(buffer)).toBe(buffer);
});
it('copies a Uint8Array view into a standalone ArrayBuffer', () => {
const pool = new Uint8Array([0, 0, 1, 2, 3, 0]);
const view = pool.subarray(2, 5);
const copied = toArrayBuffer(view);
expect(copied).not.toBe(pool.buffer);
expect(new Uint8Array(copied!)).toEqual(new Uint8Array([1, 2, 3]));
});
it('returns null for unsupported values', () => {
expect(toArrayBuffer(null)).toBeNull();
expect(toArrayBuffer('bytes')).toBeNull();
});
it('supports Web Crypto when DEK is a subarray of a larger buffer', async () => {
const dek = generateDek();
const pool = new Uint8Array(dek.byteLength + 4);
pool.set([9, 8, 7, 6], 0);
pool.set(dek, 4);
const dekView = pool.subarray(4, 4 + AES_GCM_KEY_BYTE_LENGTH);
const plaintext = new TextEncoder().encode('subarray-dek');
const iv = generateIv();
const key = await importAesGcmKey(dekView);
const ciphertext = await encryptAesGcm(plaintext, key, iv);
const recovered = await decryptAesGcm(ciphertext, key, iv);
expect(recovered).toEqual(plaintext);
});
});
+2
View File
@@ -1,3 +1,5 @@
export function toArrayBuffer(data: ArrayBuffer | Uint8Array): ArrayBuffer;
export function toArrayBuffer(data: unknown): ArrayBuffer | null;
export function toArrayBuffer(data: unknown): ArrayBuffer | null {
if (data instanceof ArrayBuffer) {
return data;
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
import { decryptPasswordProtectedZkUpload } from '@/utils/upload/decryptPasswordProtectedZkUpload';
import { prepareZkUploadRequest } from '@/utils/upload/prepareZkUploadRequest';
describe('decryptPasswordProtectedZkUpload', () => {
const passwordOptions = {
expiresAt: null,
maxViews: null,
passwordProtected: true as const,
password: 'viewer-secret'
};
it('decrypts ciphertext when fragment key and password match upload', async () => {
const plaintext = new TextEncoder().encode('password-protected image');
const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, passwordOptions);
const recovered = await decryptPasswordProtectedZkUpload(
base64ToBytes(request.ciphertext),
base64ToBytes(request.iv),
fragmentKey,
passwordOptions.password,
base64ToBytes(request.wrappedDek!),
base64ToBytes(request.wrapIv!),
base64ToBytes(request.salt!)
);
expect(recovered).toEqual(plaintext);
});
it('rejects decryption with the wrong password', async () => {
const plaintext = new Uint8Array([42]);
const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, passwordOptions);
await expect(
decryptPasswordProtectedZkUpload(
base64ToBytes(request.ciphertext),
base64ToBytes(request.iv),
fragmentKey,
'wrong-password',
base64ToBytes(request.wrappedDek!),
base64ToBytes(request.wrapIv!),
base64ToBytes(request.salt!)
)
).rejects.toThrow();
});
});
@@ -0,0 +1,20 @@
import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm';
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes';
import { unwrapZkDek } from '@/utils/upload/unwrapZkDek';
export const decryptPasswordProtectedZkUpload = async (
ciphertext: Uint8Array,
iv: Uint8Array,
fragmentKey: string,
password: string,
wrappedDek: Uint8Array,
wrapIv: Uint8Array,
salt: Uint8Array
): Promise<Uint8Array> => {
const fragmentSecret = base64UrlToBytes(fragmentKey);
const dek = await unwrapZkDek(wrappedDek, wrapIv, salt, password, fragmentSecret);
const key = await importAesGcmKey(dek);
return decryptAesGcm(ciphertext, key, iv);
};
@@ -0,0 +1,24 @@
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 { dek, ciphertext, iv } = await encryptZkUpload(plaintext);
const fragmentKey = bytesToBase64Url(dek);
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_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength';
import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength';
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
import { encryptZkUpload } from '@/utils/upload/encryptZkUpload';
describe('encryptZkUpload', () => {
it('returns a random DEK and IV', async () => {
const encrypted = await encryptZkUpload(new Uint8Array([1]));
expect(encrypted.dek).toHaveLength(AES_GCM_KEY_BYTE_LENGTH);
expect(encrypted.iv).toHaveLength(AES_GCM_IV_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,14 @@
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';
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 { 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,112 @@
import { describe, expect, it } from 'vitest';
import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
import { buildZkViewerPath } from '@/utils/share/buildZkViewerPath';
import { decryptPasswordProtectedZkUpload } from '@/utils/upload/decryptPasswordProtectedZkUpload';
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);
});
describe('password protected', () => {
const passwordOptions = {
expiresAt: null,
maxViews: null,
passwordProtected: true as const,
password: 'batch-secret'
};
it('includes wrap fields and omits password from the POST body', async () => {
const { request, fragmentKey } = await prepareZkUploadRequest(new Uint8Array([1]), passwordOptions);
expect(request.passwordProtected).toBe(true);
expect(request.wrappedDek).toMatch(/^[A-Za-z0-9+/]+=*$/);
expect(request.wrapIv).toMatch(/^[A-Za-z0-9+/]+=*$/);
expect(request.salt).toMatch(/^[A-Za-z0-9+/]+=*$/);
expect(request).not.toHaveProperty('password');
expect(fragmentKey).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('does not decrypt with URL fragment alone', async () => {
const plaintext = new TextEncoder().encode('protected');
const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, passwordOptions);
await expect(
decryptZkUpload(
base64ToBytes(request.ciphertext),
base64ToBytes(request.iv),
fragmentKey
)
).rejects.toThrow();
});
it('roundtrips with fragment key and password', async () => {
const plaintext = new TextEncoder().encode('protected-payload');
const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, passwordOptions);
const recovered = await decryptPasswordProtectedZkUpload(
base64ToBytes(request.ciphertext),
base64ToBytes(request.iv),
fragmentKey,
passwordOptions.password,
base64ToBytes(request.wrappedDek!),
base64ToBytes(request.wrapIv!),
base64ToBytes(request.salt!)
);
expect(recovered).toEqual(plaintext);
});
});
});
@@ -0,0 +1,44 @@
import type { CreateZkUploadRequest } from '@/types/upload/CreateZkUploadRequest';
import type { ZkUploadPrepareOptions } from '@/types/upload/ZkUploadPrepareOptions';
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
import { bytesToBase64Url } from '@/utils/encoding/bytesToBase64Url';
import { generateDek } from '@/utils/crypto/generateDek';
import { encryptZkUpload } from '@/utils/upload/encryptZkUpload';
import { wrapZkDek } from '@/utils/upload/wrapZkDek';
export const prepareZkUploadRequest = async (
plaintext: Uint8Array,
options: ZkUploadPrepareOptions
): Promise<{ request: CreateZkUploadRequest; fragmentKey: string }> => {
if (!options.passwordProtected) {
const { dek, iv, ciphertext } = await encryptZkUpload(plaintext);
return {
fragmentKey: bytesToBase64Url(dek),
request: {
...options,
iv: bytesToBase64(iv),
ciphertext: bytesToBase64(ciphertext),
passwordProtected: false
}
};
}
const { password, ...uploadOptions } = options;
const { dek, iv, ciphertext } = await encryptZkUpload(plaintext);
const fragmentSecret = generateDek();
const { wrappedDek, wrapIv, salt } = await wrapZkDek(dek, password, fragmentSecret);
return {
fragmentKey: bytesToBase64Url(fragmentSecret),
request: {
...uploadOptions,
iv: bytesToBase64(iv),
ciphertext: bytesToBase64(ciphertext),
passwordProtected: true,
wrappedDek: bytesToBase64(wrappedDek),
wrapIv: bytesToBase64(wrapIv),
salt: bytesToBase64(salt)
}
};
};
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { generateDek } from '@/utils/crypto/generateDek';
import { unwrapZkDek } from '@/utils/upload/unwrapZkDek';
import { wrapZkDek } from '@/utils/upload/wrapZkDek';
describe('unwrapZkDek', () => {
it('fails unwrap with a mismatched fragment secret', async () => {
const dek = generateDek();
const fragmentSecret = generateDek();
const wrapped = await wrapZkDek(dek, 'password', fragmentSecret);
const otherFragment = generateDek();
await expect(
unwrapZkDek(wrapped.wrappedDek, wrapped.wrapIv, wrapped.salt, 'password', otherFragment)
).rejects.toThrow();
});
});
+16
View File
@@ -0,0 +1,16 @@
import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm';
import { deriveZkWrapKeyBytes } from '@/utils/crypto/deriveZkWrapKeyBytes';
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
export const unwrapZkDek = async (
wrappedDek: Uint8Array,
wrapIv: Uint8Array,
salt: Uint8Array,
password: string,
fragmentSecret: Uint8Array
): Promise<Uint8Array> => {
const wrapKeyBytes = await deriveZkWrapKeyBytes(password, salt, fragmentSecret);
const wrapKey = await importAesGcmKey(wrapKeyBytes);
return decryptAesGcm(wrappedDek, wrapKey, wrapIv);
};
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { generateDek } from '@/utils/crypto/generateDek';
import { unwrapZkDek } from '@/utils/upload/unwrapZkDek';
import { wrapZkDek } from '@/utils/upload/wrapZkDek';
describe('wrapZkDek', () => {
it('roundtrips the DEK with password and fragment secret', async () => {
const dek = generateDek();
const fragmentSecret = generateDek();
const password = 'viewer-password';
const wrapped = await wrapZkDek(dek, password, fragmentSecret);
const recovered = await unwrapZkDek(
wrapped.wrappedDek,
wrapped.wrapIv,
wrapped.salt,
password,
fragmentSecret
);
expect(recovered).toEqual(dek);
});
it('fails unwrap with wrong password', async () => {
const dek = generateDek();
const fragmentSecret = generateDek();
const wrapped = await wrapZkDek(dek, 'correct', fragmentSecret);
await expect(
unwrapZkDek(wrapped.wrappedDek, wrapped.wrapIv, wrapped.salt, 'wrong', fragmentSecret)
).rejects.toThrow();
});
});
+21
View File
@@ -0,0 +1,21 @@
import type { WrappedZkDek } from '@/types/upload/WrappedZkDek';
import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm';
import { deriveZkWrapKeyBytes } from '@/utils/crypto/deriveZkWrapKeyBytes';
import { generateIv } from '@/utils/crypto/generateIv';
import { generateRandomBytes } from '@/utils/crypto/generateRandomBytes';
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
export const wrapZkDek = async (
dek: Uint8Array,
password: string,
fragmentSecret: Uint8Array
): Promise<WrappedZkDek> => {
const saltByteLength = 16;
const salt = generateRandomBytes(saltByteLength);
const wrapKeyBytes = await deriveZkWrapKeyBytes(password, salt, fragmentSecret);
const wrapIv = generateIv();
const wrapKey = await importAesGcmKey(wrapKeyBytes);
const wrappedDek = await encryptAesGcm(dek, wrapKey, wrapIv);
return { wrappedDek, wrapIv, salt };
};
+1 -1
View File
@@ -16,5 +16,5 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
"include": ["vite.config.ts", "vitest.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
test: {
environment: 'node',
include: ['src/**/*.spec.ts']
}
});