Wrap the image DEK for password-protected batches, keep the URL fragment as a separate secret, and extend prepare/decrypt helpers with tests.
113 lines
4.4 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|