Slave/client crypto #7

Merged
nobswebdev merged 5 commits from slave/client-crypto into master 2026-10-20 17:30:03 +00:00
29 changed files with 2021 additions and 2 deletions
Showing only changes of commit 0238858c8b - Show all commits
+1494
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -7,6 +7,7 @@
"dev": "vite --host 0.0.0.0",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"postinstall": "node patches/patch-exiftool-stderr.mjs && node patches/patch-zeroperl-worker-fetch.mjs"
},
"dependencies": {
@@ -25,6 +26,7 @@
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"vite": "^8.0.4",
"vitest": "^3.2.7",
"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 =
'/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);
});
});
+4
View File
@@ -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);
});
});
+4
View File
@@ -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;
};
+2
View File
@@ -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 {
if (data instanceof ArrayBuffer) {
return data;
+1 -1
View File
@@ -16,5 +16,5 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
"include": ["vite.config.ts", "vitest.config.ts"]
}
+17
View File
@@ -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']
}
});