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:
@@ -1,3 +1,4 @@
|
|||||||
|
import { AES_GCM_AUTH_TAG_BYTE_LENGTH } from '../consts/crypto/aesGcmAuthTagByteLength';
|
||||||
import { AltchaConfig } from '../types/config/AltchaConfig';
|
import { AltchaConfig } from '../types/config/AltchaConfig';
|
||||||
import { AppConfig } from '../types/config/AppConfig';
|
import { AppConfig } from '../types/config/AppConfig';
|
||||||
import { NodeEnv } from '../types/NodeEnv';
|
import { NodeEnv } from '../types/NodeEnv';
|
||||||
@@ -34,7 +35,6 @@ export const getAltchaConfig = (): AltchaConfig => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getAppConfig = (): AppConfig => {
|
export const getAppConfig = (): AppConfig => {
|
||||||
const aesGcmCiphertextOverheadBytes = 16;
|
|
||||||
const jsonBase64ByteRatioNumerator = 4;
|
const jsonBase64ByteRatioNumerator = 4;
|
||||||
const jsonBase64ByteRatioDenominator = 3;
|
const jsonBase64ByteRatioDenominator = 3;
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ export const getAppConfig = (): AppConfig => {
|
|||||||
upload: {
|
upload: {
|
||||||
maxViewsMax: envInt('VALIDATION_UPLOAD_MAX_VIEWS_MAX'),
|
maxViewsMax: envInt('VALIDATION_UPLOAD_MAX_VIEWS_MAX'),
|
||||||
maxImageBytes,
|
maxImageBytes,
|
||||||
maxCiphertextBytes: maxImageBytes + aesGcmCiphertextOverheadBytes,
|
maxCiphertextBytes: maxImageBytes + AES_GCM_AUTH_TAG_BYTE_LENGTH,
|
||||||
maxRequestBodyBytes:
|
maxRequestBodyBytes:
|
||||||
Math.ceil((maxImageBytes * jsonBase64ByteRatioNumerator) / jsonBase64ByteRatioDenominator) +
|
Math.ceil((maxImageBytes * jsonBase64ByteRatioNumerator) / jsonBase64ByteRatioDenominator) +
|
||||||
jsonBodyPaddingBytes
|
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 { IsBoolean, IsNotEmpty, ValidateIf } from 'class-validator';
|
||||||
|
import { AES_GCM_IV_BYTE_LENGTH } from '../../../consts/crypto/aesGcmIvByteLength';
|
||||||
import { IsBase64 } from '../../../validation/decorators/isBase64';
|
import { IsBase64 } from '../../../validation/decorators/isBase64';
|
||||||
import { UploadCiphertextOptionsDto } from './UploadCiphertextOptionsDto';
|
import { UploadCiphertextOptionsDto } from './UploadCiphertextOptionsDto';
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ export class CreateZkUploadDto extends UploadCiphertextOptionsDto {
|
|||||||
|
|
||||||
@ValidateIf((dto: CreateZkUploadDto) => dto.passwordProtected === true)
|
@ValidateIf((dto: CreateZkUploadDto) => dto.passwordProtected === true)
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsBase64({ byteLength: 12 })
|
@IsBase64({ byteLength: AES_GCM_IV_BYTE_LENGTH })
|
||||||
wrapIv?: string;
|
wrapIv?: string;
|
||||||
|
|
||||||
@ValidateIf((dto: CreateZkUploadDto) => dto.passwordProtected === true)
|
@ValidateIf((dto: CreateZkUploadDto) => dto.passwordProtected === true)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IsNotEmpty } from 'class-validator';
|
import { IsNotEmpty } from 'class-validator';
|
||||||
|
import { AES_GCM_IV_BYTE_LENGTH } from '../../../consts/crypto/aesGcmIvByteLength';
|
||||||
import { getAppConfig } from '../../../config';
|
import { getAppConfig } from '../../../config';
|
||||||
import { IsBase64 } from '../../../validation/decorators/isBase64';
|
import { IsBase64 } from '../../../validation/decorators/isBase64';
|
||||||
import { UploadOptionsDto } from './UploadOptionsDto';
|
import { UploadOptionsDto } from './UploadOptionsDto';
|
||||||
@@ -11,7 +12,7 @@ const {
|
|||||||
|
|
||||||
export class UploadCiphertextOptionsDto extends UploadOptionsDto {
|
export class UploadCiphertextOptionsDto extends UploadOptionsDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsBase64({ byteLength: 12 })
|
@IsBase64({ byteLength: AES_GCM_IV_BYTE_LENGTH })
|
||||||
iv: string;
|
iv: string;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
Generated
+1501
File diff suppressed because it is too large
Load Diff
@@ -7,12 +7,14 @@
|
|||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
"postinstall": "node patches/patch-exiftool-stderr.mjs && node patches/patch-zeroperl-worker-fetch.mjs"
|
"postinstall": "node patches/patch-exiftool-stderr.mjs && node patches/patch-zeroperl-worker-fetch.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@uswriting/exiftool": "1.0.9",
|
"@uswriting/exiftool": "1.0.9",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
|
"hash-wasm": "^4.12.0",
|
||||||
"pinia": "^4.0.3",
|
"pinia": "^4.0.3",
|
||||||
"pretty-bytes": "^7.2.0",
|
"pretty-bytes": "^7.2.0",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.0.4",
|
"vite": "^8.0.4",
|
||||||
|
"vitest": "^3.2.7",
|
||||||
"vue-tsc": "^3.2.6"
|
"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 =
|
const EXIFTOOL_WORKER_INIT_JPEG_BASE64 =
|
||||||
'/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AL+AD//Z';
|
'/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 {
|
export function toArrayBuffer(data: unknown): ArrayBuffer | null {
|
||||||
if (data instanceof ArrayBuffer) {
|
if (data instanceof ArrayBuffer) {
|
||||||
return data;
|
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,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": 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