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.
This commit is contained in:
2026-10-20 18:31:33 +02:00
parent 4376ed7ff1
commit 47361a83b9
20 changed files with 360 additions and 20 deletions
+7
View File
@@ -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",
+1
View File
@@ -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",
@@ -1,5 +1,5 @@
export type EncryptedZkUpload = {
fragmentKey: string;
dek: Uint8Array;
iv: Uint8Array;
ciphertext: Uint8Array;
};
@@ -0,0 +1,5 @@
export type WrappedZkDek = {
wrappedDek: Uint8Array;
wrapIv: Uint8Array;
salt: Uint8Array;
};
@@ -0,0 +1,5 @@
import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest';
export type ZkUploadPrepareOptions =
| (UploadOptionsRequest & { passwordProtected: false })
| (UploadOptionsRequest & { passwordProtected: true; password: string });
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { deriveZkWrapKeyBytes } from '@/utils/crypto/deriveZkWrapKeyBytes';
import { generateDek } from '@/utils/crypto/generateDek';
describe('deriveZkWrapKeyBytes', () => {
const salt = new Uint8Array([8, 7, 6, 5, 4, 3, 2, 1, 0, 9, 8, 7, 6, 5, 4, 3]);
const fragmentSecret = generateDek();
const password = 'wrap-password';
it('returns a 32-byte wrap key', async () => {
const wrapKey = await deriveZkWrapKeyBytes(password, salt, fragmentSecret);
expect(wrapKey.byteLength).toBe(32);
});
it('is deterministic for the same inputs', async () => {
const first = await deriveZkWrapKeyBytes(password, salt, fragmentSecret);
const second = await deriveZkWrapKeyBytes(password, salt, fragmentSecret);
expect(first).toEqual(second);
});
it('binds the URL fragment secret into the wrap key', async () => {
const otherFragment = generateDek();
const wrapKeyA = await deriveZkWrapKeyBytes(password, salt, fragmentSecret);
const wrapKeyB = await deriveZkWrapKeyBytes(password, salt, otherFragment);
expect(wrapKeyA).not.toEqual(wrapKeyB);
});
});
@@ -0,0 +1,17 @@
import { hashPasswordArgon2id } from '@/utils/crypto/hashPasswordArgon2id';
import { toArrayBuffer } from '@/utils/toArrayBuffer';
export const deriveZkWrapKeyBytes = async (
password: string,
salt: Uint8Array,
fragmentSecret: Uint8Array
): Promise<Uint8Array> => {
const kek = await hashPasswordArgon2id(password, salt);
const combined = new Uint8Array(kek.length + fragmentSecret.length);
combined.set(kek, 0);
combined.set(fragmentSecret, kek.length);
const digest = await crypto.subtle.digest('SHA-256', toArrayBuffer(combined));
return new Uint8Array(digest);
};
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { hashPasswordArgon2id } from '@/utils/crypto/hashPasswordArgon2id';
describe('hashPasswordArgon2id', () => {
const salt = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
it('returns a 32-byte hash', async () => {
const hash = await hashPasswordArgon2id('test-password', salt);
expect(hash).toBeInstanceOf(Uint8Array);
expect(hash.byteLength).toBe(32);
});
it('is deterministic for the same password and salt', async () => {
const first = await hashPasswordArgon2id('same', salt);
const second = await hashPasswordArgon2id('same', salt);
expect(first).toEqual(second);
});
it('changes when the salt changes', async () => {
const otherSalt = new Uint8Array(salt);
otherSalt[0] ^= 0xff;
const hashA = await hashPasswordArgon2id('same', salt);
const hashB = await hashPasswordArgon2id('same', otherSalt);
expect(hashA).not.toEqual(hashB);
});
});
@@ -0,0 +1,20 @@
import { argon2id } from 'hash-wasm';
export const hashPasswordArgon2id = async (password: string, salt: Uint8Array): Promise<Uint8Array> => {
const iterations = 3;
const memorySizeKib = 65536;
const parallelism = 1;
const hashByteLength = 32;
const hash = await argon2id({
password,
salt,
parallelism,
iterations,
memorySize: memorySizeKib,
hashLength: hashByteLength,
outputType: 'binary'
});
return new Uint8Array(hash);
};
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
import { decryptPasswordProtectedZkUpload } from '@/utils/upload/decryptPasswordProtectedZkUpload';
import { prepareZkUploadRequest } from '@/utils/upload/prepareZkUploadRequest';
describe('decryptPasswordProtectedZkUpload', () => {
const passwordOptions = {
expiresAt: null,
maxViews: null,
passwordProtected: true as const,
password: 'viewer-secret'
};
it('decrypts ciphertext when fragment key and password match upload', async () => {
const plaintext = new TextEncoder().encode('password-protected image');
const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, passwordOptions);
const recovered = await decryptPasswordProtectedZkUpload(
base64ToBytes(request.ciphertext),
base64ToBytes(request.iv),
fragmentKey,
passwordOptions.password,
base64ToBytes(request.wrappedDek!),
base64ToBytes(request.wrapIv!),
base64ToBytes(request.salt!)
);
expect(recovered).toEqual(plaintext);
});
it('rejects decryption with the wrong password', async () => {
const plaintext = new Uint8Array([42]);
const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, passwordOptions);
await expect(
decryptPasswordProtectedZkUpload(
base64ToBytes(request.ciphertext),
base64ToBytes(request.iv),
fragmentKey,
'wrong-password',
base64ToBytes(request.wrappedDek!),
base64ToBytes(request.wrapIv!),
base64ToBytes(request.salt!)
)
).rejects.toThrow();
});
});
@@ -0,0 +1,20 @@
import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm';
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes';
import { unwrapZkDek } from '@/utils/upload/unwrapZkDek';
export const decryptPasswordProtectedZkUpload = async (
ciphertext: Uint8Array,
iv: Uint8Array,
fragmentKey: string,
password: string,
wrappedDek: Uint8Array,
wrapIv: Uint8Array,
salt: Uint8Array
): Promise<Uint8Array> => {
const fragmentSecret = base64UrlToBytes(fragmentKey);
const dek = await unwrapZkDek(wrappedDek, wrapIv, salt, password, fragmentSecret);
const key = await importAesGcmKey(dek);
return decryptAesGcm(ciphertext, key, iv);
};
@@ -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);
@@ -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 () => {
+1 -6
View File
@@ -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<EncryptedZkUpload> => {
const dek = generateDek();
@@ -11,9 +10,5 @@ export const encryptZkUpload = async (plaintext: Uint8Array): Promise<EncryptedZ
const key = await importAesGcmKey(dek);
const ciphertext = await encryptAesGcm(plaintext, key, iv);
return {
fragmentKey: bytesToBase64Url(dek),
iv,
ciphertext
};
return { dek, iv, ciphertext };
};
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
import { buildZkViewerPath } from '@/utils/share/buildZkViewerPath';
import { decryptPasswordProtectedZkUpload } from '@/utils/upload/decryptPasswordProtectedZkUpload';
import { decryptZkUpload } from '@/utils/upload/decryptZkUpload';
import { prepareZkUploadRequest } from '@/utils/upload/prepareZkUploadRequest';
@@ -58,4 +59,54 @@ describe('prepareZkUploadRequest', () => {
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);
});
});
});
@@ -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: encrypted.fragmentKey,
fragmentKey: bytesToBase64Url(dek),
request: {
...options,
iv: bytesToBase64(encrypted.iv),
ciphertext: bytesToBase64(encrypted.ciphertext),
iv: bytesToBase64(iv),
ciphertext: bytesToBase64(ciphertext),
passwordProtected: false
}
};
}
const { password, ...uploadOptions } = options;
const { dek, iv, ciphertext } = await encryptZkUpload(plaintext);
const fragmentSecret = generateDek();
const { wrappedDek, wrapIv, salt } = await wrapZkDek(dek, password, fragmentSecret);
return {
fragmentKey: bytesToBase64Url(fragmentSecret),
request: {
...uploadOptions,
iv: bytesToBase64(iv),
ciphertext: bytesToBase64(ciphertext),
passwordProtected: true,
wrappedDek: bytesToBase64(wrappedDek),
wrapIv: bytesToBase64(wrapIv),
salt: bytesToBase64(salt)
}
};
};
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { generateDek } from '@/utils/crypto/generateDek';
import { unwrapZkDek } from '@/utils/upload/unwrapZkDek';
import { wrapZkDek } from '@/utils/upload/wrapZkDek';
describe('unwrapZkDek', () => {
it('fails unwrap with a mismatched fragment secret', async () => {
const dek = generateDek();
const fragmentSecret = generateDek();
const wrapped = await wrapZkDek(dek, 'password', fragmentSecret);
const otherFragment = generateDek();
await expect(
unwrapZkDek(wrapped.wrappedDek, wrapped.wrapIv, wrapped.salt, 'password', otherFragment)
).rejects.toThrow();
});
});
+16
View File
@@ -0,0 +1,16 @@
import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm';
import { deriveZkWrapKeyBytes } from '@/utils/crypto/deriveZkWrapKeyBytes';
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
export const unwrapZkDek = async (
wrappedDek: Uint8Array,
wrapIv: Uint8Array,
salt: Uint8Array,
password: string,
fragmentSecret: Uint8Array
): Promise<Uint8Array> => {
const wrapKeyBytes = await deriveZkWrapKeyBytes(password, salt, fragmentSecret);
const wrapKey = await importAesGcmKey(wrapKeyBytes);
return decryptAesGcm(wrappedDek, wrapKey, wrapIv);
};
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { generateDek } from '@/utils/crypto/generateDek';
import { unwrapZkDek } from '@/utils/upload/unwrapZkDek';
import { wrapZkDek } from '@/utils/upload/wrapZkDek';
describe('wrapZkDek', () => {
it('roundtrips the DEK with password and fragment secret', async () => {
const dek = generateDek();
const fragmentSecret = generateDek();
const password = 'viewer-password';
const wrapped = await wrapZkDek(dek, password, fragmentSecret);
const recovered = await unwrapZkDek(
wrapped.wrappedDek,
wrapped.wrapIv,
wrapped.salt,
password,
fragmentSecret
);
expect(recovered).toEqual(dek);
});
it('fails unwrap with wrong password', async () => {
const dek = generateDek();
const fragmentSecret = generateDek();
const wrapped = await wrapZkDek(dek, 'correct', fragmentSecret);
await expect(
unwrapZkDek(wrapped.wrappedDek, wrapped.wrapIv, wrapped.salt, 'wrong', fragmentSecret)
).rejects.toThrow();
});
});
+21
View File
@@ -0,0 +1,21 @@
import type { WrappedZkDek } from '@/types/upload/WrappedZkDek';
import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm';
import { deriveZkWrapKeyBytes } from '@/utils/crypto/deriveZkWrapKeyBytes';
import { generateIv } from '@/utils/crypto/generateIv';
import { generateRandomBytes } from '@/utils/crypto/generateRandomBytes';
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
export const wrapZkDek = async (
dek: Uint8Array,
password: string,
fragmentSecret: Uint8Array
): Promise<WrappedZkDek> => {
const saltByteLength = 16;
const salt = generateRandomBytes(saltByteLength);
const wrapKeyBytes = await deriveZkWrapKeyBytes(password, salt, fragmentSecret);
const wrapIv = generateIv();
const wrapKey = await importAesGcmKey(wrapKeyBytes);
const wrappedDek = await encryptAesGcm(dek, wrapKey, wrapIv);
return { wrappedDek, wrapIv, salt };
};