Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 152 additions & 12 deletions packages/common/http/src/transfer_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
}

/**
Expand Down
35 changes: 33 additions & 2 deletions packages/common/http/test/transfer_cache_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -353,15 +354,15 @@ describe('TransferCache', () => {

const transferState = TestBed.inject(TransferState);
expect(JSON.parse(transferState.toJson()) as Record<string, unknown>).toEqual({
'2400571479': {
'2da5dfaf112523258ec9c26a0abe9a093b59ed7dbe5f43e4b5ee25a407ac9cf0': {
[BODY]: 'foo',
[HEADERS]: {},
[STATUS]: 200,
[STATUS_TEXT]: 'OK',
[REQ_URL]: '/test-1',
[RESPONSE_TYPE]: 'json',
},
'2400572440': {
'869485290d9385f3c0a9ba571918c335bbca9e03373bf8260d02f2b7dd335849': {
[BODY]: 'buzz',
[HEADERS]: {},
[STATUS]: 200,
Expand Down Expand Up @@ -1033,4 +1034,34 @@ describe('TransferCache', () => {
});
});
});

describe('generateHash', () => {
async function computeNativeSha256(value: string): Promise<string> {
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);
}
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -781,6 +782,7 @@
"stringifyForError",
"subscribeOn",
"syncViewWithBlueprint",
"textEncoder",
"throwInvalidWriteToSignalErrorFn",
"throwProviderNotFoundError",
"timeoutProvider",
Expand All @@ -807,4 +809,4 @@
],
"lazy": []
}
}
}
Loading