🛡️ 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:
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user