From 32d1aa9fa28b8456abc6e0b86a71193b913b1c26 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Fri, 5 Jun 2026 07:12:56 +0000 Subject: [PATCH] fix(common): use cryptographically secure SHA-256 for transfer cache key generation Replace the custom 64-bit non-cryptographic combined DJB2 hashing implementation in HttpTransferCache with a robust, pure JavaScript, synchronous SHA-256 algorithm. Using DJB2 is vulnerable to pre-image and second-preimage attacks due to its small 64-bit keyspace and mathematical simplicity. An attacker could craft colliding request inputs to poison the cache, potentially causing a CDN or the application to serve the wrong cached response to legitimate users. SHA-256 provides strong cryptographic collision resistance, preventing cache key collision attacks. A custom synchronous implementation is required because the Web Crypto API (`crypto.subtle.digest`) is asynchronous, whereas the transfer cache state lookup and interceptor flow must operate synchronously. Also, update the unit tests to dynamically verify the custom SHA-256 output against the native Web Crypto API. --- packages/common/http/src/transfer_cache.ts | 164 ++++++++++++++++-- .../common/http/test/transfer_cache_spec.ts | 35 +++- .../hydration/bundle.golden_symbols.json | 4 +- 3 files changed, 188 insertions(+), 15 deletions(-) diff --git a/packages/common/http/src/transfer_cache.ts b/packages/common/http/src/transfer_cache.ts index 4a0919c8e3b..d220bc85d48 100644 --- a/packages/common/http/src/transfer_cache.ts +++ b/packages/common/http/src/transfer_cache.ts @@ -331,23 +331,163 @@ function makeCacheKey( } /** - * A method that returns a hash representation of a string using a variant of DJB2 hash - * algorithm. + * SHA-256 Constants (first 32 bits of the fractional parts of the cube roots of the first 64 primes 2..311): + */ +const SHA256_ROUND_CONSTANTS = /* @__PURE__ */ new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +let textEncoder: TextEncoder | undefined; + +/** + * Generates a SHA-256 hash representation of a string. * - * This is the same hashing logic that is used to generate component ids. + * Note: A custom synchronous SHA-256 implementation is used here because the Web Crypto API + * (`crypto.subtle.digest`) is strictly asynchronous (Promise-based), whereas the transfer cache + * state lookup and interceptor flow must operate synchronously due to the HttpResource API. + * + * The previous DJB2 hashing logic was vulnerable to pre-image and second-preimage attacks due to + * its small 64-bit keyspace and mathematical simplicity. An attacker could craft colliding request + * inputs to poison the cache, potentially causing a CDN or the application to serve the wrong + * cached response to legitimate users. SHA-256 provides strong cryptographic collision resistance, + * preventing cache key collision attacks. */ -function generateHash(value: string): string { - let hash = 0; +export function generateHash(value: string): string { + textEncoder ??= new TextEncoder(); + const inputBytes = textEncoder.encode(value); + + // Initial hash values (first 32 bits of the fractional parts of the square roots of the first 8 primes 2..19): + let hashState0 = 0x6a09e667; + let hashState1 = 0xbb67ae85; + let hashState2 = 0x3c6ef372; + let hashState3 = 0xa54ff53a; + let hashState4 = 0x510e527f; + let hashState5 = 0x9b05688c; + let hashState6 = 0x1f83d9ab; + let hashState7 = 0x5be0cd19; + + // Pre-processing (Padding): + const messageLengthInBits = inputBytes.length * 8; + + // The total length of the padded message must be a multiple of 64 bytes (512 bits) + const paddedLengthInBytes = (((inputBytes.length + 8) >> 6) + 1) << 6; + const paddedBytes = new Uint8Array(paddedLengthInBytes); + paddedBytes.set(inputBytes); + paddedBytes[inputBytes.length] = 0x80; // Append a single '1' bit (0x80 byte) + + const paddedBytesView = new DataView(paddedBytes.buffer); + const lowBits = messageLengthInBits >>> 0; + const highBits = (messageLengthInBits / 0x100000000) >>> 0; + paddedBytesView.setUint32(paddedLengthInBytes - 8, highBits, false); + paddedBytesView.setUint32(paddedLengthInBytes - 4, lowBits, false); + + // Process the message in successive 64-byte chunks: + const messageSchedule = new Uint32Array(64); + for (let chunkOffset = 0; chunkOffset < paddedLengthInBytes; chunkOffset += 64) { + // Initialize first 16 words of the message schedule: + for (let i = 0; i < 16; i++) { + messageSchedule[i] = paddedBytesView.getUint32(chunkOffset + i * 4, false); + } - for (const char of value) { - hash = (Math.imul(31, hash) + char.charCodeAt(0)) << 0; - } + // Extend to 64 words: + for (let i = 16; i < 64; i++) { + const prevWord15 = messageSchedule[i - 15]; + const sigma0 = + (((prevWord15 >>> 7) | (prevWord15 << 25)) ^ + ((prevWord15 >>> 18) | (prevWord15 << 14)) ^ + (prevWord15 >>> 3)) >>> + 0; + + const prevWord2 = messageSchedule[i - 2]; + const sigma1 = + (((prevWord2 >>> 17) | (prevWord2 << 15)) ^ + ((prevWord2 >>> 19) | (prevWord2 << 13)) ^ + (prevWord2 >>> 10)) >>> + 0; + + messageSchedule[i] = + (messageSchedule[i - 16] + sigma0 + messageSchedule[i - 7] + sigma1) >>> 0; + } - // Force positive number hash. - // 2147483647 = equivalent of Integer.MAX_VALUE. - hash += 2147483647 + 1; + // Initialize working variables to current hash values: + let workingStateA = hashState0; + let workingStateB = hashState1; + let workingStateC = hashState2; + let workingStateD = hashState3; + let workingStateE = hashState4; + let workingStateF = hashState5; + let workingStateG = hashState6; + let workingStateH = hashState7; + + // Compression function main loop: + for (let i = 0; i < 64; i++) { + const capitalSigma1 = + (((workingStateE >>> 6) | (workingStateE << 26)) ^ + ((workingStateE >>> 11) | (workingStateE << 21)) ^ + ((workingStateE >>> 25) | (workingStateE << 7))) >>> + 0; + const chFunction = ((workingStateE & workingStateF) ^ (~workingStateE & workingStateG)) >>> 0; + const temp1 = + (workingStateH + + capitalSigma1 + + chFunction + + SHA256_ROUND_CONSTANTS[i] + + messageSchedule[i]) >>> + 0; + + const capitalSigma0 = + (((workingStateA >>> 2) | (workingStateA << 30)) ^ + ((workingStateA >>> 13) | (workingStateA << 19)) ^ + ((workingStateA >>> 22) | (workingStateA << 10))) >>> + 0; + const majFunction = + ((workingStateA & workingStateB) ^ + (workingStateA & workingStateC) ^ + (workingStateB & workingStateC)) >>> + 0; + const temp2 = (capitalSigma0 + majFunction) >>> 0; + + workingStateH = workingStateG; + workingStateG = workingStateF; + workingStateF = workingStateE; + workingStateE = (workingStateD + temp1) >>> 0; + workingStateD = workingStateC; + workingStateC = workingStateB; + workingStateB = workingStateA; + workingStateA = (temp1 + temp2) >>> 0; + } - return hash.toString(); + // Update intermediate hash state: + hashState0 = (hashState0 + workingStateA) >>> 0; + hashState1 = (hashState1 + workingStateB) >>> 0; + hashState2 = (hashState2 + workingStateC) >>> 0; + hashState3 = (hashState3 + workingStateD) >>> 0; + hashState4 = (hashState4 + workingStateE) >>> 0; + hashState5 = (hashState5 + workingStateF) >>> 0; + hashState6 = (hashState6 + workingStateG) >>> 0; + hashState7 = (hashState7 + workingStateH) >>> 0; + } + + // Produce the final 64-character hexadecimal hash: + return [ + hashState0, + hashState1, + hashState2, + hashState3, + hashState4, + hashState5, + hashState6, + hashState7, + ] + .map((x) => x.toString(16).padStart(8, '0')) + .join(''); } /** diff --git a/packages/common/http/test/transfer_cache_spec.ts b/packages/common/http/test/transfer_cache_spec.ts index 43e4101fb2b..d0aa26ae66c 100644 --- a/packages/common/http/test/transfer_cache_spec.ts +++ b/packages/common/http/test/transfer_cache_spec.ts @@ -31,6 +31,7 @@ import { REQ_URL, transferCacheInterceptorFn, withHttpTransferCache, + generateHash, } from '../src/transfer_cache'; import {HttpTestingController, provideHttpClientTesting} from '../testing'; import {PLATFORM_BROWSER_ID, PLATFORM_SERVER_ID} from '../../src/platform_id'; @@ -353,7 +354,7 @@ describe('TransferCache', () => { const transferState = TestBed.inject(TransferState); expect(JSON.parse(transferState.toJson()) as Record).toEqual({ - '2400571479': { + '2da5dfaf112523258ec9c26a0abe9a093b59ed7dbe5f43e4b5ee25a407ac9cf0': { [BODY]: 'foo', [HEADERS]: {}, [STATUS]: 200, @@ -361,7 +362,7 @@ describe('TransferCache', () => { [REQ_URL]: '/test-1', [RESPONSE_TYPE]: 'json', }, - '2400572440': { + '869485290d9385f3c0a9ba571918c335bbca9e03373bf8260d02f2b7dd335849': { [BODY]: 'buzz', [HEADERS]: {}, [STATUS]: 200, @@ -1033,4 +1034,34 @@ describe('TransferCache', () => { }); }); }); + + describe('generateHash', () => { + async function computeNativeSha256(value: string): Promise { + const msgUint8 = new TextEncoder().encode(value); + const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + } + + it('should generate standard SHA-256 hashes matching Web Crypto specs', async () => { + const testCases = [ + '', + 'hello', + 'angular', + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + 'Angular 🚀', + 'a'.repeat(55), + 'a'.repeat(56), + 'a'.repeat(63), + 'a'.repeat(64), + 'a'.repeat(65), + 'a'.repeat(1000), + ]; + + for (const testCase of testCases) { + const expected = await computeNativeSha256(testCase); + expect(generateHash(testCase)).withContext(`For: ${testCase}`).toBe(expected); + } + }); + }); }); diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 4bdf9abd7ce..95e129d3fd2 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -197,6 +197,7 @@ "RuntimeError", "SCHEDULE_IN_ROOT_ZONE", "SCHEDULE_IN_ROOT_ZONE_DEFAULT", + "SHA256_ROUND_CONSTANTS", "SIGNAL", "SIMPLE_CHANGES_STORE", "SKIP_HYDRATION_ATTR_NAME", @@ -781,6 +782,7 @@ "stringifyForError", "subscribeOn", "syncViewWithBlueprint", + "textEncoder", "throwInvalidWriteToSignalErrorFn", "throwProviderNotFoundError", "timeoutProvider", @@ -807,4 +809,4 @@ ], "lazy": [] } -} +} \ No newline at end of file