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:
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user