From f2987da2fe3989074f0f94396d2d2f69b05afde1 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Mon, 19 Oct 2026 02:59:52 +0200 Subject: [PATCH 1/5] update upload validation to use shared AES-GCM size constants on the backend. --- backend/src/config/index.ts | 4 ++-- backend/src/consts/crypto/aesGcmAuthTagByteLength.ts | 1 + backend/src/consts/crypto/aesGcmIvByteLength.ts | 1 + backend/src/modules/upload/dto/CreateZkUploadDto.ts | 3 ++- backend/src/modules/upload/dto/UploadCiphertextOptionsDto.ts | 3 ++- 5 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 backend/src/consts/crypto/aesGcmAuthTagByteLength.ts create mode 100644 backend/src/consts/crypto/aesGcmIvByteLength.ts diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 32c8a9c..c39b5b7 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -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 diff --git a/backend/src/consts/crypto/aesGcmAuthTagByteLength.ts b/backend/src/consts/crypto/aesGcmAuthTagByteLength.ts new file mode 100644 index 0000000..d80fc31 --- /dev/null +++ b/backend/src/consts/crypto/aesGcmAuthTagByteLength.ts @@ -0,0 +1 @@ +export const AES_GCM_AUTH_TAG_BYTE_LENGTH = 16; diff --git a/backend/src/consts/crypto/aesGcmIvByteLength.ts b/backend/src/consts/crypto/aesGcmIvByteLength.ts new file mode 100644 index 0000000..4c332a1 --- /dev/null +++ b/backend/src/consts/crypto/aesGcmIvByteLength.ts @@ -0,0 +1 @@ +export const AES_GCM_IV_BYTE_LENGTH = 12; diff --git a/backend/src/modules/upload/dto/CreateZkUploadDto.ts b/backend/src/modules/upload/dto/CreateZkUploadDto.ts index 9e5ca8f..d915096 100644 --- a/backend/src/modules/upload/dto/CreateZkUploadDto.ts +++ b/backend/src/modules/upload/dto/CreateZkUploadDto.ts @@ -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) diff --git a/backend/src/modules/upload/dto/UploadCiphertextOptionsDto.ts b/backend/src/modules/upload/dto/UploadCiphertextOptionsDto.ts index 5414cf9..01bc070 100644 --- a/backend/src/modules/upload/dto/UploadCiphertextOptionsDto.ts +++ b/backend/src/modules/upload/dto/UploadCiphertextOptionsDto.ts @@ -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() From 0238858c8bb0f276ba450db3f797b155f6ac8a78 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Mon, 19 Oct 2026 03:00:03 +0200 Subject: [PATCH 2/5] add frontend AES-GCM and base64 helpers with Vitest coverage. --- frontend/package-lock.json | 1494 +++++++++++++++++ frontend/package.json | 2 + .../consts/crypto/aesGcmAuthTagByteLength.ts | 1 + .../src/consts/crypto/aesGcmIvByteLength.ts | 1 + .../src/consts/crypto/aesGcmKeyByteLength.ts | 1 + frontend/src/consts/exiftoolWorkerInitJpeg.ts | 2 +- .../src/utils/crypto/decryptAesGcm.spec.ts | 66 + frontend/src/utils/crypto/decryptAesGcm.ts | 19 + .../src/utils/crypto/encryptAesGcm.spec.ts | 109 ++ frontend/src/utils/crypto/encryptAesGcm.ts | 23 + frontend/src/utils/crypto/generateDek.spec.ts | 19 + frontend/src/utils/crypto/generateDek.ts | 4 + frontend/src/utils/crypto/generateIv.spec.ts | 19 + frontend/src/utils/crypto/generateIv.ts | 4 + .../utils/crypto/generateRandomBytes.spec.ts | 24 + .../src/utils/crypto/generateRandomBytes.ts | 5 + .../src/utils/crypto/importAesGcmKey.spec.ts | 35 + frontend/src/utils/crypto/importAesGcmKey.ts | 10 + .../src/utils/encoding/base64ToBytes.spec.ts | 32 + .../src/utils/{ => encoding}/base64ToBytes.ts | 0 .../utils/encoding/base64UrlToBytes.spec.ts | 27 + .../src/utils/encoding/base64UrlToBytes.ts | 13 + .../src/utils/encoding/bytesToBase64.spec.ts | 40 + frontend/src/utils/encoding/bytesToBase64.ts | 5 + .../utils/encoding/bytesToBase64Url.spec.ts | 36 + .../src/utils/encoding/bytesToBase64Url.ts | 11 + frontend/src/utils/toArrayBuffer.ts | 2 + frontend/tsconfig.node.json | 2 +- frontend/vitest.config.ts | 17 + 29 files changed, 2021 insertions(+), 2 deletions(-) create mode 100644 frontend/src/consts/crypto/aesGcmAuthTagByteLength.ts create mode 100644 frontend/src/consts/crypto/aesGcmIvByteLength.ts create mode 100644 frontend/src/consts/crypto/aesGcmKeyByteLength.ts create mode 100644 frontend/src/utils/crypto/decryptAesGcm.spec.ts create mode 100644 frontend/src/utils/crypto/decryptAesGcm.ts create mode 100644 frontend/src/utils/crypto/encryptAesGcm.spec.ts create mode 100644 frontend/src/utils/crypto/encryptAesGcm.ts create mode 100644 frontend/src/utils/crypto/generateDek.spec.ts create mode 100644 frontend/src/utils/crypto/generateDek.ts create mode 100644 frontend/src/utils/crypto/generateIv.spec.ts create mode 100644 frontend/src/utils/crypto/generateIv.ts create mode 100644 frontend/src/utils/crypto/generateRandomBytes.spec.ts create mode 100644 frontend/src/utils/crypto/generateRandomBytes.ts create mode 100644 frontend/src/utils/crypto/importAesGcmKey.spec.ts create mode 100644 frontend/src/utils/crypto/importAesGcmKey.ts create mode 100644 frontend/src/utils/encoding/base64ToBytes.spec.ts rename frontend/src/utils/{ => encoding}/base64ToBytes.ts (100%) create mode 100644 frontend/src/utils/encoding/base64UrlToBytes.spec.ts create mode 100644 frontend/src/utils/encoding/base64UrlToBytes.ts create mode 100644 frontend/src/utils/encoding/bytesToBase64.spec.ts create mode 100644 frontend/src/utils/encoding/bytesToBase64.ts create mode 100644 frontend/src/utils/encoding/bytesToBase64Url.spec.ts create mode 100644 frontend/src/utils/encoding/bytesToBase64Url.ts create mode 100644 frontend/vitest.config.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2075cd9..c571614 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,7 @@ "@vue/tsconfig": "^0.9.1", "typescript": "~6.0.2", "vite": "^8.0.4", + "vitest": "^3.2.7", "vue-tsc": "^3.2.6" } }, @@ -79,6 +80,448 @@ "node": ">=6.9.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -124,6 +567,23 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@oxc-project/types": { "version": "0.151.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", @@ -379,6 +839,356 @@ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "license": "MIT" }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.10.tgz", + "integrity": "sha512-oJ9ZfVIZjSjts0ZIYA48+5T0vurE5fQfX/Liad2U52Y6EN99x/OCdjGGuKMDP6jSk2i2lY6jwwA3X0drEc4PBg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.10.tgz", + "integrity": "sha512-LylBINgYYwgHNz8OMCy5+Y0w/LEgRYE0PcAIZfEuTuLNMf0S40GSLX6SFwqZsBLplS7RIHHy4zOAbl8Y5cQEvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.10.tgz", + "integrity": "sha512-vbuT3VT1GBI2fAOoUJUrsHVoOg9nbrFXTM7e3DSnb9PYf4n6jZCN8v4Vo8O6x4u5B67+/c803b8BtCZe2nIeqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.10.tgz", + "integrity": "sha512-qYUp/qYvM3T4cF+VWalPl+przBUESILxi+qZ1m1MVRHpg06vS+jHaxXubjCaRpXjLJPDFdXzOZ0FABDghHOhNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.10.tgz", + "integrity": "sha512-v9NRQG+JztbP4DP/0yuhSyDcEJk047lYbqeNnqIgbXITcVDjGDS7OpkW5nadSY3tJ7MY8W1qIMMqP4Q3Q8BDjQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.10.tgz", + "integrity": "sha512-CHc9LBHdxhn6/NXfb9IegXCWoz5VlvcDOx9QrzZ9q+XYhFlfzwymSWJjOlZcEMMtC1zgMV7qxDe6U18AI1SG0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.10.tgz", + "integrity": "sha512-ble2WR7kWfzZFJUd3ODMGCJn1XDszptfRC0QvWc0h9/EA6MxnrQ1mvbjPZifEdgHyfHhDrwWeKeJONDITcrHSg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.10.tgz", + "integrity": "sha512-DMCYegihxBMKvLpODdk4OsvIgxtHTi+Oddf1Pm3d6wMVNjcBiG2pqyXjMPy55HgG1gCdEFr9VfhB1CmvuKn1lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.10.tgz", + "integrity": "sha512-HQ6HuVH9TmhvPWIas7MA/KeAROgdwqH3REpUU6hZq47NW7m1b1QCrHgM+M3+N/B8DfMt88x1/z79ppRc9RflCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.10.tgz", + "integrity": "sha512-avBhxba1DdctX0kxKkN3JWdTSg72QsxSn6FdfhZ9MkL3TTYDOXqxY1GqbC7VNttlgviK7tCe2No5btfRkFgeIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.10.tgz", + "integrity": "sha512-UtQMex7fiXq0W/GqcWRpy6XKqUbXgQeGKB+Bd5rJngmHzpI+3kjwpWFLAhKg02onFsIMSh+Zx0zp9JIMQ7/05A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.10.tgz", + "integrity": "sha512-0Aum2pUsCSuOopi6oka+nR1DTj/nfvc1xjL979lvh6kXUjNa30TCX7sKFjyMsrX7bK3bNVs0FsJwQcE2HCWX1Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.10.tgz", + "integrity": "sha512-4dgsouKal3qfMFORgtNigLPt7K3HXiO6jTr7+nFJv1GWu1OkZx0kvQIXbK4sjrIGnaLBk0s80eQCQ9hPIelLrw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.10.tgz", + "integrity": "sha512-d7NE89try3jisYzBD7J9M5iwfgcrXYC4dwHEjQ3HIWBftvwch1U/cy3iZ26SthtUOyClKiu7iqYiFjQEwiOlqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.10.tgz", + "integrity": "sha512-dqB5ChSBp91JWXxpCKAS5ZVAE38esKQvdfyqANm0lF5JPiQrXnYwgG3oVBQmuNRcQ1WHkA3j5qYLsrl2CtfhCA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.10.tgz", + "integrity": "sha512-7GMf3vbQQslorWLmfsbi5fdt9RVnglk205XpOMFvW6UY5jE5oJc6JLieQJPHtsYlZ2EO0PtC06nY9Hz/IG8guw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.10.tgz", + "integrity": "sha512-tuKbkB23DKc5LG+irPSUnZZztRHqC3CNmyxeWloueBteHB+i+1HkRCMzAE41lseLDlarybeLhqfibFitKOxKMg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.10.tgz", + "integrity": "sha512-ntHtQOMw7oIshHRF1swV3vyBw+beQt9PeZ04O3D32EeDZUMoHMJuZKp4/fDllH/+xogeK0GUCONVfjjKusqKLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.10.tgz", + "integrity": "sha512-Dlh8q8r145aYgh+N3gNIG1dz35Fz/FCxe5hs/43orDsffjP8S1P4Xz5I4FpSJsDS4LpQw1QmswZkQlUbkCldjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.10.tgz", + "integrity": "sha512-VBJhHWz/55u45CWFv9OmfSRDgJDVENESgFxSAn13gIKJpTuFJbP4/7ymMZhDfeKFx1zGn1XBsLnNB7Ryy71qww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.10.tgz", + "integrity": "sha512-Vjcazg+EEzUfIz+LcaxS6YtbQHo+sv0sCD+5VbwhY504bCKNW4OOreVgKq61pHT2esRQ/+AXYVPStDwxSvAOdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.10.tgz", + "integrity": "sha512-jJAaP4El+HWxYkM0jeJghASaFgfnluY0FRHhyHXxU6STs/nBhKMIxIMDrJEFg2ftKz6orX9WAo4diVan5NugNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.10.tgz", + "integrity": "sha512-bXXdRUfRx9PaHCToKt8R/7wjDV6Tbk/hmSHK0uQBKmD3rj4jkuYndct8uGX77ZuZsePrVkQbWUgPR2jJhCTMpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.10.tgz", + "integrity": "sha512-57Idx+VkEOQeG6Ys6sZOBFT7FfbeyxGv9A3k1m+Lf/z2SwqT40rNWbAINzjPHSMoCCBvY7Fnb9RGZWQin/HHAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.10.tgz", + "integrity": "sha512-vrvuasIpm/xf+BpZbzgirku0WnrQZMAc+H2Ai26jzqBxYEmQQE4DdJb7h8erkJMfRRipx0ma2Cty8jJwUm9ocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -885,6 +1695,31 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", @@ -921,6 +1756,94 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.28", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", @@ -1132,6 +2055,16 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1160,6 +2093,16 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1173,6 +2116,33 @@ "node": ">= 0.4" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1208,6 +2178,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1283,6 +2263,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -1310,12 +2297,64 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1515,6 +2554,13 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -1764,6 +2810,13 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1847,6 +2900,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/perfect-debounce": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", @@ -1979,6 +3049,59 @@ "@rolldown/binding-win32-x64-msvc": "1.2.11" } }, + "node_modules/rollup": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.10.tgz", + "integrity": "sha512-8N52YH1B5JzBP+B1rkcBERG7rfRTGAM0RmUpyd76yyRfTljuG452GCNbVm6MPzs1+gXBha6Rw5/oHRFohOpfDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.10", + "@rollup/rollup-android-arm64": "4.64.10", + "@rollup/rollup-darwin-arm64": "4.64.10", + "@rollup/rollup-darwin-x64": "4.64.10", + "@rollup/rollup-freebsd-arm64": "4.64.10", + "@rollup/rollup-freebsd-x64": "4.64.10", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.10", + "@rollup/rollup-linux-arm-musleabihf": "4.64.10", + "@rollup/rollup-linux-arm64-gnu": "4.64.10", + "@rollup/rollup-linux-arm64-musl": "4.64.10", + "@rollup/rollup-linux-loong64-gnu": "4.64.10", + "@rollup/rollup-linux-loong64-musl": "4.64.10", + "@rollup/rollup-linux-ppc64-gnu": "4.64.10", + "@rollup/rollup-linux-ppc64-musl": "4.64.10", + "@rollup/rollup-linux-riscv64-gnu": "4.64.10", + "@rollup/rollup-linux-riscv64-musl": "4.64.10", + "@rollup/rollup-linux-s390x-gnu": "4.64.10", + "@rollup/rollup-linux-x64-gnu": "4.64.10", + "@rollup/rollup-linux-x64-musl": "4.64.10", + "@rollup/rollup-openbsd-x64": "4.64.10", + "@rollup/rollup-openharmony-arm64": "4.64.10", + "@rollup/rollup-win32-arm64-msvc": "4.64.10", + "@rollup/rollup-win32-ia32-msvc": "4.64.10", + "@rollup/rollup-win32-x64-gnu": "4.64.10", + "@rollup/rollup-win32-x64-msvc": "4.64.10", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1988,6 +3111,33 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -2007,6 +3157,20 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2023,6 +3187,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -2121,6 +3315,289 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/vscode-uri": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", @@ -2187,6 +3664,23 @@ "typescript": ">=5.0.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yaml": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 246539e..86bbf3e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "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": { @@ -25,6 +26,7 @@ "@vue/tsconfig": "^0.9.1", "typescript": "~6.0.2", "vite": "^8.0.4", + "vitest": "^3.2.7", "vue-tsc": "^3.2.6" } } diff --git a/frontend/src/consts/crypto/aesGcmAuthTagByteLength.ts b/frontend/src/consts/crypto/aesGcmAuthTagByteLength.ts new file mode 100644 index 0000000..d80fc31 --- /dev/null +++ b/frontend/src/consts/crypto/aesGcmAuthTagByteLength.ts @@ -0,0 +1 @@ +export const AES_GCM_AUTH_TAG_BYTE_LENGTH = 16; diff --git a/frontend/src/consts/crypto/aesGcmIvByteLength.ts b/frontend/src/consts/crypto/aesGcmIvByteLength.ts new file mode 100644 index 0000000..4c332a1 --- /dev/null +++ b/frontend/src/consts/crypto/aesGcmIvByteLength.ts @@ -0,0 +1 @@ +export const AES_GCM_IV_BYTE_LENGTH = 12; diff --git a/frontend/src/consts/crypto/aesGcmKeyByteLength.ts b/frontend/src/consts/crypto/aesGcmKeyByteLength.ts new file mode 100644 index 0000000..96b9c15 --- /dev/null +++ b/frontend/src/consts/crypto/aesGcmKeyByteLength.ts @@ -0,0 +1 @@ +export const AES_GCM_KEY_BYTE_LENGTH = 32; diff --git a/frontend/src/consts/exiftoolWorkerInitJpeg.ts b/frontend/src/consts/exiftoolWorkerInitJpeg.ts index b2d0d60..b1940cc 100644 --- a/frontend/src/consts/exiftoolWorkerInitJpeg.ts +++ b/frontend/src/consts/exiftoolWorkerInitJpeg.ts @@ -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'; diff --git a/frontend/src/utils/crypto/decryptAesGcm.spec.ts b/frontend/src/utils/crypto/decryptAesGcm.spec.ts new file mode 100644 index 0000000..5896663 --- /dev/null +++ b/frontend/src/utils/crypto/decryptAesGcm.spec.ts @@ -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(); + }); +}); diff --git a/frontend/src/utils/crypto/decryptAesGcm.ts b/frontend/src/utils/crypto/decryptAesGcm.ts new file mode 100644 index 0000000..5836930 --- /dev/null +++ b/frontend/src/utils/crypto/decryptAesGcm.ts @@ -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 => { + 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); +}; diff --git a/frontend/src/utils/crypto/encryptAesGcm.spec.ts b/frontend/src/utils/crypto/encryptAesGcm.spec.ts new file mode 100644 index 0000000..6ef9f8a --- /dev/null +++ b/frontend/src/utils/crypto/encryptAesGcm.spec.ts @@ -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); + }); +}); diff --git a/frontend/src/utils/crypto/encryptAesGcm.ts b/frontend/src/utils/crypto/encryptAesGcm.ts new file mode 100644 index 0000000..dea9551 --- /dev/null +++ b/frontend/src/utils/crypto/encryptAesGcm.ts @@ -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 => { + 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); +}; diff --git a/frontend/src/utils/crypto/generateDek.spec.ts b/frontend/src/utils/crypto/generateDek.spec.ts new file mode 100644 index 0000000..22dca80 --- /dev/null +++ b/frontend/src/utils/crypto/generateDek.spec.ts @@ -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); + }); +}); diff --git a/frontend/src/utils/crypto/generateDek.ts b/frontend/src/utils/crypto/generateDek.ts new file mode 100644 index 0000000..25dab54 --- /dev/null +++ b/frontend/src/utils/crypto/generateDek.ts @@ -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); diff --git a/frontend/src/utils/crypto/generateIv.spec.ts b/frontend/src/utils/crypto/generateIv.spec.ts new file mode 100644 index 0000000..c3e5979 --- /dev/null +++ b/frontend/src/utils/crypto/generateIv.spec.ts @@ -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); + }); +}); diff --git a/frontend/src/utils/crypto/generateIv.ts b/frontend/src/utils/crypto/generateIv.ts new file mode 100644 index 0000000..6dcff36 --- /dev/null +++ b/frontend/src/utils/crypto/generateIv.ts @@ -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); diff --git a/frontend/src/utils/crypto/generateRandomBytes.spec.ts b/frontend/src/utils/crypto/generateRandomBytes.spec.ts new file mode 100644 index 0000000..0711ecf --- /dev/null +++ b/frontend/src/utils/crypto/generateRandomBytes.spec.ts @@ -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); + }); +}); diff --git a/frontend/src/utils/crypto/generateRandomBytes.ts b/frontend/src/utils/crypto/generateRandomBytes.ts new file mode 100644 index 0000000..61e55a2 --- /dev/null +++ b/frontend/src/utils/crypto/generateRandomBytes.ts @@ -0,0 +1,5 @@ +export const generateRandomBytes = (length: number): Uint8Array => { + const bytes = new Uint8Array(length); + + return crypto.getRandomValues(bytes); +}; diff --git a/frontend/src/utils/crypto/importAesGcmKey.spec.ts b/frontend/src/utils/crypto/importAesGcmKey.spec.ts new file mode 100644 index 0000000..9158bbe --- /dev/null +++ b/frontend/src/utils/crypto/importAesGcmKey.spec.ts @@ -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` + ); + }); +}); diff --git a/frontend/src/utils/crypto/importAesGcmKey.ts b/frontend/src/utils/crypto/importAesGcmKey.ts new file mode 100644 index 0000000..77eda21 --- /dev/null +++ b/frontend/src/utils/crypto/importAesGcmKey.ts @@ -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 => { + 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']); +}; diff --git a/frontend/src/utils/encoding/base64ToBytes.spec.ts b/frontend/src/utils/encoding/base64ToBytes.spec.ts new file mode 100644 index 0000000..e8e9d07 --- /dev/null +++ b/frontend/src/utils/encoding/base64ToBytes.spec.ts @@ -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(); + }); +}); diff --git a/frontend/src/utils/base64ToBytes.ts b/frontend/src/utils/encoding/base64ToBytes.ts similarity index 100% rename from frontend/src/utils/base64ToBytes.ts rename to frontend/src/utils/encoding/base64ToBytes.ts diff --git a/frontend/src/utils/encoding/base64UrlToBytes.spec.ts b/frontend/src/utils/encoding/base64UrlToBytes.spec.ts new file mode 100644 index 0000000..27f2c34 --- /dev/null +++ b/frontend/src/utils/encoding/base64UrlToBytes.spec.ts @@ -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(); + }); +}); diff --git a/frontend/src/utils/encoding/base64UrlToBytes.ts b/frontend/src/utils/encoding/base64UrlToBytes.ts new file mode 100644 index 0000000..463f66e --- /dev/null +++ b/frontend/src/utils/encoding/base64UrlToBytes.ts @@ -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); +}; diff --git a/frontend/src/utils/encoding/bytesToBase64.spec.ts b/frontend/src/utils/encoding/bytesToBase64.spec.ts new file mode 100644 index 0000000..9edac6c --- /dev/null +++ b/frontend/src/utils/encoding/bytesToBase64.spec.ts @@ -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); + }); +}); diff --git a/frontend/src/utils/encoding/bytesToBase64.ts b/frontend/src/utils/encoding/bytesToBase64.ts new file mode 100644 index 0000000..a594d9f --- /dev/null +++ b/frontend/src/utils/encoding/bytesToBase64.ts @@ -0,0 +1,5 @@ +export const bytesToBase64 = (bytes: Uint8Array): string => { + const binary = Array.from(bytes, byte => String.fromCharCode(byte)).join(''); + + return btoa(binary); +}; diff --git a/frontend/src/utils/encoding/bytesToBase64Url.spec.ts b/frontend/src/utils/encoding/bytesToBase64Url.spec.ts new file mode 100644 index 0000000..fa0f0a0 --- /dev/null +++ b/frontend/src/utils/encoding/bytesToBase64Url.spec.ts @@ -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('')) + ); + }); +}); diff --git a/frontend/src/utils/encoding/bytesToBase64Url.ts b/frontend/src/utils/encoding/bytesToBase64Url.ts new file mode 100644 index 0000000..9ded57b --- /dev/null +++ b/frontend/src/utils/encoding/bytesToBase64Url.ts @@ -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; +}; diff --git a/frontend/src/utils/toArrayBuffer.ts b/frontend/src/utils/toArrayBuffer.ts index 981f6d2..83033c5 100644 --- a/frontend/src/utils/toArrayBuffer.ts +++ b/frontend/src/utils/toArrayBuffer.ts @@ -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; diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index c4ea6d9..f6f8420 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -16,5 +16,5 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vitest.config.ts"] } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..1716273 --- /dev/null +++ b/frontend/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'] + } +}); From afbd5e204cbe219b7c20d4c73ba46d138719e7bf Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Mon, 19 Oct 2026 23:05:33 +0200 Subject: [PATCH 3/5] Add client-side ZK and hotlink upload crypto with Pinia store. Encrypt image bytes in the browser, keep keys out of POST bodies (URL fragment or path secret), and POST ciphertext via typed upload API helpers. --- frontend/src/consts/altchaRequestHeader.ts | 1 + frontend/src/stores/upload.ts | 34 +++++++++++ .../upload/CreateHotlinkUploadRequest.ts | 7 +++ .../src/types/upload/CreateZkUploadRequest.ts | 10 +++ .../src/types/upload/CreatedUploadResult.ts | 7 +++ .../types/upload/EncryptedHotlinkUpload.ts | 5 ++ .../src/types/upload/EncryptedZkUpload.ts | 5 ++ frontend/src/types/upload/UploadMode.ts | 1 + .../src/types/upload/UploadOptionsRequest.ts | 4 ++ .../src/utils/share/buildHotlinkImagePath.ts | 1 + frontend/src/utils/share/buildZkViewerPath.ts | 1 + .../src/utils/upload/decryptZkUpload.spec.ts | 23 +++++++ frontend/src/utils/upload/decryptZkUpload.ts | 14 +++++ .../utils/upload/encryptHotlinkUpload.spec.ts | 34 +++++++++++ .../src/utils/upload/encryptHotlinkUpload.ts | 20 ++++++ .../src/utils/upload/encryptZkUpload.spec.ts | 21 +++++++ frontend/src/utils/upload/encryptZkUpload.ts | 19 ++++++ .../prepareHotlinkUploadRequest.spec.ts | 59 ++++++++++++++++++ .../upload/prepareHotlinkUploadRequest.ts | 22 +++++++ .../upload/prepareZkUploadRequest.spec.ts | 61 +++++++++++++++++++ .../utils/upload/prepareZkUploadRequest.ts | 21 +++++++ 21 files changed, 370 insertions(+) create mode 100644 frontend/src/consts/altchaRequestHeader.ts create mode 100644 frontend/src/stores/upload.ts create mode 100644 frontend/src/types/upload/CreateHotlinkUploadRequest.ts create mode 100644 frontend/src/types/upload/CreateZkUploadRequest.ts create mode 100644 frontend/src/types/upload/CreatedUploadResult.ts create mode 100644 frontend/src/types/upload/EncryptedHotlinkUpload.ts create mode 100644 frontend/src/types/upload/EncryptedZkUpload.ts create mode 100644 frontend/src/types/upload/UploadMode.ts create mode 100644 frontend/src/types/upload/UploadOptionsRequest.ts create mode 100644 frontend/src/utils/share/buildHotlinkImagePath.ts create mode 100644 frontend/src/utils/share/buildZkViewerPath.ts create mode 100644 frontend/src/utils/upload/decryptZkUpload.spec.ts create mode 100644 frontend/src/utils/upload/decryptZkUpload.ts create mode 100644 frontend/src/utils/upload/encryptHotlinkUpload.spec.ts create mode 100644 frontend/src/utils/upload/encryptHotlinkUpload.ts create mode 100644 frontend/src/utils/upload/encryptZkUpload.spec.ts create mode 100644 frontend/src/utils/upload/encryptZkUpload.ts create mode 100644 frontend/src/utils/upload/prepareHotlinkUploadRequest.spec.ts create mode 100644 frontend/src/utils/upload/prepareHotlinkUploadRequest.ts create mode 100644 frontend/src/utils/upload/prepareZkUploadRequest.spec.ts create mode 100644 frontend/src/utils/upload/prepareZkUploadRequest.ts diff --git a/frontend/src/consts/altchaRequestHeader.ts b/frontend/src/consts/altchaRequestHeader.ts new file mode 100644 index 0000000..1dbde6e --- /dev/null +++ b/frontend/src/consts/altchaRequestHeader.ts @@ -0,0 +1 @@ +export const ALTCHA_REQUEST_HEADER = 'x-altcha'; diff --git a/frontend/src/stores/upload.ts b/frontend/src/stores/upload.ts new file mode 100644 index 0000000..977f66a --- /dev/null +++ b/frontend/src/stores/upload.ts @@ -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 => { + const { data } = await api.post('/uploads/zk', body, { + headers: getUploadRequestHeaders(altcha) + }); + + return data; + }; + + const createHotlinkUpload = async ( + body: CreateHotlinkUploadRequest, + altcha?: string + ): Promise => { + const { data } = await api.post('/uploads/hotlink', body, { + headers: getUploadRequestHeaders(altcha) + }); + + return data; + }; + + const getUploadRequestHeaders = (altcha?: string) => (altcha ? { [ALTCHA_REQUEST_HEADER]: altcha } : undefined); + + return { + createZkUpload, + createHotlinkUpload + }; +}); diff --git a/frontend/src/types/upload/CreateHotlinkUploadRequest.ts b/frontend/src/types/upload/CreateHotlinkUploadRequest.ts new file mode 100644 index 0000000..c53a50e --- /dev/null +++ b/frontend/src/types/upload/CreateHotlinkUploadRequest.ts @@ -0,0 +1,7 @@ +import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest'; + +export type CreateHotlinkUploadRequest = UploadOptionsRequest & { + iv: string; + ciphertext: string; + mimeType: string; +}; diff --git a/frontend/src/types/upload/CreateZkUploadRequest.ts b/frontend/src/types/upload/CreateZkUploadRequest.ts new file mode 100644 index 0000000..2057623 --- /dev/null +++ b/frontend/src/types/upload/CreateZkUploadRequest.ts @@ -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; +}; diff --git a/frontend/src/types/upload/CreatedUploadResult.ts b/frontend/src/types/upload/CreatedUploadResult.ts new file mode 100644 index 0000000..e8ef58f --- /dev/null +++ b/frontend/src/types/upload/CreatedUploadResult.ts @@ -0,0 +1,7 @@ +import type { UploadMode } from '@/types/upload/UploadMode'; + +export type CreatedUploadResult = { + id: string; + mode: UploadMode; + deleteToken: string; +}; diff --git a/frontend/src/types/upload/EncryptedHotlinkUpload.ts b/frontend/src/types/upload/EncryptedHotlinkUpload.ts new file mode 100644 index 0000000..90a1e3e --- /dev/null +++ b/frontend/src/types/upload/EncryptedHotlinkUpload.ts @@ -0,0 +1,5 @@ +export type EncryptedHotlinkUpload = { + secret: string; + iv: Uint8Array; + ciphertext: Uint8Array; +}; diff --git a/frontend/src/types/upload/EncryptedZkUpload.ts b/frontend/src/types/upload/EncryptedZkUpload.ts new file mode 100644 index 0000000..a128d3a --- /dev/null +++ b/frontend/src/types/upload/EncryptedZkUpload.ts @@ -0,0 +1,5 @@ +export type EncryptedZkUpload = { + fragmentKey: string; + iv: Uint8Array; + ciphertext: Uint8Array; +}; diff --git a/frontend/src/types/upload/UploadMode.ts b/frontend/src/types/upload/UploadMode.ts new file mode 100644 index 0000000..b9d403e --- /dev/null +++ b/frontend/src/types/upload/UploadMode.ts @@ -0,0 +1 @@ +export type UploadMode = 'zk' | 'hotlink'; diff --git a/frontend/src/types/upload/UploadOptionsRequest.ts b/frontend/src/types/upload/UploadOptionsRequest.ts new file mode 100644 index 0000000..b033432 --- /dev/null +++ b/frontend/src/types/upload/UploadOptionsRequest.ts @@ -0,0 +1,4 @@ +export type UploadOptionsRequest = { + expiresAt: string | null; + maxViews: number | null; +}; diff --git a/frontend/src/utils/share/buildHotlinkImagePath.ts b/frontend/src/utils/share/buildHotlinkImagePath.ts new file mode 100644 index 0000000..0c4e6ce --- /dev/null +++ b/frontend/src/utils/share/buildHotlinkImagePath.ts @@ -0,0 +1 @@ +export const buildHotlinkImagePath = (uploadId: string, secret: string): string => `/i/${uploadId}/${secret}`; diff --git a/frontend/src/utils/share/buildZkViewerPath.ts b/frontend/src/utils/share/buildZkViewerPath.ts new file mode 100644 index 0000000..3161907 --- /dev/null +++ b/frontend/src/utils/share/buildZkViewerPath.ts @@ -0,0 +1 @@ +export const buildZkViewerPath = (uploadId: string, fragmentKey: string): string => `/v/${uploadId}#${fragmentKey}`; diff --git a/frontend/src/utils/upload/decryptZkUpload.spec.ts b/frontend/src/utils/upload/decryptZkUpload.spec.ts new file mode 100644 index 0000000..e164164 --- /dev/null +++ b/frontend/src/utils/upload/decryptZkUpload.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { generateDek } from '@/utils/crypto/generateDek'; +import { bytesToBase64Url } from '@/utils/encoding/bytesToBase64Url'; +import { decryptZkUpload } from '@/utils/upload/decryptZkUpload'; +import { encryptZkUpload } from '@/utils/upload/encryptZkUpload'; + +describe('decryptZkUpload', () => { + it('decrypts using the URL fragment key (base64url-encoded DEK)', async () => { + const plaintext = new TextEncoder().encode('viewer-payload'); + const { ciphertext, iv, fragmentKey } = await encryptZkUpload(plaintext); + + const recovered = await decryptZkUpload(ciphertext, iv, fragmentKey); + + expect(recovered).toEqual(plaintext); + }); + + it('rejects decryption with a mismatched fragment key', async () => { + const { ciphertext, iv } = await encryptZkUpload(new Uint8Array([7])); + const otherFragmentKey = bytesToBase64Url(generateDek()); + + await expect(decryptZkUpload(ciphertext, iv, otherFragmentKey)).rejects.toThrow(); + }); +}); diff --git a/frontend/src/utils/upload/decryptZkUpload.ts b/frontend/src/utils/upload/decryptZkUpload.ts new file mode 100644 index 0000000..c1f738c --- /dev/null +++ b/frontend/src/utils/upload/decryptZkUpload.ts @@ -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 => { + const dek = base64UrlToBytes(fragmentKey); + const key = await importAesGcmKey(dek); + + return decryptAesGcm(ciphertext, key, iv); +}; diff --git a/frontend/src/utils/upload/encryptHotlinkUpload.spec.ts b/frontend/src/utils/upload/encryptHotlinkUpload.spec.ts new file mode 100644 index 0000000..bffc0c0 --- /dev/null +++ b/frontend/src/utils/upload/encryptHotlinkUpload.spec.ts @@ -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+/]+=*$/); + }); +}); diff --git a/frontend/src/utils/upload/encryptHotlinkUpload.ts b/frontend/src/utils/upload/encryptHotlinkUpload.ts new file mode 100644 index 0000000..264d7f9 --- /dev/null +++ b/frontend/src/utils/upload/encryptHotlinkUpload.ts @@ -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 => { + 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 + }; +}; diff --git a/frontend/src/utils/upload/encryptZkUpload.spec.ts b/frontend/src/utils/upload/encryptZkUpload.spec.ts new file mode 100644 index 0000000..2e91404 --- /dev/null +++ b/frontend/src/utils/upload/encryptZkUpload.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength'; +import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes'; +import { bytesToBase64 } from '@/utils/encoding/bytesToBase64'; +import { encryptZkUpload } from '@/utils/upload/encryptZkUpload'; + +describe('encryptZkUpload', () => { + it('exposes fragmentKey as base64url-encoded DEK', async () => { + const encrypted = await encryptZkUpload(new Uint8Array([1])); + + expect(encrypted.fragmentKey).toMatch(/^[A-Za-z0-9_-]+$/); + expect(base64UrlToBytes(encrypted.fragmentKey)).toHaveLength(AES_GCM_KEY_BYTE_LENGTH); + }); + + it('serializes iv and ciphertext for the upload API', async () => { + const encrypted = await encryptZkUpload(new Uint8Array([1, 2, 3])); + + expect(bytesToBase64(encrypted.iv)).toMatch(/^[A-Za-z0-9+/]+=*$/); + expect(bytesToBase64(encrypted.ciphertext)).toMatch(/^[A-Za-z0-9+/]+=*$/); + }); +}); diff --git a/frontend/src/utils/upload/encryptZkUpload.ts b/frontend/src/utils/upload/encryptZkUpload.ts new file mode 100644 index 0000000..637a324 --- /dev/null +++ b/frontend/src/utils/upload/encryptZkUpload.ts @@ -0,0 +1,19 @@ +import type { EncryptedZkUpload } from '@/types/upload/EncryptedZkUpload'; +import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm'; +import { generateDek } from '@/utils/crypto/generateDek'; +import { generateIv } from '@/utils/crypto/generateIv'; +import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey'; +import { bytesToBase64Url } from '@/utils/encoding/bytesToBase64Url'; + +export const encryptZkUpload = async (plaintext: Uint8Array): Promise => { + const dek = generateDek(); + const iv = generateIv(); + const key = await importAesGcmKey(dek); + const ciphertext = await encryptAesGcm(plaintext, key, iv); + + return { + fragmentKey: bytesToBase64Url(dek), + iv, + ciphertext + }; +}; diff --git a/frontend/src/utils/upload/prepareHotlinkUploadRequest.spec.ts b/frontend/src/utils/upload/prepareHotlinkUploadRequest.spec.ts new file mode 100644 index 0000000..0453cab --- /dev/null +++ b/frontend/src/utils/upload/prepareHotlinkUploadRequest.spec.ts @@ -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); + }); +}); diff --git a/frontend/src/utils/upload/prepareHotlinkUploadRequest.ts b/frontend/src/utils/upload/prepareHotlinkUploadRequest.ts new file mode 100644 index 0000000..0e6c508 --- /dev/null +++ b/frontend/src/utils/upload/prepareHotlinkUploadRequest.ts @@ -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 + } + }; +}; diff --git a/frontend/src/utils/upload/prepareZkUploadRequest.spec.ts b/frontend/src/utils/upload/prepareZkUploadRequest.spec.ts new file mode 100644 index 0000000..8de72a7 --- /dev/null +++ b/frontend/src/utils/upload/prepareZkUploadRequest.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { base64ToBytes } from '@/utils/encoding/base64ToBytes'; +import { buildZkViewerPath } from '@/utils/share/buildZkViewerPath'; +import { decryptZkUpload } from '@/utils/upload/decryptZkUpload'; +import { prepareZkUploadRequest } from '@/utils/upload/prepareZkUploadRequest'; + +describe('prepareZkUploadRequest', () => { + const options = { expiresAt: null, maxViews: null, passwordProtected: false as const }; + + it('returns fragmentKey separately from the POST request', async () => { + const { request, fragmentKey } = await prepareZkUploadRequest(new Uint8Array([9]), options); + + expect(fragmentKey).toMatch(/^[A-Za-z0-9_-]+$/); + expect(request).toEqual({ + ...options, + iv: request.iv, + ciphertext: request.ciphertext, + passwordProtected: false + }); + expect(request).not.toHaveProperty('fragmentKey'); + expect(Object.keys(request).sort()).toEqual([ + 'ciphertext', + 'expiresAt', + 'iv', + 'maxViews', + 'passwordProtected' + ]); + }); + + it('roundtrips plaintext using request fields and URL fragment key', async () => { + const plaintext = new TextEncoder().encode('zk-payload'); + const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, options); + + const recovered = await decryptZkUpload( + base64ToBytes(request.ciphertext), + base64ToBytes(request.iv), + fragmentKey + ); + + expect(recovered).toEqual(plaintext); + }); + + it('wires fragmentKey into the viewer URL hash for decryption', async () => { + const plaintext = new TextEncoder().encode('share-link'); + const uploadId = '7f3e2a1b-0000-4000-8000-000000000001'; + const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, options); + const path = buildZkViewerPath(uploadId, fragmentKey); + + const [pathWithoutHash, hashKey] = path.split('#'); + + expect(pathWithoutHash).toBe(`/v/${uploadId}`); + expect(hashKey).toBe(fragmentKey); + const recovered = await decryptZkUpload( + base64ToBytes(request.ciphertext), + base64ToBytes(request.iv), + hashKey + ); + + expect(recovered).toEqual(plaintext); + }); +}); diff --git a/frontend/src/utils/upload/prepareZkUploadRequest.ts b/frontend/src/utils/upload/prepareZkUploadRequest.ts new file mode 100644 index 0000000..ee09be7 --- /dev/null +++ b/frontend/src/utils/upload/prepareZkUploadRequest.ts @@ -0,0 +1,21 @@ +import type { CreateZkUploadRequest } from '@/types/upload/CreateZkUploadRequest'; +import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest'; +import { bytesToBase64 } from '@/utils/encoding/bytesToBase64'; +import { encryptZkUpload } from '@/utils/upload/encryptZkUpload'; + +export const prepareZkUploadRequest = async ( + plaintext: Uint8Array, + options: UploadOptionsRequest & { passwordProtected: false } +): Promise<{ request: CreateZkUploadRequest; fragmentKey: string }> => { + const encrypted = await encryptZkUpload(plaintext); + + return { + fragmentKey: encrypted.fragmentKey, + request: { + ...options, + iv: bytesToBase64(encrypted.iv), + ciphertext: bytesToBase64(encrypted.ciphertext), + passwordProtected: false + } + }; +}; From 4376ed7ff16af75535d32ed405745d65fa7f7bf1 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Mon, 19 Oct 2026 23:05:46 +0200 Subject: [PATCH 4/5] Expand Vitest coverage for shared frontend utils. Cover Axios error message extraction, generic error fallbacks, ArrayBuffer views used by Web Crypto, and share URL builders. --- .../http/resolveAxiosErrorMessage.spec.ts | 82 +++++++++++++++++++ .../src/utils/resolveErrorMessage.spec.ts | 22 +++++ .../utils/share/buildHotlinkImagePath.spec.ts | 11 +++ .../src/utils/share/buildZkViewerPath.spec.ts | 11 +++ frontend/src/utils/toArrayBuffer.spec.ts | 47 +++++++++++ 5 files changed, 173 insertions(+) create mode 100644 frontend/src/utils/http/resolveAxiosErrorMessage.spec.ts create mode 100644 frontend/src/utils/resolveErrorMessage.spec.ts create mode 100644 frontend/src/utils/share/buildHotlinkImagePath.spec.ts create mode 100644 frontend/src/utils/share/buildZkViewerPath.spec.ts create mode 100644 frontend/src/utils/toArrayBuffer.spec.ts diff --git a/frontend/src/utils/http/resolveAxiosErrorMessage.spec.ts b/frontend/src/utils/http/resolveAxiosErrorMessage.spec.ts new file mode 100644 index 0000000..fe88b06 --- /dev/null +++ b/frontend/src/utils/http/resolveAxiosErrorMessage.spec.ts @@ -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'); + }); +}); diff --git a/frontend/src/utils/resolveErrorMessage.spec.ts b/frontend/src/utils/resolveErrorMessage.spec.ts new file mode 100644 index 0000000..00b0ead --- /dev/null +++ b/frontend/src/utils/resolveErrorMessage.spec.ts @@ -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'); + }); +}); diff --git a/frontend/src/utils/share/buildHotlinkImagePath.spec.ts b/frontend/src/utils/share/buildHotlinkImagePath.spec.ts new file mode 100644 index 0000000..3f1eda5 --- /dev/null +++ b/frontend/src/utils/share/buildHotlinkImagePath.spec.ts @@ -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}`); + }); +}); diff --git a/frontend/src/utils/share/buildZkViewerPath.spec.ts b/frontend/src/utils/share/buildZkViewerPath.spec.ts new file mode 100644 index 0000000..1450827 --- /dev/null +++ b/frontend/src/utils/share/buildZkViewerPath.spec.ts @@ -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}`); + }); +}); diff --git a/frontend/src/utils/toArrayBuffer.spec.ts b/frontend/src/utils/toArrayBuffer.spec.ts new file mode 100644 index 0000000..e56831b --- /dev/null +++ b/frontend/src/utils/toArrayBuffer.spec.ts @@ -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); + }); +}); From 47361a83b9e519ec1046827e995a2b02e5dcee30 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 20 Oct 2026 18:31:33 +0200 Subject: [PATCH 5/5] add optional Argon2id password wrap for zk uploads. Wrap the image DEK for password-protected batches, keep the URL fragment as a separate secret, and extend prepare/decrypt helpers with tests. --- frontend/package-lock.json | 7 +++ frontend/package.json | 1 + .../src/types/upload/EncryptedZkUpload.ts | 2 +- frontend/src/types/upload/WrappedZkDek.ts | 5 ++ .../types/upload/ZkUploadPrepareOptions.ts | 5 ++ .../utils/crypto/deriveZkWrapKeyBytes.spec.ts | 31 +++++++++++ .../src/utils/crypto/deriveZkWrapKeyBytes.ts | 17 +++++++ .../utils/crypto/hashPasswordArgon2id.spec.ts | 30 +++++++++++ .../src/utils/crypto/hashPasswordArgon2id.ts | 20 ++++++++ .../decryptPasswordProtectedZkUpload.spec.ts | 47 +++++++++++++++++ .../decryptPasswordProtectedZkUpload.ts | 20 ++++++++ .../src/utils/upload/decryptZkUpload.spec.ts | 3 +- .../src/utils/upload/encryptZkUpload.spec.ts | 8 +-- frontend/src/utils/upload/encryptZkUpload.ts | 7 +-- .../upload/prepareZkUploadRequest.spec.ts | 51 +++++++++++++++++++ .../utils/upload/prepareZkUploadRequest.ts | 39 +++++++++++--- frontend/src/utils/upload/unwrapZkDek.spec.ts | 17 +++++++ frontend/src/utils/upload/unwrapZkDek.ts | 16 ++++++ frontend/src/utils/upload/wrapZkDek.spec.ts | 33 ++++++++++++ frontend/src/utils/upload/wrapZkDek.ts | 21 ++++++++ 20 files changed, 360 insertions(+), 20 deletions(-) create mode 100644 frontend/src/types/upload/WrappedZkDek.ts create mode 100644 frontend/src/types/upload/ZkUploadPrepareOptions.ts create mode 100644 frontend/src/utils/crypto/deriveZkWrapKeyBytes.spec.ts create mode 100644 frontend/src/utils/crypto/deriveZkWrapKeyBytes.ts create mode 100644 frontend/src/utils/crypto/hashPasswordArgon2id.spec.ts create mode 100644 frontend/src/utils/crypto/hashPasswordArgon2id.ts create mode 100644 frontend/src/utils/upload/decryptPasswordProtectedZkUpload.spec.ts create mode 100644 frontend/src/utils/upload/decryptPasswordProtectedZkUpload.ts create mode 100644 frontend/src/utils/upload/unwrapZkDek.spec.ts create mode 100644 frontend/src/utils/upload/unwrapZkDek.ts create mode 100644 frontend/src/utils/upload/wrapZkDek.spec.ts create mode 100644 frontend/src/utils/upload/wrapZkDek.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c571614..ca7d2ae 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@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", @@ -2513,6 +2514,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash-wasm": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.12.0.tgz", + "integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==", + "license": "MIT" + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 86bbf3e..172b509 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@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", diff --git a/frontend/src/types/upload/EncryptedZkUpload.ts b/frontend/src/types/upload/EncryptedZkUpload.ts index a128d3a..1e0b892 100644 --- a/frontend/src/types/upload/EncryptedZkUpload.ts +++ b/frontend/src/types/upload/EncryptedZkUpload.ts @@ -1,5 +1,5 @@ export type EncryptedZkUpload = { - fragmentKey: string; + dek: Uint8Array; iv: Uint8Array; ciphertext: Uint8Array; }; diff --git a/frontend/src/types/upload/WrappedZkDek.ts b/frontend/src/types/upload/WrappedZkDek.ts new file mode 100644 index 0000000..550bc96 --- /dev/null +++ b/frontend/src/types/upload/WrappedZkDek.ts @@ -0,0 +1,5 @@ +export type WrappedZkDek = { + wrappedDek: Uint8Array; + wrapIv: Uint8Array; + salt: Uint8Array; +}; diff --git a/frontend/src/types/upload/ZkUploadPrepareOptions.ts b/frontend/src/types/upload/ZkUploadPrepareOptions.ts new file mode 100644 index 0000000..ee90188 --- /dev/null +++ b/frontend/src/types/upload/ZkUploadPrepareOptions.ts @@ -0,0 +1,5 @@ +import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest'; + +export type ZkUploadPrepareOptions = + | (UploadOptionsRequest & { passwordProtected: false }) + | (UploadOptionsRequest & { passwordProtected: true; password: string }); diff --git a/frontend/src/utils/crypto/deriveZkWrapKeyBytes.spec.ts b/frontend/src/utils/crypto/deriveZkWrapKeyBytes.spec.ts new file mode 100644 index 0000000..07fdf4a --- /dev/null +++ b/frontend/src/utils/crypto/deriveZkWrapKeyBytes.spec.ts @@ -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); + }); +}); diff --git a/frontend/src/utils/crypto/deriveZkWrapKeyBytes.ts b/frontend/src/utils/crypto/deriveZkWrapKeyBytes.ts new file mode 100644 index 0000000..4ce9520 --- /dev/null +++ b/frontend/src/utils/crypto/deriveZkWrapKeyBytes.ts @@ -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 => { + 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); +}; diff --git a/frontend/src/utils/crypto/hashPasswordArgon2id.spec.ts b/frontend/src/utils/crypto/hashPasswordArgon2id.spec.ts new file mode 100644 index 0000000..06fd45a --- /dev/null +++ b/frontend/src/utils/crypto/hashPasswordArgon2id.spec.ts @@ -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); + }); +}); diff --git a/frontend/src/utils/crypto/hashPasswordArgon2id.ts b/frontend/src/utils/crypto/hashPasswordArgon2id.ts new file mode 100644 index 0000000..6c029c5 --- /dev/null +++ b/frontend/src/utils/crypto/hashPasswordArgon2id.ts @@ -0,0 +1,20 @@ +import { argon2id } from 'hash-wasm'; + +export const hashPasswordArgon2id = async (password: string, salt: Uint8Array): Promise => { + 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); +}; diff --git a/frontend/src/utils/upload/decryptPasswordProtectedZkUpload.spec.ts b/frontend/src/utils/upload/decryptPasswordProtectedZkUpload.spec.ts new file mode 100644 index 0000000..5eede08 --- /dev/null +++ b/frontend/src/utils/upload/decryptPasswordProtectedZkUpload.spec.ts @@ -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(); + }); +}); diff --git a/frontend/src/utils/upload/decryptPasswordProtectedZkUpload.ts b/frontend/src/utils/upload/decryptPasswordProtectedZkUpload.ts new file mode 100644 index 0000000..09ebbf8 --- /dev/null +++ b/frontend/src/utils/upload/decryptPasswordProtectedZkUpload.ts @@ -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 => { + const fragmentSecret = base64UrlToBytes(fragmentKey); + const dek = await unwrapZkDek(wrappedDek, wrapIv, salt, password, fragmentSecret); + const key = await importAesGcmKey(dek); + + return decryptAesGcm(ciphertext, key, iv); +}; diff --git a/frontend/src/utils/upload/decryptZkUpload.spec.ts b/frontend/src/utils/upload/decryptZkUpload.spec.ts index e164164..0e46d83 100644 --- a/frontend/src/utils/upload/decryptZkUpload.spec.ts +++ b/frontend/src/utils/upload/decryptZkUpload.spec.ts @@ -7,7 +7,8 @@ import { encryptZkUpload } from '@/utils/upload/encryptZkUpload'; describe('decryptZkUpload', () => { it('decrypts using the URL fragment key (base64url-encoded DEK)', async () => { const plaintext = new TextEncoder().encode('viewer-payload'); - const { ciphertext, iv, fragmentKey } = await encryptZkUpload(plaintext); + const { dek, ciphertext, iv } = await encryptZkUpload(plaintext); + const fragmentKey = bytesToBase64Url(dek); const recovered = await decryptZkUpload(ciphertext, iv, fragmentKey); diff --git a/frontend/src/utils/upload/encryptZkUpload.spec.ts b/frontend/src/utils/upload/encryptZkUpload.spec.ts index 2e91404..8f6f429 100644 --- a/frontend/src/utils/upload/encryptZkUpload.spec.ts +++ b/frontend/src/utils/upload/encryptZkUpload.spec.ts @@ -1,15 +1,15 @@ 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 { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes'; import { bytesToBase64 } from '@/utils/encoding/bytesToBase64'; import { encryptZkUpload } from '@/utils/upload/encryptZkUpload'; describe('encryptZkUpload', () => { - it('exposes fragmentKey as base64url-encoded DEK', async () => { + it('returns a random DEK and IV', async () => { const encrypted = await encryptZkUpload(new Uint8Array([1])); - expect(encrypted.fragmentKey).toMatch(/^[A-Za-z0-9_-]+$/); - expect(base64UrlToBytes(encrypted.fragmentKey)).toHaveLength(AES_GCM_KEY_BYTE_LENGTH); + 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 () => { diff --git a/frontend/src/utils/upload/encryptZkUpload.ts b/frontend/src/utils/upload/encryptZkUpload.ts index 637a324..798591a 100644 --- a/frontend/src/utils/upload/encryptZkUpload.ts +++ b/frontend/src/utils/upload/encryptZkUpload.ts @@ -3,7 +3,6 @@ import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm'; import { generateDek } from '@/utils/crypto/generateDek'; import { generateIv } from '@/utils/crypto/generateIv'; import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey'; -import { bytesToBase64Url } from '@/utils/encoding/bytesToBase64Url'; export const encryptZkUpload = async (plaintext: Uint8Array): Promise => { const dek = generateDek(); @@ -11,9 +10,5 @@ export const encryptZkUpload = async (plaintext: Uint8Array): Promise { 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); + }); + }); }); diff --git a/frontend/src/utils/upload/prepareZkUploadRequest.ts b/frontend/src/utils/upload/prepareZkUploadRequest.ts index ee09be7..fe834d2 100644 --- a/frontend/src/utils/upload/prepareZkUploadRequest.ts +++ b/frontend/src/utils/upload/prepareZkUploadRequest.ts @@ -1,21 +1,44 @@ import type { CreateZkUploadRequest } from '@/types/upload/CreateZkUploadRequest'; -import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest'; +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: UploadOptionsRequest & { passwordProtected: false } + options: ZkUploadPrepareOptions ): Promise<{ request: CreateZkUploadRequest; fragmentKey: string }> => { - const encrypted = await encryptZkUpload(plaintext); + 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: encrypted.fragmentKey, + fragmentKey: bytesToBase64Url(fragmentSecret), request: { - ...options, - iv: bytesToBase64(encrypted.iv), - ciphertext: bytesToBase64(encrypted.ciphertext), - passwordProtected: false + ...uploadOptions, + iv: bytesToBase64(iv), + ciphertext: bytesToBase64(ciphertext), + passwordProtected: true, + wrappedDek: bytesToBase64(wrappedDek), + wrapIv: bytesToBase64(wrapIv), + salt: bytesToBase64(salt) } }; }; diff --git a/frontend/src/utils/upload/unwrapZkDek.spec.ts b/frontend/src/utils/upload/unwrapZkDek.spec.ts new file mode 100644 index 0000000..b6c09f3 --- /dev/null +++ b/frontend/src/utils/upload/unwrapZkDek.spec.ts @@ -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(); + }); +}); diff --git a/frontend/src/utils/upload/unwrapZkDek.ts b/frontend/src/utils/upload/unwrapZkDek.ts new file mode 100644 index 0000000..df6312e --- /dev/null +++ b/frontend/src/utils/upload/unwrapZkDek.ts @@ -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 => { + const wrapKeyBytes = await deriveZkWrapKeyBytes(password, salt, fragmentSecret); + const wrapKey = await importAesGcmKey(wrapKeyBytes); + + return decryptAesGcm(wrappedDek, wrapKey, wrapIv); +}; diff --git a/frontend/src/utils/upload/wrapZkDek.spec.ts b/frontend/src/utils/upload/wrapZkDek.spec.ts new file mode 100644 index 0000000..484d4f1 --- /dev/null +++ b/frontend/src/utils/upload/wrapZkDek.spec.ts @@ -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(); + }); +}); diff --git a/frontend/src/utils/upload/wrapZkDek.ts b/frontend/src/utils/upload/wrapZkDek.ts new file mode 100644 index 0000000..a373d9d --- /dev/null +++ b/frontend/src/utils/upload/wrapZkDek.ts @@ -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 => { + 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 }; +};