From 3bbec5a4ac3e765b8fc76a94590221018274d70f Mon Sep 17 00:00:00 2001 From: Ralksta <16941050+ralksta@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:51:39 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITICAL/H?= =?UTF-8?q?IGH]=20Upgrade=20token=20encryption=20to=20AES-GCM=20(#117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgraded `encodeAssetId` and `decodeAssetId` to use `aes-256-gcm` (Authenticated Encryption) instead of `aes-256-cbc`. AES-CBC lacks authentication, leaving it potentially susceptible to padding oracle attacks or ciphertext manipulation. The new token adds a `v2:` prefix which strictly enforces AES-GCM while supporting legacy fallback for cached urls. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- lib/__tests__/tokens.test.ts | 19 ++++++++++++++--- lib/tokens.ts | 41 +++++++++++++++++++++++++----------- 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/lib/__tests__/tokens.test.ts b/lib/__tests__/tokens.test.ts index d0fce3d..e7711c2 100644 --- a/lib/__tests__/tokens.test.ts +++ b/lib/__tests__/tokens.test.ts @@ -18,12 +18,11 @@ describe('tokens', () => { }); describe('encodeAssetId', () => { - it('produces a non-empty base64url string', () => { + it('produces a non-empty string with v2: prefix', () => { const token = encodeAssetId(SAMPLE_UUID); expect(token).toBeTruthy(); expect(typeof token).toBe('string'); - // base64url characters only - expect(token).toMatch(/^[A-Za-z0-9_-]+$/); + expect(token).toMatch(/^v2:[A-Za-z0-9_-]+$/); }); it('is deterministic — same input yields same token', () => { @@ -46,6 +45,20 @@ describe('tokens', () => { expect(decoded).toBe(SAMPLE_UUID); }); + it('round-trips a legacy aes-256-cbc token', async () => { + // Manually encode using legacy method + const crypto = await import('crypto'); + const authSecret = 'test-auth-secret-32-chars-long-min'; + const key = crypto.createHash('sha256').update(authSecret).digest(); + const iv = crypto.createHash('sha256').update(SAMPLE_UUID).digest().subarray(0, 16); + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); + const encrypted = Buffer.concat([cipher.update(SAMPLE_UUID, 'utf8'), cipher.final()]); + const legacyToken = Buffer.concat([iv, encrypted]).toString('base64url'); + + const decoded = decodeAssetId(legacyToken); + expect(decoded).toBe(SAMPLE_UUID); + }); + it('returns null for a garbage string', () => { expect(decodeAssetId('not-a-valid-token')).toBeNull(); }); diff --git a/lib/tokens.ts b/lib/tokens.ts index cc82bec..9305840 100644 --- a/lib/tokens.ts +++ b/lib/tokens.ts @@ -21,7 +21,7 @@ function getKey(): Buffer { /** * Encode an asset ID into an opaque URL-safe token. - * Note: The AES-CBC deterministic IV causes the identical input asset ID + * Note: The AES-GCM deterministic IV causes the identical input asset ID * to encrypt to the exact same cipher token. This provides URL obfuscation * and caching consistency, but it does NOT provide k-anonymous cryptographic * security guarantees against recognizing identical items if intercepted. @@ -31,10 +31,12 @@ export function encodeAssetId(assetId: string): string { // Deterministic IV from asset ID (same input → same token). // SHA-256 slice → first 16 bytes. MD5 was deprecated for cryptographic use. const iv = crypto.createHash('sha256').update(assetId).digest().subarray(0, 16); - const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); const encrypted = Buffer.concat([cipher.update(assetId, 'utf8'), cipher.final()]); - // Compact: base64url(iv + ciphertext) - return Buffer.concat([iv, encrypted]).toString('base64url'); + const authTag = cipher.getAuthTag(); + // Compact: base64url(iv + authTag + ciphertext) with v2 prefix + const payload = Buffer.concat([iv, authTag, encrypted]).toString('base64url'); + return `v2:${payload}`; } /** @@ -44,15 +46,30 @@ export function encodeAssetId(assetId: string): string { export function decodeAssetId(token: string): string | null { try { const key = getKey(); - const data = Buffer.from(token, 'base64url'); - if (data.length < 17) return null; // iv(16) + at least 1 byte - const iv = data.subarray(0, 16); - const encrypted = data.subarray(16); - const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); - const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString( - 'utf8', - ); + let decrypted: string; + + if (token.startsWith('v2:')) { + const data = Buffer.from(token.slice(3), 'base64url'); + if (data.length < 33) return null; // iv(16) + authTag(16) + at least 1 byte + + const iv = data.subarray(0, 16); + const authTag = data.subarray(16, 32); + const encrypted = data.subarray(32); + + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(authTag); + decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8'); + } else { + // Legacy aes-256-cbc fallback + const data = Buffer.from(token, 'base64url'); + if (data.length < 17) return null; // iv(16) + at least 1 byte + + const iv = data.subarray(0, 16); + const encrypted = data.subarray(16); + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); + decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8'); + } // Validate it looks like a UUID const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;