Slave/client crypto #7
Generated
+1494
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@
|
|||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
"postinstall": "node patches/patch-exiftool-stderr.mjs && node patches/patch-zeroperl-worker-fetch.mjs"
|
"postinstall": "node patches/patch-exiftool-stderr.mjs && node patches/patch-zeroperl-worker-fetch.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.0.4",
|
"vite": "^8.0.4",
|
||||||
|
"vitest": "^3.2.7",
|
||||||
"vue-tsc": "^3.2.6"
|
"vue-tsc": "^3.2.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const AES_GCM_AUTH_TAG_BYTE_LENGTH = 16;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const AES_GCM_IV_BYTE_LENGTH = 12;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const AES_GCM_KEY_BYTE_LENGTH = 32;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { base64ToBytes } from '@/utils/base64ToBytes';
|
import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
|
||||||
|
|
||||||
const EXIFTOOL_WORKER_INIT_JPEG_BASE64 =
|
const EXIFTOOL_WORKER_INIT_JPEG_BASE64 =
|
||||||
'/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AL+AD//Z';
|
'/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AL+AD//Z';
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { AES_GCM_AUTH_TAG_BYTE_LENGTH } from '@/consts/crypto/aesGcmAuthTagByteLength';
|
||||||
|
import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength';
|
||||||
|
import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm';
|
||||||
|
import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm';
|
||||||
|
import { generateDek } from '@/utils/crypto/generateDek';
|
||||||
|
import { generateIv } from '@/utils/crypto/generateIv';
|
||||||
|
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
|
||||||
|
|
||||||
|
describe('decryptAesGcm', () => {
|
||||||
|
it('rejects wrong-length IVs before calling Web Crypto', async () => {
|
||||||
|
const dek = generateDek();
|
||||||
|
const key = await importAesGcmKey(dek);
|
||||||
|
const ciphertext = new Uint8Array(AES_GCM_AUTH_TAG_BYTE_LENGTH);
|
||||||
|
|
||||||
|
await expect(decryptAesGcm(ciphertext, key, new Uint8Array(AES_GCM_IV_BYTE_LENGTH - 1))).rejects.toThrow(
|
||||||
|
`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`
|
||||||
|
);
|
||||||
|
await expect(decryptAesGcm(ciphertext, key, new Uint8Array(AES_GCM_IV_BYTE_LENGTH + 1))).rejects.toThrow(
|
||||||
|
`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects tampered ciphertext (auth tag failure)', async () => {
|
||||||
|
const plaintext = new TextEncoder().encode('integrity-checked');
|
||||||
|
const dek = generateDek();
|
||||||
|
const iv = generateIv();
|
||||||
|
const key = await importAesGcmKey(dek);
|
||||||
|
const ciphertext = await encryptAesGcm(plaintext, key, iv);
|
||||||
|
|
||||||
|
const tampered = new Uint8Array(ciphertext);
|
||||||
|
tampered[tampered.length - 1] ^= 0x01;
|
||||||
|
|
||||||
|
await expect(decryptAesGcm(tampered, key, iv)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects decryption with the wrong key', async () => {
|
||||||
|
const plaintext = new TextEncoder().encode('wrong-key');
|
||||||
|
const iv = generateIv();
|
||||||
|
const encryptKey = await importAesGcmKey(generateDek());
|
||||||
|
const decryptKey = await importAesGcmKey(generateDek());
|
||||||
|
const ciphertext = await encryptAesGcm(plaintext, encryptKey, iv);
|
||||||
|
|
||||||
|
await expect(decryptAesGcm(ciphertext, decryptKey, iv)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects decryption with the wrong IV', async () => {
|
||||||
|
const plaintext = new TextEncoder().encode('wrong-iv');
|
||||||
|
const dek = generateDek();
|
||||||
|
const key = await importAesGcmKey(dek);
|
||||||
|
const ciphertext = await encryptAesGcm(plaintext, key, generateIv());
|
||||||
|
|
||||||
|
await expect(decryptAesGcm(ciphertext, key, generateIv())).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects truncated ciphertext', async () => {
|
||||||
|
const plaintext = new TextEncoder().encode('truncated');
|
||||||
|
const dek = generateDek();
|
||||||
|
const iv = generateIv();
|
||||||
|
const key = await importAesGcmKey(dek);
|
||||||
|
const ciphertext = await encryptAesGcm(plaintext, key, iv);
|
||||||
|
const truncated = ciphertext.slice(0, ciphertext.length - 1);
|
||||||
|
|
||||||
|
await expect(decryptAesGcm(truncated, key, iv)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength';
|
||||||
|
import { toArrayBuffer } from '@/utils/toArrayBuffer';
|
||||||
|
|
||||||
|
export const decryptAesGcm = async (ciphertext: Uint8Array, key: CryptoKey, iv: Uint8Array): Promise<Uint8Array> => {
|
||||||
|
if (iv.byteLength !== AES_GCM_IV_BYTE_LENGTH) {
|
||||||
|
throw new Error(`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plaintext = await crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv: toArrayBuffer(iv)
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
toArrayBuffer(ciphertext)
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Uint8Array(plaintext);
|
||||||
|
};
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { AES_GCM_AUTH_TAG_BYTE_LENGTH } from '@/consts/crypto/aesGcmAuthTagByteLength';
|
||||||
|
import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength';
|
||||||
|
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
|
||||||
|
import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm';
|
||||||
|
import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm';
|
||||||
|
import { generateDek } from '@/utils/crypto/generateDek';
|
||||||
|
import { generateIv } from '@/utils/crypto/generateIv';
|
||||||
|
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
|
||||||
|
|
||||||
|
const UPLOAD_API_BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||||
|
|
||||||
|
describe('encryptAesGcm', () => {
|
||||||
|
it('roundtrips plaintext', async () => {
|
||||||
|
const plaintext = new TextEncoder().encode('stripped-image-bytes');
|
||||||
|
const dek = generateDek();
|
||||||
|
const iv = generateIv();
|
||||||
|
const key = await importAesGcmKey(dek);
|
||||||
|
|
||||||
|
const ciphertext = await encryptAesGcm(plaintext, key, iv);
|
||||||
|
const decrypted = await decryptAesGcm(ciphertext, key, iv);
|
||||||
|
|
||||||
|
expect(decrypted).toEqual(plaintext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('roundtrips empty plaintext and large payloads', async () => {
|
||||||
|
const dek = generateDek();
|
||||||
|
const key = await importAesGcmKey(dek);
|
||||||
|
|
||||||
|
const empty = new Uint8Array(0);
|
||||||
|
const large = new Uint8Array(65_536);
|
||||||
|
|
||||||
|
for (let i = 0; i < large.length; i++) {
|
||||||
|
large[i] = i % 256;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const plaintext of [empty, large]) {
|
||||||
|
const iv = generateIv();
|
||||||
|
const ciphertext = await encryptAesGcm(plaintext, key, iv);
|
||||||
|
const decrypted = await decryptAesGcm(ciphertext, key, iv);
|
||||||
|
|
||||||
|
expect(decrypted).toEqual(plaintext);
|
||||||
|
expect(ciphertext.byteLength).toBe(plaintext.byteLength + AES_GCM_AUTH_TAG_BYTE_LENGTH);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts ArrayBuffer plaintext', async () => {
|
||||||
|
const plaintext = new TextEncoder().encode('array-buffer-plaintext').buffer;
|
||||||
|
const dek = generateDek();
|
||||||
|
const iv = generateIv();
|
||||||
|
const key = await importAesGcmKey(dek);
|
||||||
|
|
||||||
|
const ciphertext = await encryptAesGcm(plaintext, key, iv);
|
||||||
|
const decrypted = await decryptAesGcm(ciphertext, key, iv);
|
||||||
|
|
||||||
|
expect(decrypted).toEqual(new Uint8Array(plaintext));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong-length IVs before calling Web Crypto', async () => {
|
||||||
|
const dek = generateDek();
|
||||||
|
const key = await importAesGcmKey(dek);
|
||||||
|
const plaintext = new Uint8Array([1, 2, 3]);
|
||||||
|
|
||||||
|
await expect(encryptAesGcm(plaintext, key, new Uint8Array(AES_GCM_IV_BYTE_LENGTH - 1))).rejects.toThrow(
|
||||||
|
`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`
|
||||||
|
);
|
||||||
|
await expect(encryptAesGcm(plaintext, key, new Uint8Array(AES_GCM_IV_BYTE_LENGTH + 1))).rejects.toThrow(
|
||||||
|
`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic for a fixed key, IV, and plaintext', async () => {
|
||||||
|
const plaintext = new TextEncoder().encode('deterministic-check');
|
||||||
|
const dek = generateDek();
|
||||||
|
const iv = generateIv();
|
||||||
|
const key = await importAesGcmKey(dek);
|
||||||
|
|
||||||
|
const first = await encryptAesGcm(plaintext, key, iv);
|
||||||
|
const second = await encryptAesGcm(plaintext, key, iv);
|
||||||
|
|
||||||
|
expect(first).toEqual(second);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes ciphertext when the IV changes (nonce separation)', async () => {
|
||||||
|
const plaintext = new TextEncoder().encode('unique-nonce');
|
||||||
|
const dek = generateDek();
|
||||||
|
const key = await importAesGcmKey(dek);
|
||||||
|
|
||||||
|
const ciphertextA = await encryptAesGcm(plaintext, key, generateIv());
|
||||||
|
const ciphertextB = await encryptAesGcm(plaintext, key, generateIv());
|
||||||
|
|
||||||
|
expect(ciphertextA).not.toEqual(ciphertextB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces standard base64 iv and ciphertext suitable for the upload API', async () => {
|
||||||
|
const plaintext = new Uint8Array(64);
|
||||||
|
const dek = generateDek();
|
||||||
|
const iv = generateIv();
|
||||||
|
const key = await importAesGcmKey(dek);
|
||||||
|
|
||||||
|
const ciphertext = await encryptAesGcm(plaintext, key, iv);
|
||||||
|
const ivBase64 = bytesToBase64(iv);
|
||||||
|
const ciphertextBase64 = bytesToBase64(ciphertext);
|
||||||
|
|
||||||
|
expect(UPLOAD_API_BASE64_PATTERN.test(ivBase64)).toBe(true);
|
||||||
|
expect(UPLOAD_API_BASE64_PATTERN.test(ciphertextBase64)).toBe(true);
|
||||||
|
expect(ciphertext.byteLength).toBe(plaintext.byteLength + AES_GCM_AUTH_TAG_BYTE_LENGTH);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength';
|
||||||
|
import { toArrayBuffer } from '@/utils/toArrayBuffer';
|
||||||
|
|
||||||
|
export const encryptAesGcm = async (
|
||||||
|
plaintext: ArrayBuffer | Uint8Array,
|
||||||
|
key: CryptoKey,
|
||||||
|
iv: Uint8Array
|
||||||
|
): Promise<Uint8Array> => {
|
||||||
|
if (iv.byteLength !== AES_GCM_IV_BYTE_LENGTH) {
|
||||||
|
throw new Error(`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ciphertext = await crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv: toArrayBuffer(iv)
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
toArrayBuffer(plaintext)
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Uint8Array(ciphertext);
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength';
|
||||||
|
import { generateDek } from '@/utils/crypto/generateDek';
|
||||||
|
|
||||||
|
describe('generateDek', () => {
|
||||||
|
it('returns a 32-byte raw AES-256 key', () => {
|
||||||
|
const dek = generateDek();
|
||||||
|
|
||||||
|
expect(dek).toBeInstanceOf(Uint8Array);
|
||||||
|
expect(dek.byteLength).toBe(AES_GCM_KEY_BYTE_LENGTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates distinct DEKs across calls', () => {
|
||||||
|
const dekA = generateDek();
|
||||||
|
const dekB = generateDek();
|
||||||
|
|
||||||
|
expect(dekA).not.toEqual(dekB);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength';
|
||||||
|
import { generateRandomBytes } from '@/utils/crypto/generateRandomBytes';
|
||||||
|
|
||||||
|
export const generateDek = (): Uint8Array => generateRandomBytes(AES_GCM_KEY_BYTE_LENGTH);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength';
|
||||||
|
import { generateIv } from '@/utils/crypto/generateIv';
|
||||||
|
|
||||||
|
describe('generateIv', () => {
|
||||||
|
it('returns a 12-byte IV for AES-GCM', () => {
|
||||||
|
const iv = generateIv();
|
||||||
|
|
||||||
|
expect(iv).toBeInstanceOf(Uint8Array);
|
||||||
|
expect(iv.byteLength).toBe(AES_GCM_IV_BYTE_LENGTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates distinct IVs across calls', () => {
|
||||||
|
const ivA = generateIv();
|
||||||
|
const ivB = generateIv();
|
||||||
|
|
||||||
|
expect(ivA).not.toEqual(ivB);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength';
|
||||||
|
import { generateRandomBytes } from '@/utils/crypto/generateRandomBytes';
|
||||||
|
|
||||||
|
export const generateIv = (): Uint8Array => generateRandomBytes(AES_GCM_IV_BYTE_LENGTH);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { generateRandomBytes } from '@/utils/crypto/generateRandomBytes';
|
||||||
|
|
||||||
|
describe('generateRandomBytes', () => {
|
||||||
|
it('returns a Uint8Array of the requested length', () => {
|
||||||
|
expect(generateRandomBytes(0)).toEqual(new Uint8Array(0));
|
||||||
|
expect(generateRandomBytes(1).byteLength).toBe(1);
|
||||||
|
expect(generateRandomBytes(4096).byteLength).toBe(4096);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return the same buffer for consecutive calls', () => {
|
||||||
|
const first = generateRandomBytes(32);
|
||||||
|
const second = generateRandomBytes(32);
|
||||||
|
|
||||||
|
expect(first).not.toEqual(second);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not trivially constant for non-zero length', () => {
|
||||||
|
const bytes = generateRandomBytes(64);
|
||||||
|
const allSame = bytes.every(byte => byte === bytes[0]);
|
||||||
|
|
||||||
|
expect(allSame).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const generateRandomBytes = (length: number): Uint8Array => {
|
||||||
|
const bytes = new Uint8Array(length);
|
||||||
|
|
||||||
|
return crypto.getRandomValues(bytes);
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength';
|
||||||
|
import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm';
|
||||||
|
import { generateDek } from '@/utils/crypto/generateDek';
|
||||||
|
import { generateIv } from '@/utils/crypto/generateIv';
|
||||||
|
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
|
||||||
|
|
||||||
|
describe('importAesGcmKey', () => {
|
||||||
|
it('imports a raw 32-byte key for AES-GCM encrypt and decrypt', async () => {
|
||||||
|
const dek = generateDek();
|
||||||
|
const key = await importAesGcmKey(dek);
|
||||||
|
|
||||||
|
expect(key.algorithm.name).toBe('AES-GCM');
|
||||||
|
expect(key.type).toBe('secret');
|
||||||
|
expect(key.extractable).toBe(false);
|
||||||
|
expect(key.usages).toEqual(['encrypt', 'decrypt']);
|
||||||
|
|
||||||
|
const iv = generateIv();
|
||||||
|
const plaintext = new TextEncoder().encode('key-import-smoke');
|
||||||
|
|
||||||
|
await expect(encryptAesGcm(plaintext, key, iv)).resolves.toBeInstanceOf(Uint8Array);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong-length raw keys', async () => {
|
||||||
|
const shortKey = new Uint8Array(AES_GCM_KEY_BYTE_LENGTH - 1);
|
||||||
|
const longKey = new Uint8Array(AES_GCM_KEY_BYTE_LENGTH + 1);
|
||||||
|
|
||||||
|
await expect(importAesGcmKey(shortKey)).rejects.toThrow(
|
||||||
|
`AES-GCM key must be ${AES_GCM_KEY_BYTE_LENGTH} bytes`
|
||||||
|
);
|
||||||
|
await expect(importAesGcmKey(longKey)).rejects.toThrow(
|
||||||
|
`AES-GCM key must be ${AES_GCM_KEY_BYTE_LENGTH} bytes`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength';
|
||||||
|
import { toArrayBuffer } from '@/utils/toArrayBuffer';
|
||||||
|
|
||||||
|
export const importAesGcmKey = async (rawKey: Uint8Array): Promise<CryptoKey> => {
|
||||||
|
if (rawKey.byteLength !== AES_GCM_KEY_BYTE_LENGTH) {
|
||||||
|
throw new Error(`AES-GCM key must be ${AES_GCM_KEY_BYTE_LENGTH} bytes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.subtle.importKey('raw', toArrayBuffer(rawKey), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
|
||||||
|
|
||||||
|
describe('base64ToBytes', () => {
|
||||||
|
it('decodes RFC 4648 examples', () => {
|
||||||
|
expect(base64ToBytes('Zg==')).toEqual(new Uint8Array([102]));
|
||||||
|
expect(base64ToBytes('Zm8=')).toEqual(new Uint8Array([102, 111]));
|
||||||
|
expect(base64ToBytes('Zm9v')).toEqual(new Uint8Array([102, 111, 111]));
|
||||||
|
expect(base64ToBytes('Zm9vYg==')).toEqual(new Uint8Array([102, 111, 111, 98]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes empty input', () => {
|
||||||
|
expect(base64ToBytes('')).toEqual(new Uint8Array([]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes every byte value via roundtrip with btoa', () => {
|
||||||
|
const bytes = new Uint8Array(256);
|
||||||
|
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
bytes[i] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
const binary = Array.from(bytes, byte => String.fromCharCode(byte)).join('');
|
||||||
|
const base64 = btoa(binary);
|
||||||
|
|
||||||
|
expect(base64ToBytes(base64)).toEqual(bytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid base64', () => {
|
||||||
|
expect(() => base64ToBytes('not!!!valid')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
|
||||||
|
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
|
||||||
|
import { bytesToBase64Url } from '@/utils/encoding/bytesToBase64Url';
|
||||||
|
import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes';
|
||||||
|
|
||||||
|
describe('base64UrlToBytes', () => {
|
||||||
|
it('decodes with and without restored padding', () => {
|
||||||
|
const bytes = new Uint8Array([1, 2, 3]);
|
||||||
|
const standard = bytesToBase64(bytes);
|
||||||
|
const url = bytesToBase64Url(bytes);
|
||||||
|
|
||||||
|
expect(base64UrlToBytes(url)).toEqual(bytes);
|
||||||
|
expect(base64UrlToBytes(standard.replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''))).toEqual(bytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches standard base64 decode for padded input', () => {
|
||||||
|
const standard = 'AQIDBA==';
|
||||||
|
const url = 'AQIDBA';
|
||||||
|
|
||||||
|
expect(base64UrlToBytes(url)).toEqual(base64ToBytes(standard));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid input', () => {
|
||||||
|
expect(() => base64UrlToBytes('%%%')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
|
||||||
|
|
||||||
|
export const base64UrlToBytes = (base64Url: string): Uint8Array => {
|
||||||
|
let base64 = base64Url.split('-').join('+').split('_').join('/');
|
||||||
|
|
||||||
|
const padding = base64.length % 4;
|
||||||
|
|
||||||
|
if (padding > 0) {
|
||||||
|
base64 += '='.repeat(4 - padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64ToBytes(base64);
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
|
||||||
|
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
|
||||||
|
|
||||||
|
const UPLOAD_API_BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||||
|
|
||||||
|
describe('bytesToBase64', () => {
|
||||||
|
it('encodes RFC 4648 examples', () => {
|
||||||
|
expect(bytesToBase64(new Uint8Array([102]))).toBe('Zg==');
|
||||||
|
expect(bytesToBase64(new Uint8Array([102, 111]))).toBe('Zm8=');
|
||||||
|
expect(bytesToBase64(new Uint8Array([102, 111, 111]))).toBe('Zm9v');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes empty input', () => {
|
||||||
|
expect(bytesToBase64(new Uint8Array([]))).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('roundtrips with base64ToBytes for random lengths', () => {
|
||||||
|
for (const length of [1, 2, 3, 4, 5, 12, 16, 32, 64, 1000]) {
|
||||||
|
const bytes = new Uint8Array(length);
|
||||||
|
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
bytes[i] = (i * 17 + length) % 256;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(base64ToBytes(bytesToBase64(bytes))).toEqual(bytes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces strings accepted by the upload API base64 validator', () => {
|
||||||
|
const ivLike = new Uint8Array(12);
|
||||||
|
|
||||||
|
crypto.getRandomValues(ivLike);
|
||||||
|
|
||||||
|
const encoded = bytesToBase64(ivLike);
|
||||||
|
|
||||||
|
expect(UPLOAD_API_BASE64_PATTERN.test(encoded)).toBe(true);
|
||||||
|
expect(base64ToBytes(encoded)).toEqual(ivLike);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const bytesToBase64 = (bytes: Uint8Array): string => {
|
||||||
|
const binary = Array.from(bytes, byte => String.fromCharCode(byte)).join('');
|
||||||
|
|
||||||
|
return btoa(binary);
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes';
|
||||||
|
import { bytesToBase64Url } from '@/utils/encoding/bytesToBase64Url';
|
||||||
|
|
||||||
|
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/;
|
||||||
|
|
||||||
|
describe('bytesToBase64Url', () => {
|
||||||
|
it('uses only URL-safe characters and omits padding', () => {
|
||||||
|
const bytes = new Uint8Array([255, 254, 253, 252, 251, 250]);
|
||||||
|
|
||||||
|
const encoded = bytesToBase64Url(bytes);
|
||||||
|
|
||||||
|
expect(BASE64URL_PATTERN.test(encoded)).toBe(true);
|
||||||
|
expect(encoded.includes('+')).toBe(false);
|
||||||
|
expect(encoded.includes('/')).toBe(false);
|
||||||
|
expect(encoded.includes('=')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('roundtrips with base64UrlToBytes', () => {
|
||||||
|
for (const length of [0, 1, 2, 3, 4, 12, 16, 32, 64]) {
|
||||||
|
const bytes = new Uint8Array(length);
|
||||||
|
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
|
||||||
|
expect(base64UrlToBytes(bytesToBase64Url(bytes))).toEqual(bytes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('differs from standard base64 when URL encoding is required', () => {
|
||||||
|
const bytes = new Uint8Array([251, 255, 255]);
|
||||||
|
|
||||||
|
expect(bytesToBase64Url(bytes)).not.toBe(
|
||||||
|
btoa(Array.from(bytes, b => String.fromCharCode(b)).join(''))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
|
||||||
|
|
||||||
|
export const bytesToBase64Url = (bytes: Uint8Array): string => {
|
||||||
|
let base64 = bytesToBase64(bytes).split('+').join('-').split('/').join('_');
|
||||||
|
|
||||||
|
while (base64.endsWith('=')) {
|
||||||
|
base64 = base64.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64;
|
||||||
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export function toArrayBuffer(data: ArrayBuffer | Uint8Array): ArrayBuffer;
|
||||||
|
export function toArrayBuffer(data: unknown): ArrayBuffer | null;
|
||||||
export function toArrayBuffer(data: unknown): ArrayBuffer | null {
|
export function toArrayBuffer(data: unknown): ArrayBuffer | null {
|
||||||
if (data instanceof ArrayBuffer) {
|
if (data instanceof ArrayBuffer) {
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -16,5 +16,5 @@
|
|||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, 'src')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.spec.ts']
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user