🛡️ Sentinel: [CRITICAL/HIGH] Upgrade token encryption to AES-GCM (#117)

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>
This commit is contained in:
Ralksta
2026-04-21 22:51:39 +02:00
committed by GitHub
parent 13fcfe8436
commit 3bbec5a4ac
2 changed files with 45 additions and 15 deletions

View File

@@ -18,12 +18,11 @@ describe('tokens', () => {
}); });
describe('encodeAssetId', () => { describe('encodeAssetId', () => {
it('produces a non-empty base64url string', () => { it('produces a non-empty string with v2: prefix', () => {
const token = encodeAssetId(SAMPLE_UUID); const token = encodeAssetId(SAMPLE_UUID);
expect(token).toBeTruthy(); expect(token).toBeTruthy();
expect(typeof token).toBe('string'); expect(typeof token).toBe('string');
// base64url characters only expect(token).toMatch(/^v2:[A-Za-z0-9_-]+$/);
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
}); });
it('is deterministic — same input yields same token', () => { it('is deterministic — same input yields same token', () => {
@@ -46,6 +45,20 @@ describe('tokens', () => {
expect(decoded).toBe(SAMPLE_UUID); 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', () => { it('returns null for a garbage string', () => {
expect(decodeAssetId('not-a-valid-token')).toBeNull(); expect(decodeAssetId('not-a-valid-token')).toBeNull();
}); });

View File

@@ -21,7 +21,7 @@ function getKey(): Buffer {
/** /**
* Encode an asset ID into an opaque URL-safe token. * 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 * to encrypt to the exact same cipher token. This provides URL obfuscation
* and caching consistency, but it does NOT provide k-anonymous cryptographic * and caching consistency, but it does NOT provide k-anonymous cryptographic
* security guarantees against recognizing identical items if intercepted. * 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). // Deterministic IV from asset ID (same input → same token).
// SHA-256 slice → first 16 bytes. MD5 was deprecated for cryptographic use. // SHA-256 slice → first 16 bytes. MD5 was deprecated for cryptographic use.
const iv = crypto.createHash('sha256').update(assetId).digest().subarray(0, 16); 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()]); const encrypted = Buffer.concat([cipher.update(assetId, 'utf8'), cipher.final()]);
// Compact: base64url(iv + ciphertext) const authTag = cipher.getAuthTag();
return Buffer.concat([iv, encrypted]).toString('base64url'); // 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 { export function decodeAssetId(token: string): string | null {
try { try {
const key = getKey(); const key = getKey();
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'); const data = Buffer.from(token, 'base64url');
if (data.length < 17) return null; // iv(16) + at least 1 byte if (data.length < 17) return null; // iv(16) + at least 1 byte
const iv = data.subarray(0, 16); const iv = data.subarray(0, 16);
const encrypted = data.subarray(16); const encrypted = data.subarray(16);
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString( decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
'utf8', }
);
// Validate it looks like a UUID // 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; const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;