ba-fledge-util.sub.js (14030B)
1 'use strict'; 2 3 let BA = {}; 4 5 (function(BA) { 6 const TestPrivateKey = new Uint8Array([ 7 0xff, 0x1f, 0x47, 0xb1, 0x68, 0xb6, 0xb9, 0xea, 0x65, 0xf7, 0x97, 8 0x4f, 0xf2, 0x2e, 0xf2, 0x36, 0x94, 0xe2, 0xf6, 0xb6, 0x8d, 0x66, 9 0xf3, 0xa7, 0x64, 0x14, 0x28, 0xd4, 0x45, 0x35, 0x01, 0x8f 10 ]); 11 12 const _hpkeModulePromise = import('../third_party/hpke-js/hpke.js'); 13 14 // Common utilities. 15 16 function _get16(buffer, offset) { 17 return buffer[offset] << 8 | buffer[offset + 1]; 18 } 19 20 function _get32(buffer, offset) { 21 return buffer[offset] << 24 | buffer[offset + 1] << 16 | 22 buffer[offset + 2] << 8 | buffer[offset + 3]; 23 } 24 25 function _put16(buffer, offset, val) { 26 buffer[offset] = val >> 8; 27 buffer[offset + 1] = val & 0xFF; 28 } 29 30 function _put32(buffer, offset, val) { 31 buffer[offset] = (val >> 24) & 0xFF; 32 buffer[offset + 1] = (val >> 16) & 0xFF; 33 buffer[offset + 2] = (val >> 8) & 0xFF; 34 buffer[offset + 3] = val & 0xFF; 35 } 36 37 // Concatenates two Uint8Array's. 38 function _concat(a, b) { 39 let c = new Uint8Array(a.length + b.length); 40 for (var i = 0; i < a.length; ++i) { 41 c[i] = a[i]; 42 } 43 for (var i = 0; i < b.length; ++i) { 44 c[i + a.length] = b[i]; 45 } 46 return c; 47 } 48 49 function _toArrayBuffer(typedArray) { 50 return typedArray.buffer.slice( 51 typedArray.byteOffset, typedArray.byteOffset + typedArray.byteLength); 52 } 53 54 function _toBytesArrayBuffer(str) { 55 return _toArrayBuffer(new TextEncoder().encode(str)); 56 } 57 58 function _bufferAsStream(buffer) { 59 return new ReadableStream({ 60 start: controller => { 61 controller.enqueue(buffer); 62 controller.close(); 63 } 64 }); 65 } 66 67 // Returns an ArrayBuffer. 68 async function _applyTransform(inData, transform) { 69 const resultResponse = 70 new Response(_bufferAsStream(inData).pipeThrough(transform)); 71 const resultBlob = await resultResponse.blob(); 72 return await resultBlob.arrayBuffer(); 73 } 74 75 // Returns an ArrayBuffer (promise). 76 async function _gzip(inData) { 77 const compress = new CompressionStream('gzip'); 78 return _applyTransform(inData, compress); 79 } 80 81 // Returns an ArrayBuffer (promise). 82 async function _gunzip(inData) { 83 const decompress = new DecompressionStream('gzip'); 84 return _applyTransform(inData, decompress); 85 } 86 87 // InterestGroupData decoding helpers. 88 89 function _decodeIgDataHeader(igData) { 90 if (igData.length < 8) { 91 throw 'Not enough data for B&A and OHTTP headers'; 92 } 93 return { 94 version: igData[0], 95 keyId: igData[1], 96 kemId: _get16(igData, 2), 97 kdfId: _get16(igData, 4), 98 aeadId: _get16(igData, 6), 99 payload: igData.slice(8) 100 }; 101 } 102 103 // Splits up the actual B&A IG Data into the enc and ct portions 104 // for HPKE, using `suite` for sizing; and also figures out the appropriate 105 // info string. 106 function _splitIgDataPayloadIntoEncAndCt(header, suite) { 107 const RequestMessageType = 'message/auction request'; 108 109 // From RFC 9458 (Oblivious HTTP): 110 // "2. Build a sequence of bytes (info) by concatenating the ASCII- 111 // encoded string "message/bhttp request"; a zero byte; key_id as an 112 // 8-bit integer; plus kem_id, kdf_id, and aead_id as three 16-bit 113 // integers." 114 // (except we use a different message type string). 115 const infoLength = RequestMessageType.length + 1 + 1 + 6; 116 let info = new Uint8Array(infoLength); 117 for (let pos = 0; pos < RequestMessageType.length; ++pos) { 118 info[pos] = RequestMessageType.charCodeAt(pos); 119 } 120 info[RequestMessageType.length] = 0; 121 info[RequestMessageType.length + 1] = header.keyId; 122 _put16(info, RequestMessageType.length + 2, header.kemId); 123 _put16(info, RequestMessageType.length + 4, header.kdfId); 124 _put16(info, RequestMessageType.length + 6, header.aeadId); 125 return { 126 info: info, 127 enc: header.payload.slice(0, suite.kem.encSize), 128 ct: header.payload.slice(suite.kem.encSize) 129 }; 130 } 131 132 // Unwraps the padding envelope. 133 function _decodeIgDataPaddingHeader(decryptedText) { 134 let length = _get32(decryptedText, 1); 135 let format = decryptedText[0]; 136 137 // We currently only support format 2, which version = 0, and gzip 138 // compression. 139 assert_equals(format, 2); 140 return { 141 format: format, 142 data: decryptedText.slice(5, 5 + length) 143 }; 144 } 145 146 // serverResponse encoding helpers. 147 148 // Takes an ArrayBuffer, returns a Uint8Array. 149 function _frameServerResponse(arrayBuffer) { 150 let array = new Uint8Array(arrayBuffer); 151 let framedLength = 5 + array.length; 152 let framed = new Uint8Array(framedLength); 153 framed[0] = 2; // gzip + ver 0. 154 _put32(framed, 1, array.length); 155 for (let i = 0; i < array.length; ++i) { 156 framed[i + 5] = array[i]; 157 } 158 return framed; 159 } 160 161 async function _encryptServerResponse(payload, decoded) { 162 // This again follows RFC 9458 (Oblivious HTTP), "Encapsulation of 163 // Responses", just with different message type: 164 const ResponseMessageType = 'message/auction response'; 165 const Nk = decoded.cipherSuite.aead.keySize; 166 const Nn = decoded.cipherSuite.aead.nonceSize; 167 let secret = await decoded.receiveContext.export( 168 _toBytesArrayBuffer(ResponseMessageType), Math.max(Nk, Nn)); 169 let responseNonce = new Uint8Array(Math.max(Nk, Nn)); 170 crypto.getRandomValues(responseNonce); 171 let salt = _concat(decoded.enc, responseNonce); 172 let prk = await decoded.cipherSuite.kdf.extract(salt, secret); 173 let aeadKey = 174 await decoded.cipherSuite.kdf.expand(prk, _toBytesArrayBuffer('key'), Nk); 175 let aeadNonce = await decoded.cipherSuite.kdf.expand( 176 prk, _toBytesArrayBuffer('nonce'), Nn); 177 let encContext = decoded.cipherSuite.aead.createEncryptionContext(aeadKey); 178 let ct = await encContext.seal( 179 /*iv=*/ aeadNonce, /*data=*/ payload, 180 /*aad=*/ _toBytesArrayBuffer('')); 181 return _concat(responseNonce, new Uint8Array(ct)); 182 } 183 184 // CBOR requires property names to be in sorted order; but the library we use 185 // doesn't do it automatically. Since it's easy for a test to fail for the 186 // wrong reason if the response isn't specified correctly, this ensures the 187 // proper ordering. It assumes a very simple data model, so no arrays with 188 // holes, no mixture of different kinds of indices in the map, etc. 189 // Getting the sort order right in more complicated cases is outside the 190 // scope of this helper. 191 function _sortForCbor(input) { 192 if (input === null || typeof input !== 'object') { 193 return input; 194 } 195 196 if (input instanceof Array) { 197 let out = []; 198 for (let i = 0; i < input.length; ++i) { 199 out[i] = _sortForCbor(input[i]); 200 } 201 return out; 202 } else if (input instanceof Uint8Array) { 203 return input; 204 } else { 205 let keys = Object.getOwnPropertyNames(input).sort((a, b) => { 206 // CBOR order compares lengths before values. 207 if (a.length < b.length) 208 return -1; 209 if (a.length > b.length) 210 return 1; 211 if (a < b) 212 return -1; 213 if (a > b) 214 return 1; 215 return 0; 216 }); 217 let out = {}; 218 for (let key of keys) { 219 out[key] = _sortForCbor(input[key]); 220 } 221 return out; 222 } 223 } 224 225 // Works on both ArrayBuffer and Uint8Array, returns the same type. 226 function _injectFault(input) { 227 let uint8Input; 228 if (input instanceof ArrayBuffer) { 229 uint8Input = new Uint8Array(input); 230 } else { 231 assert_true(input instanceof Uint8Array); 232 uint8Input = input; 233 } 234 235 // Just mess up the 0th byte. 236 uint8Input[0] = uint8Input[0] ^ 0x4e; 237 238 if (input instanceof ArrayBuffer) { 239 return _toArrayBuffer(uint8Input); 240 } else { 241 return uint8Input; 242 } 243 } 244 245 // Exported API. 246 247 // Decodes the request payload produced by getInterestGroupAdAuctionData into 248 // {paddedSize: ..., message: ..., cipherSuite: ... , receiveContext: ..., 249 // enc:...} 250 BA.decodeInterestGroupData = async function(igData) { 251 const hpke = await _hpkeModulePromise; 252 253 // Decode B&A level headers, and check them. 254 const header = _decodeIgDataHeader(igData); 255 256 // Only version 0 in use now. 257 assert_equals(header.version, 0); 258 259 // Test config uses keyId = 0x14 only 260 // If the feature is not set up properly we may get a different, non-test key. 261 // We can't use assert_equals because it includes the (random) non-test key 262 // in the error message if testing support for this feature is not present. 263 assert_true(header.keyId === 0x14, "valid key Id"); 264 265 // Current cipher config. 266 assert_equals(header.kemId, hpke.KemId.DhkemX25519HkdfSha256); 267 assert_equals(header.kdfId, hpke.KdfId.HkdfSha256); 268 assert_equals(header.aeadId, hpke.AeadId.Aes256Gcm); 269 270 const suite = new hpke.CipherSuite({ 271 kem: header.kemId, 272 kdf: header.kdfId, 273 aead: header.aeadId, 274 }); 275 276 // Split up the ciphertext from encapsulated key, and also compute 277 // the expected message info. 278 const pieces = _splitIgDataPayloadIntoEncAndCt(header, suite); 279 280 // We can now decode the ciphertext. 281 const privateKey = await suite.kem.importKey('raw', TestPrivateKey); 282 const recipient = await suite.createRecipientContext( 283 {recipientKey: privateKey, info: pieces.info, enc: pieces.enc}); 284 const pt = new Uint8Array(await recipient.open(pieces.ct)); 285 286 // The resulting text has yet another envelope with version and size info, 287 // and a bunch of padding. 288 const withoutPadding = _decodeIgDataPaddingHeader(pt); 289 const decoded = CBOR.decode(_toArrayBuffer(withoutPadding.data)); 290 291 // Decompress IGs, CBOR-decode them, and replace in-place. 292 for (let key of Object.getOwnPropertyNames(decoded.interestGroups)) { 293 let val = decoded.interestGroups[key]; 294 let decompressedVal = await _gunzip(val); 295 decoded.interestGroups[key] = CBOR.decode(decompressedVal); 296 } 297 298 return { 299 paddedSize: pt.length, 300 message: decoded, 301 receiveContext: recipient, 302 cipherSuite: suite, 303 enc: pieces.enc 304 }; 305 }; 306 307 BA.injectCborFault = 1; 308 BA.injectGzipFault = 2; 309 BA.injectFrameFault = 4; 310 BA.injectEncryptFault = 8; 311 312 // Encodes, compresses, encrypts, etc., `responseObject` into a proper 313 // serverResponse in reply to `decoded`. 314 BA.encodeServerResponse = 315 async function(responseObject, decoded, injectFaults = 0) { 316 let cborPayload = new Uint8Array(CBOR.encode(_sortForCbor(responseObject))); 317 if (injectFaults & BA.injectCborFault) { 318 cborPayload = _injectFault(cborPayload); 319 } 320 321 let gzipPayload = await _gzip(cborPayload); 322 if (injectFaults & BA.injectGzipFault) { 323 gzipPayload = _injectFault(gzipPayload); 324 } 325 326 let framedPayload = _toArrayBuffer(_frameServerResponse(gzipPayload)); 327 if (injectFaults & BA.injectFrameFault) { 328 framedPayload = _injectFault(framedPayload); 329 } 330 331 let encrypted = await _encryptServerResponse(framedPayload, decoded); 332 if (injectFaults & BA.injectEncryptFault) { 333 encrypted = _injectFault(encrypted); 334 } 335 336 return encrypted; 337 }; 338 339 // Returns a hash string that can be used to authorize a given response, 340 // formatted for use in an Ad-Auction-Result HTTP header. 341 BA.payloadHash = async function(serverResponse) { 342 let hash = 343 new Uint8Array(await crypto.subtle.digest('SHA-256', serverResponse)); 344 let hashString = '' 345 for (let i = 0; i < hash.length; ++i) { 346 hashString += String.fromCharCode(hash[i]); 347 } 348 return btoa(hashString) 349 .replace(/\+/g, '-') 350 .replace(/\//g, '_') 351 .replace(/=+$/g, ''); 352 }; 353 354 // Authorizes each serverResponse hash in `hashes` to be used for 355 // B&A auction result. 356 BA.authorizeServerResponseHashes = async function(hashes) { 357 let authorizeURL = 358 new URL('resources/authorize-server-response.py', window.location); 359 authorizeURL.searchParams.append('hashes', hashes.join(',')); 360 await fetch(authorizeURL, {adAuctionHeaders: true}); 361 }; 362 363 // Authorizes each serverResponse nonce in `nonces` to be used for 364 // B&A auction result. 365 BA.authorizeServerResponseNonces = async function(nonces) { 366 let authorizeURL = 367 new URL('resources/authorize-server-response.py', window.location); 368 authorizeURL.searchParams.append('nonces', nonces.join(',')); 369 await fetch(authorizeURL, {adAuctionHeaders: true}); 370 }; 371 372 BA.configureCoordinator = async function() { 373 // This is async in hope it can eventually use testdriver to configure this. 374 return 'https://{{hosts[][]}}'; 375 }; 376 377 // Runs responseMutator on a minimal correct server response, and expects 378 // either success/failure based on expectWin. 379 BA.testWithMutatedServerResponse = async function( 380 test, expectWin, responseMutator, igMutator = undefined, 381 ownerOverride = null) { 382 let finalIgOwner = ownerOverride ? ownerOverride : window.location.origin; 383 const uuid = generateUuid(test); 384 const adA = createTrackerURL(finalIgOwner, uuid, 'track_get', 'a'); 385 const adB = createTrackerURL(finalIgOwner, uuid, 'track_get', 'b'); 386 const adsArray = 387 [{renderURL: adA, adRenderId: 'a'}, {renderURL: adB, adRenderId: 'b'}]; 388 let ig = {ads: adsArray}; 389 if (igMutator) { 390 igMutator(ig, uuid); 391 } 392 if (ownerOverride !== null) { 393 await joinCrossOriginInterestGroup(test, uuid, ownerOverride, ig); 394 } else { 395 await joinInterestGroup(test, uuid, ig); 396 } 397 398 const result = await navigator.getInterestGroupAdAuctionData({ 399 coordinatorOrigin: await BA.configureCoordinator(), 400 seller: window.location.origin 401 }); 402 assert_true(result.requestId !== null); 403 assert_true(result.request.length > 0); 404 405 let decoded = await BA.decodeInterestGroupData(result.request); 406 407 let serverResponseMsg = { 408 'biddingGroups': {}, 409 'adRenderURL': ig.ads[0].renderURL, 410 'interestGroupName': DEFAULT_INTEREST_GROUP_NAME, 411 'interestGroupOwner': finalIgOwner, 412 }; 413 serverResponseMsg.biddingGroups[finalIgOwner] = [0]; 414 await responseMutator(serverResponseMsg, uuid); 415 416 let serverResponse = 417 await BA.encodeServerResponse(serverResponseMsg, decoded); 418 419 let hashString = await BA.payloadHash(serverResponse); 420 await BA.authorizeServerResponseHashes([hashString]); 421 422 let auctionResult = await navigator.runAdAuction({ 423 'seller': window.location.origin, 424 'interestGroupBuyers': [finalIgOwner], 425 'requestId': result.requestId, 426 'serverResponse': serverResponse, 427 'resolveToConfig': true, 428 }); 429 if (expectWin) { 430 expectSuccess(auctionResult); 431 return auctionResult; 432 } else { 433 expectNoWinner(auctionResult); 434 } 435 }; 436 437 })(BA);