Slave/client crypto #7
@@ -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()
|
||||
|
||||
Generated
+1501
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -16,5 +16,5 @@
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user