Files
private_image/frontend/src/utils/upload/prepareZkUploadRequest.spec.ts
T
nobswebdev 47361a83b9 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.
2026-10-20 18:31:33 +02:00

113 lines
4.4 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
import { buildZkViewerPath } from '@/utils/share/buildZkViewerPath';
import { decryptPasswordProtectedZkUpload } from '@/utils/upload/decryptPasswordProtectedZkUpload';
import { decryptZkUpload } from '@/utils/upload/decryptZkUpload';
import { prepareZkUploadRequest } from '@/utils/upload/prepareZkUploadRequest';
describe('prepareZkUploadRequest', () => {
const options = { expiresAt: null, maxViews: null, passwordProtected: false as const };
it('returns fragmentKey separately from the POST request', async () => {
const { request, fragmentKey } = await prepareZkUploadRequest(new Uint8Array([9]), options);
expect(fragmentKey).toMatch(/^[A-Za-z0-9_-]+$/);
expect(request).toEqual({
...options,
iv: request.iv,
ciphertext: request.ciphertext,
passwordProtected: false
});
expect(request).not.toHaveProperty('fragmentKey');
expect(Object.keys(request).sort()).toEqual([
'ciphertext',
'expiresAt',
'iv',
'maxViews',
'passwordProtected'
]);
});
it('roundtrips plaintext using request fields and URL fragment key', async () => {
const plaintext = new TextEncoder().encode('zk-payload');
const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, options);
const recovered = await decryptZkUpload(
base64ToBytes(request.ciphertext),
base64ToBytes(request.iv),
fragmentKey
);
expect(recovered).toEqual(plaintext);
});
it('wires fragmentKey into the viewer URL hash for decryption', async () => {
const plaintext = new TextEncoder().encode('share-link');
const uploadId = '7f3e2a1b-0000-4000-8000-000000000001';
const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, options);
const path = buildZkViewerPath(uploadId, fragmentKey);
const [pathWithoutHash, hashKey] = path.split('#');
expect(pathWithoutHash).toBe(`/v/${uploadId}`);
expect(hashKey).toBe(fragmentKey);
const recovered = await decryptZkUpload(
base64ToBytes(request.ciphertext),
base64ToBytes(request.iv),
hashKey
);
expect(recovered).toEqual(plaintext);
});
describe('password protected', () => {
const passwordOptions = {
expiresAt: null,
maxViews: null,
passwordProtected: true as const,
password: 'batch-secret'
};
it('includes wrap fields and omits password from the POST body', async () => {
const { request, fragmentKey } = await prepareZkUploadRequest(new Uint8Array([1]), passwordOptions);
expect(request.passwordProtected).toBe(true);
expect(request.wrappedDek).toMatch(/^[A-Za-z0-9+/]+=*$/);
expect(request.wrapIv).toMatch(/^[A-Za-z0-9+/]+=*$/);
expect(request.salt).toMatch(/^[A-Za-z0-9+/]+=*$/);
expect(request).not.toHaveProperty('password');
expect(fragmentKey).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('does not decrypt with URL fragment alone', async () => {
const plaintext = new TextEncoder().encode('protected');
const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, passwordOptions);
await expect(
decryptZkUpload(
base64ToBytes(request.ciphertext),
base64ToBytes(request.iv),
fragmentKey
)
).rejects.toThrow();
});
it('roundtrips with fragment key and password', async () => {
const plaintext = new TextEncoder().encode('protected-payload');
const { request, fragmentKey } = await prepareZkUploadRequest(plaintext, passwordOptions);
const recovered = await decryptPasswordProtectedZkUpload(
base64ToBytes(request.ciphertext),
base64ToBytes(request.iv),
fragmentKey,
passwordOptions.password,
base64ToBytes(request.wrappedDek!),
base64ToBytes(request.wrapIv!),
base64ToBytes(request.salt!)
);
expect(recovered).toEqual(plaintext);
});
});
});