'use strict'; let BA = {}; (function(BA) { const TestPrivateKey = new Uint8Array([ 0xff, 0x1f, 0x47, 0xb1, 0x68, 0xb6, 0xb9, 0xea, 0x65, 0xf7, 0x97, 0x4f, 0xf2, 0x2e, 0xf2, 0x36, 0x94, 0xe2, 0xf6, 0xb6, 0x8d, 0x66, 0xf3, 0xa7, 0x64, 0x14, 0x28, 0xd4, 0x45, 0x35, 0x01, 0x8f ]); const _hpkeModulePromise = import('../third_party/hpke-js/hpke.js'); // Common utilities. function _get16(buffer, offset) { return buffer[offset] << 8 | buffer[offset + 1]; } function _get32(buffer, offset) { return buffer[offset] << 24 | buffer[offset + 1] << 16 | buffer[offset + 2] << 8 | buffer[offset + 3]; } function _put16(buffer, offset, val) { buffer[offset] = val >> 8; buffer[offset + 1] = val & 0xFF; } function _put32(buffer, offset, val) { buffer[offset] = (val >> 24) & 0xFF; buffer[offset + 1] = (val >> 16) & 0xFF; buffer[offset + 2] = (val >> 8) & 0xFF; buffer[offset + 3] = val & 0xFF; } // Concatenates two Uint8Array's. function _concat(a, b) { let c = new Uint8Array(a.length + b.length); for (var i = 0; i < a.length; ++i) { c[i] = a[i]; } for (var i = 0; i < b.length; ++i) { c[i + a.length] = b[i]; } return c; } function _toArrayBuffer(typedArray) { return typedArray.buffer.slice( typedArray.byteOffset, typedArray.byteOffset + typedArray.byteLength); } function _toBytesArrayBuffer(str) { return _toArrayBuffer(new TextEncoder().encode(str)); } function _bufferAsStream(buffer) { return new ReadableStream({ start: controller => { controller.enqueue(buffer); controller.close(); } }); } // Returns an ArrayBuffer. async function _applyTransform(inData, transform) { const resultResponse = new Response(_bufferAsStream(inData).pipeThrough(transform)); const resultBlob = await resultResponse.blob(); return await resultBlob.arrayBuffer(); } // Returns an ArrayBuffer (promise). async function _gzip(inData) { const compress = new CompressionStream('gzip'); return _applyTransform(inData, compress); } // Returns an ArrayBuffer (promise). async function _gunzip(inData) { const decompress = new DecompressionStream('gzip'); return _applyTransform(inData, decompress); } // InterestGroupData decoding helpers. function _decodeIgDataHeader(igData) { if (igData.length < 8) { throw 'Not enough data for B&A and OHTTP headers'; } return { version: igData[0], keyId: igData[1], kemId: _get16(igData, 2), kdfId: _get16(igData, 4), aeadId: _get16(igData, 6), payload: igData.slice(8) }; } // Splits up the actual B&A IG Data into the enc and ct portions // for HPKE, using `suite` for sizing; and also figures out the appropriate // info string. function _splitIgDataPayloadIntoEncAndCt(header, suite) { const RequestMessageType = 'message/auction request'; // From RFC 9458 (Oblivious HTTP): // "2. Build a sequence of bytes (info) by concatenating the ASCII- // encoded string "message/bhttp request"; a zero byte; key_id as an // 8-bit integer; plus kem_id, kdf_id, and aead_id as three 16-bit // integers." // (except we use a different message type string). const infoLength = RequestMessageType.length + 1 + 1 + 6; let info = new Uint8Array(infoLength); for (let pos = 0; pos < RequestMessageType.length; ++pos) { info[pos] = RequestMessageType.charCodeAt(pos); } info[RequestMessageType.length] = 0; info[RequestMessageType.length + 1] = header.keyId; _put16(info, RequestMessageType.length + 2, header.kemId); _put16(info, RequestMessageType.length + 4, header.kdfId); _put16(info, RequestMessageType.length + 6, header.aeadId); return { info: info, enc: header.payload.slice(0, suite.kem.encSize), ct: header.payload.slice(suite.kem.encSize) }; } // Unwraps the padding envelope. function _decodeIgDataPaddingHeader(decryptedText) { let length = _get32(decryptedText, 1); let format = decryptedText[0]; // We currently only support format 2, which version = 0, and gzip // compression. assert_equals(format, 2); return { format: format, data: decryptedText.slice(5, 5 + length) }; } // serverResponse encoding helpers. // Takes an ArrayBuffer, returns a Uint8Array. function _frameServerResponse(arrayBuffer) { let array = new Uint8Array(arrayBuffer); let framedLength = 5 + array.length; let framed = new Uint8Array(framedLength); framed[0] = 2; // gzip + ver 0. _put32(framed, 1, array.length); for (let i = 0; i < array.length; ++i) { framed[i + 5] = array[i]; } return framed; } async function _encryptServerResponse(payload, decoded) { // This again follows RFC 9458 (Oblivious HTTP), "Encapsulation of // Responses", just with different message type: const ResponseMessageType = 'message/auction response'; const Nk = decoded.cipherSuite.aead.keySize; const Nn = decoded.cipherSuite.aead.nonceSize; let secret = await decoded.receiveContext.export( _toBytesArrayBuffer(ResponseMessageType), Math.max(Nk, Nn)); let responseNonce = new Uint8Array(Math.max(Nk, Nn)); crypto.getRandomValues(responseNonce); let salt = _concat(decoded.enc, responseNonce); let prk = await decoded.cipherSuite.kdf.extract(salt, secret); let aeadKey = await decoded.cipherSuite.kdf.expand(prk, _toBytesArrayBuffer('key'), Nk); let aeadNonce = await decoded.cipherSuite.kdf.expand( prk, _toBytesArrayBuffer('nonce'), Nn); let encContext = decoded.cipherSuite.aead.createEncryptionContext(aeadKey); let ct = await encContext.seal( /*iv=*/ aeadNonce, /*data=*/ payload, /*aad=*/ _toBytesArrayBuffer('')); return _concat(responseNonce, new Uint8Array(ct)); } // CBOR requires property names to be in sorted order; but the library we use // doesn't do it automatically. Since it's easy for a test to fail for the // wrong reason if the response isn't specified correctly, this ensures the // proper ordering. It assumes a very simple data model, so no arrays with // holes, no mixture of different kinds of indices in the map, etc. // Getting the sort order right in more complicated cases is outside the // scope of this helper. function _sortForCbor(input) { if (input === null || typeof input !== 'object') { return input; } if (input instanceof Array) { let out = []; for (let i = 0; i < input.length; ++i) { out[i] = _sortForCbor(input[i]); } return out; } else if (input instanceof Uint8Array) { return input; } else { let keys = Object.getOwnPropertyNames(input).sort((a, b) => { // CBOR order compares lengths before values. if (a.length < b.length) return -1; if (a.length > b.length) return 1; if (a < b) return -1; if (a > b) return 1; return 0; }); let out = {}; for (let key of keys) { out[key] = _sortForCbor(input[key]); } return out; } } // Works on both ArrayBuffer and Uint8Array, returns the same type. function _injectFault(input) { let uint8Input; if (input instanceof ArrayBuffer) { uint8Input = new Uint8Array(input); } else { assert_true(input instanceof Uint8Array); uint8Input = input; } // Just mess up the 0th byte. uint8Input[0] = uint8Input[0] ^ 0x4e; if (input instanceof ArrayBuffer) { return _toArrayBuffer(uint8Input); } else { return uint8Input; } } // Exported API. // Decodes the request payload produced by getInterestGroupAdAuctionData into // {paddedSize: ..., message: ..., cipherSuite: ... , receiveContext: ..., // enc:...} BA.decodeInterestGroupData = async function(igData) { const hpke = await _hpkeModulePromise; // Decode B&A level headers, and check them. const header = _decodeIgDataHeader(igData); // Only version 0 in use now. assert_equals(header.version, 0); // Test config uses keyId = 0x14 only // If the feature is not set up properly we may get a different, non-test key. // We can't use assert_equals because it includes the (random) non-test key // in the error message if testing support for this feature is not present. assert_true(header.keyId === 0x14, "valid key Id"); // Current cipher config. assert_equals(header.kemId, hpke.KemId.DhkemX25519HkdfSha256); assert_equals(header.kdfId, hpke.KdfId.HkdfSha256); assert_equals(header.aeadId, hpke.AeadId.Aes256Gcm); const suite = new hpke.CipherSuite({ kem: header.kemId, kdf: header.kdfId, aead: header.aeadId, }); // Split up the ciphertext from encapsulated key, and also compute // the expected message info. const pieces = _splitIgDataPayloadIntoEncAndCt(header, suite); // We can now decode the ciphertext. const privateKey = await suite.kem.importKey('raw', TestPrivateKey); const recipient = await suite.createRecipientContext( {recipientKey: privateKey, info: pieces.info, enc: pieces.enc}); const pt = new Uint8Array(await recipient.open(pieces.ct)); // The resulting text has yet another envelope with version and size info, // and a bunch of padding. const withoutPadding = _decodeIgDataPaddingHeader(pt); const decoded = CBOR.decode(_toArrayBuffer(withoutPadding.data)); // Decompress IGs, CBOR-decode them, and replace in-place. for (let key of Object.getOwnPropertyNames(decoded.interestGroups)) { let val = decoded.interestGroups[key]; let decompressedVal = await _gunzip(val); decoded.interestGroups[key] = CBOR.decode(decompressedVal); } return { paddedSize: pt.length, message: decoded, receiveContext: recipient, cipherSuite: suite, enc: pieces.enc }; }; BA.injectCborFault = 1; BA.injectGzipFault = 2; BA.injectFrameFault = 4; BA.injectEncryptFault = 8; // Encodes, compresses, encrypts, etc., `responseObject` into a proper // serverResponse in reply to `decoded`. BA.encodeServerResponse = async function(responseObject, decoded, injectFaults = 0) { let cborPayload = new Uint8Array(CBOR.encode(_sortForCbor(responseObject))); if (injectFaults & BA.injectCborFault) { cborPayload = _injectFault(cborPayload); } let gzipPayload = await _gzip(cborPayload); if (injectFaults & BA.injectGzipFault) { gzipPayload = _injectFault(gzipPayload); } let framedPayload = _toArrayBuffer(_frameServerResponse(gzipPayload)); if (injectFaults & BA.injectFrameFault) { framedPayload = _injectFault(framedPayload); } let encrypted = await _encryptServerResponse(framedPayload, decoded); if (injectFaults & BA.injectEncryptFault) { encrypted = _injectFault(encrypted); } return encrypted; }; // Returns a hash string that can be used to authorize a given response, // formatted for use in an Ad-Auction-Result HTTP header. BA.payloadHash = async function(serverResponse) { let hash = new Uint8Array(await crypto.subtle.digest('SHA-256', serverResponse)); let hashString = '' for (let i = 0; i < hash.length; ++i) { hashString += String.fromCharCode(hash[i]); } return btoa(hashString) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/g, ''); }; // Authorizes each serverResponse hash in `hashes` to be used for // B&A auction result. BA.authorizeServerResponseHashes = async function(hashes) { let authorizeURL = new URL('resources/authorize-server-response.py', window.location); authorizeURL.searchParams.append('hashes', hashes.join(',')); await fetch(authorizeURL, {adAuctionHeaders: true}); }; // Authorizes each serverResponse nonce in `nonces` to be used for // B&A auction result. BA.authorizeServerResponseNonces = async function(nonces) { let authorizeURL = new URL('resources/authorize-server-response.py', window.location); authorizeURL.searchParams.append('nonces', nonces.join(',')); await fetch(authorizeURL, {adAuctionHeaders: true}); }; BA.configureCoordinator = async function() { // This is async in hope it can eventually use testdriver to configure this. return 'https://{{hosts[][]}}'; }; // Runs responseMutator on a minimal correct server response, and expects // either success/failure based on expectWin. BA.testWithMutatedServerResponse = async function( test, expectWin, responseMutator, igMutator = undefined, ownerOverride = null) { let finalIgOwner = ownerOverride ? ownerOverride : window.location.origin; const uuid = generateUuid(test); const adA = createTrackerURL(finalIgOwner, uuid, 'track_get', 'a'); const adB = createTrackerURL(finalIgOwner, uuid, 'track_get', 'b'); const adsArray = [{renderURL: adA, adRenderId: 'a'}, {renderURL: adB, adRenderId: 'b'}]; let ig = {ads: adsArray}; if (igMutator) { igMutator(ig, uuid); } if (ownerOverride !== null) { await joinCrossOriginInterestGroup(test, uuid, ownerOverride, ig); } else { await joinInterestGroup(test, uuid, ig); } const result = await navigator.getInterestGroupAdAuctionData({ coordinatorOrigin: await BA.configureCoordinator(), seller: window.location.origin }); assert_true(result.requestId !== null); assert_true(result.request.length > 0); let decoded = await BA.decodeInterestGroupData(result.request); let serverResponseMsg = { 'biddingGroups': {}, 'adRenderURL': ig.ads[0].renderURL, 'interestGroupName': DEFAULT_INTEREST_GROUP_NAME, 'interestGroupOwner': finalIgOwner, }; serverResponseMsg.biddingGroups[finalIgOwner] = [0]; await responseMutator(serverResponseMsg, uuid); let serverResponse = await BA.encodeServerResponse(serverResponseMsg, decoded); let hashString = await BA.payloadHash(serverResponse); await BA.authorizeServerResponseHashes([hashString]); let auctionResult = await navigator.runAdAuction({ 'seller': window.location.origin, 'interestGroupBuyers': [finalIgOwner], 'requestId': result.requestId, 'serverResponse': serverResponse, 'resolveToConfig': true, }); if (expectWin) { expectSuccess(auctionResult); return auctionResult; } else { expectNoWinner(auctionResult); } }; })(BA);