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
@@ -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);
};