From 75a685a77c2a39be56d60780e78521c5285208ab Mon Sep 17 00:00:00 2001 From: Jingyi Gao Date: Wed, 4 Oct 2023 17:02:32 +1100 Subject: [PATCH 01/14] Decouple cstg --- gulpfile.js | 5 +- karma.conf.maker.js | 2 + modules/uid2Cstg.js | 482 +++++++++++++++++++++++++++++++++ modules/uid2IdSystem.js | 10 +- modules/uid2IdSystem_shared.js | 450 ++---------------------------- package-lock.json | 18 +- 6 files changed, 530 insertions(+), 437 deletions(-) create mode 100644 modules/uid2Cstg.js diff --git a/gulpfile.js b/gulpfile.js index 09de874e389..09cb3fb0d79 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -309,8 +309,8 @@ function testTaskMaker(options = {}) { } } } - const test = testTaskMaker(); +// const test = testTaskMaker({file: 'test/spec/modules/userid_spec.js'}); function runWebdriver({file}) { process.env.TEST_SERVER_HOST = argv.host || 'localhost'; @@ -451,6 +451,9 @@ gulp.task('build-bundle-verbose', gulp.series(makeWebpackPkg({ gulp.task('test-only', test); gulp.task('test-all-features-disabled', testTaskMaker({disableFeatures: require('./features.json'), oneBrowser: 'chrome', watch: false})); gulp.task('test', gulp.series(clean, lint, gulp.series('test-all-features-disabled', 'test-only'))); +// gulp.task('test-watch', testTaskMaker({watch: true, file: ['test/spec/modules/uid2idsystem_spec.js', 'test/spec/modules/euididsystem_spec.js', 'test/spec/modules/userid_spec.js']})); +// // gulp.task('test-watch', testTaskMaker({watch: true, file: ['test/spec/modules/euididsystem_spec.js']})); +// gulp.task('test-all-features-disabled', testTaskMaker({disableFeatures: require('./features.json'), oneBrowser: 'chrome', watch: false, file: 'test/spec/modules/userid_spec.js'})); gulp.task('test-coverage', gulp.series(clean, testCoverage)); gulp.task(viewCoverage); diff --git a/karma.conf.maker.js b/karma.conf.maker.js index e05d5b08afd..7066d26c947 100644 --- a/karma.conf.maker.js +++ b/karma.conf.maker.js @@ -111,6 +111,8 @@ module.exports = function(codeCoverage, browserstack, watchMode, file, disableFe var plugins = newPluginsArray(browserstack); var files = file ? ['test/test_deps.js', file, 'test/helpers/hookSetup.js'].flatMap(f => f) : ['test/test_index.js']; + // var fileArray = Array.isArray(file) ? file : [file]; + // var files = file ? ['test/test_deps.js', ...fileArray, 'test/helpers/hookSetup.js'].flatMap(f => f) : ['test/test_index.js']; // This file opens the /debug.html tab automatically. // It has no real value unless you're running --watch, and intend to do some debugging in the browser. if (watchMode) { diff --git a/modules/uid2Cstg.js b/modules/uid2Cstg.js new file mode 100644 index 00000000000..611e8ea8eed --- /dev/null +++ b/modules/uid2Cstg.js @@ -0,0 +1,482 @@ +/* eslint-disable no-console */ +/** + * This module adds UID2 Client-side token generation support to UID2 ID System + * The {@link module:modules/uid2IdSystem} module is required. + * @module modules/uid2Cstg + * @requires module:modules/uid2IdSystem + */ +import { cyrb53Hash } from '../src/utils.js'; +import { ajax } from '../src/ajax.js'; + +// eslint-disable-next-line prebid/validate-imports +import { isValidIdentity } from './uid2IdSystem_shared.js'; + +export function getValidIdentity(opts, _logWarn) { + if (!opts || isCSTGOptionsValid(opts, _logWarn)) return + + if (opts.emailHash) { + if (!isBase64Hash(opts.emailHash)) { + _logWarn('CSTG opts.emailHash is invalid'); + return; + } + return { email_hash: opts.emailHash }; + } + + if (opts.phoneHash) { + if (!isBase64Hash(opts.phoneHash)) { + _logWarn('CSTG opts.phoneHash is invalid'); + return; + } + return { phone_hash: opts.phoneHash }; + } + + if (opts.email) { + const normalizedEmail = normalizeEmail(opts.email); + if (normalizedEmail === undefined) { + _logWarn('CSTG opts.email is invalid'); + return; + } + return { email: opts.email }; + } + + if (opts.phone) { + if (!isNormalizedPhone(opts.phone)) { + _logWarn('CSTG opts.phone is invalid'); + return; + } + return { phone: opts.phone }; + } +} + +function isCSTGOptionsValid(maybeOpts, _logWarn) { + if (typeof maybeOpts !== 'object' || maybeOpts === null) { + _logWarn('CSTG opts must be an object'); + return false; + } + + const opts = maybeOpts; + if (typeof opts.serverPublicKey !== 'string') { + _logWarn('CSTG opts.serverPublicKey must be a string'); + return false; + } + const serverPublicKeyPrefix = /^UID2-X-[A-Z]-.+/; + if (!serverPublicKeyPrefix.test(opts.serverPublicKey)) { + _logWarn( + `CSTG opts.serverPublicKey must match the regular expression ${serverPublicKeyPrefix}` + ); + return false; + } + // We don't do any further validation of the public key, as we will find out + // later if it's valid by using importKey. + + if (typeof opts.subscriptionId !== 'string') { + _logWarn('CSTG opts.subscriptionId must be a string'); + return false; + } + if (opts.subscriptionId.length === 0) { + _logWarn('CSTG opts.subscriptionId is empty'); + return false; + } + return true; +} + +function isBase64Hash(value) { + if (!(value && value.length === 44)) { + return false; + } + + try { + return btoa(atob(value)) === value; + } catch (err) { + return false; + } +} + +function isNormalizedPhone(phone) { + return /^\+[0-9]{10,15}$/.test(phone); +} + +const EMAIL_EXTENSION_SYMBOL = '+'; +const EMAIL_DOT = '.'; +const GMAIL_DOMAIN = 'gmail.com'; + +function normalizeEmail(email) { + if (!email || !email.length) return; + + const parsedEmail = email.trim().toLowerCase(); + if (parsedEmail.indexOf(' ') > 0) return; + + const emailParts = splitEmailIntoAddressAndDomain(parsedEmail); + if (!emailParts) return; + + const { address, domain } = emailParts; + + const emailIsGmail = isGmail(domain); + const parsedAddress = normalizeAddressPart( + address, + emailIsGmail, + emailIsGmail + ); + return parsedAddress ? `${parsedAddress}@${domain}` : undefined; +} + +function splitEmailIntoAddressAndDomain(email) { + const parts = email.split('@'); + if (!parts.length || parts.length !== 2 || parts.some((part) => part === '')) return; + + return { + address: parts[0], + domain: parts[1], + }; +} + +function isGmail(domain) { + return domain === GMAIL_DOMAIN; +} + +function dropExtension( + address, + extensionSymbol = EMAIL_EXTENSION_SYMBOL +) { + return address.split(extensionSymbol)[0]; +} + +function normalizeAddressPart( + address, + shouldRemoveDot, + shouldDropExtension +) { + let parsedAddress = address; + if (shouldRemoveDot) parsedAddress = parsedAddress.replaceAll(EMAIL_DOT, ''); + if (shouldDropExtension) parsedAddress = dropExtension(parsedAddress); + return parsedAddress; +} + +export async function isStoredTokenInvalid(cstgIdentity, storedTokens, _logInfo, _logWarn) { + if (storedTokens) { + const identity = Object.values(cstgIdentity)[0] + if (!isStoredTokenFromSameIdentity(storedTokens, identity)) { + _logInfo('CSTG supplied new identity - ignoring stored value.', storedTokens.originalIdentity, cstgIdentity); + // Stored token wasn't originally sourced from the provided identity - ignore the stored value. A new user has logged in? + return true; + } + } + return false; +} + +function isStoredTokenFromSameIdentity(storedTokens, identity) { + if (!storedTokens.originalIdentity) return false; + return ( + cyrb53Hash(identity, storedTokens.originalIdentity.salt) === + storedTokens.originalIdentity.identity + ); +} + +export async function generateTokenAndStore( + baseUrl, + cstgOpts, + cstgIdentity, + storageManager, + _logInfo, + _logWarn +) { + _logInfo('UID2 cstg opts provided: ', JSON.stringify(cstgOpts)); + const client = new UID2CstgApiClient( + { baseUrl, cstg: cstgOpts }, + _logInfo, + _logWarn + ); + const response = await client.generateToken(cstgIdentity); + _logInfo('CSTG endpoint responded with:', response); + const tokens = { + originalIdentity: encodeOriginalIdentity(cstgIdentity), + latestToken: response.identity, + }; + storageManager.storeValue(tokens); + return tokens; +} + +function encodeOriginalIdentity(identity) { + const identityValue = Object.values(identity)[0]; + const salt = Math.floor(Math.random() * Math.pow(2, 32)); + return { + identity: cyrb53Hash(identityValue, salt), + salt, + }; +} + +const SERVER_PUBLIC_KEY_PREFIX_LENGTH = 9; + +function stripPublicKeyPrefix(serverPublicKey) { + return serverPublicKey.substring(SERVER_PUBLIC_KEY_PREFIX_LENGTH); +} + +export class UID2CstgApiClient { + constructor(opts, logInfo, logWarn) { + this._baseUrl = opts.baseUrl; + this._serverPublicKey = opts.cstg.serverPublicKey; + this._subscriptionId = opts.cstg.subscriptionId; + this._logInfo = logInfo; + this._logWarn = logWarn; + } + + isCstgApiSuccessResponse(response) { + return ( + this.hasStatusResponse(response) && + response.status === 'success' && + isValidIdentity(response.body) + ); + } + + isCstgApiClientErrorResponse(response) { + return ( + this.hasStatusResponse(response) && + response.status === 'client_error' && + typeof response.message === 'string' + ); + } + + isCstgApiForbiddenResponse(response) { + return ( + this.hasStatusResponse(response) && + response.status === 'invalid_http_origin' && + typeof response.message === 'string' + ); + } + + async generateCstgRequest(cstgIdentity) { + if ('email_hash' in cstgIdentity || 'phone_hash' in cstgIdentity) { + return cstgIdentity; + } + if ('email' in cstgIdentity) { + const emailHash = await UID2CstgCrypto.hash(cstgIdentity.email); + return { email_hash: emailHash }; + } + if ('phone' in cstgIdentity) { + const phoneHash = await UID2CstgCrypto.hash(cstgIdentity.phone); + return { phone_hash: phoneHash }; + } + } + + async generateToken(cstgIdentity) { + const request = await this.generateCstgRequest(cstgIdentity); + this._logInfo('Building CSTG request for', request); + const box = await UID2CstgBox.build( + stripPublicKeyPrefix(this._serverPublicKey) + ); + const encoder = new TextEncoder(); + const now = Date.now(); + const { iv, ciphertext } = await box.encrypt( + encoder.encode(JSON.stringify(request)), + encoder.encode(JSON.stringify([now])) + ); + + const exportedPublicKey = await UID2CstgCrypto.exportPublicKey( + box.clientPublicKey + ); + const requestBody = { + payload: UID2CstgCrypto.bytesToBase64(new Uint8Array(ciphertext)), + iv: UID2CstgCrypto.bytesToBase64(new Uint8Array(iv)), + public_key: UID2CstgCrypto.bytesToBase64( + new Uint8Array(exportedPublicKey) + ), + timestamp: now, + subscription_id: this._subscriptionId, + }; + return this.callCstgApi(requestBody, box); + } + + async callCstgApi(requestBody, box) { + const url = this._baseUrl + '/v2/token/client-generate'; + let resolvePromise; + let rejectPromise; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + + this._logInfo('Sending CSTG request', requestBody); + ajax( + url, + { + success: async (responseText, xhr) => { + try { + const encodedResp = UID2CstgCrypto.base64ToBytes(responseText); + const decrypted = await box.decrypt( + encodedResp.slice(0, 12), + encodedResp.slice(12) + ); + const decryptedResponse = new TextDecoder().decode(decrypted); + const response = JSON.parse(decryptedResponse); + if (this.isCstgApiSuccessResponse(response)) { + resolvePromise({ + status: 'success', + identity: response.body, + }); + } else { + // A 200 should always be a success response. + // Something has gone wrong. + rejectPromise( + `API error: Response body was invalid for HTTP status 200: ${decryptedResponse}` + ); + } + } catch (err) { + rejectPromise(err); + } + }, + error: (error, xhr) => { + try { + if (xhr.status === 400) { + const response = JSON.parse(xhr.responseText); + if (this.isCstgApiClientErrorResponse(response)) { + rejectPromise(`Client error: ${response.message}`); + } else { + // A 400 should always be a client error. + // Something has gone wrong. + rejectPromise( + `API error: Response body was invalid for HTTP status 400: ${xhr.responseText}` + ); + } + } else if (xhr.status === 403) { + const response = JSON.parse(xhr.responseText); + if (this.isCstgApiForbiddenResponse(xhr)) { + rejectPromise(`Forbidden: ${response.message}`); + } else { + // A 403 should always be a forbidden response. + // Something has gone wrong. + rejectPromise( + `API error: Response body was invalid for HTTP status 403: ${xhr.responseText}` + ); + } + } else { + rejectPromise( + `API error: Unexpected HTTP status ${xhr.status}: ${error}` + ); + } + } catch (_e) { + rejectPromise(error); + } + }, + }, + JSON.stringify(requestBody), + { method: 'POST' } + ); + return promise; + } +} + +export class UID2CstgBox { + static _namedCurve = 'P-256'; + constructor(clientPublicKey, sharedKey) { + this._clientPublicKey = clientPublicKey; + this._sharedKey = sharedKey; + } + + static async build(serverPublicKey) { + const clientKeyPair = await UID2CstgCrypto.generateKeyPair( + UID2CstgBox._namedCurve + ); + const importedServerPublicKey = await UID2CstgCrypto.importPublicKey( + serverPublicKey, + this._namedCurve + ); + const sharedKey = await UID2CstgCrypto.deriveKey( + importedServerPublicKey, + clientKeyPair.privateKey + ); + return new UID2CstgBox(clientKeyPair.publicKey, sharedKey); + } + + async encrypt(plaintext, additionalData) { + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await window.crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv, + additionalData, + }, + this._sharedKey, + plaintext + ); + return { iv, ciphertext }; + } + + async decrypt(iv, ciphertext) { + return window.crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv, + }, + this._sharedKey, + ciphertext + ); + } + + get clientPublicKey() { + return this._clientPublicKey; + } +} + +export class UID2CstgCrypto { + static base64ToBytes(base64) { + const binString = atob(base64); + return Uint8Array.from(binString, (m) => m.codePointAt(0)); + } + + static bytesToBase64(bytes) { + const binString = Array.from(bytes, (x) => String.fromCodePoint(x)).join( + '' + ); + return btoa(binString); + } + + static async generateKeyPair(namedCurve) { + const params = { + name: 'ECDH', + namedCurve: namedCurve, + }; + return window.crypto.subtle.generateKey(params, false, ['deriveKey']); + } + + static async importPublicKey(publicKey, namedCurve) { + const params = { + name: 'ECDH', + namedCurve: namedCurve, + }; + return window.crypto.subtle.importKey( + 'spki', + this.base64ToBytes(publicKey), + params, + false, + [] + ); + } + + static exportPublicKey(publicKey) { + return window.crypto.subtle.exportKey('spki', publicKey); + } + + static async deriveKey(serverPublicKey, clientPrivateKey) { + return window.crypto.subtle.deriveKey( + { + name: 'ECDH', + public: serverPublicKey, + }, + clientPrivateKey, + { + name: 'AES-GCM', + length: 256, + }, + false, + ['encrypt', 'decrypt'] + ); + } + + static async hash(value) { + const hash = await window.crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(value) + ); + return this.bytesToBase64(new Uint8Array(hash)); + } +} diff --git a/modules/uid2IdSystem.js b/modules/uid2IdSystem.js index ae63affdc06..040cc65e887 100644 --- a/modules/uid2IdSystem.js +++ b/modules/uid2IdSystem.js @@ -50,6 +50,8 @@ const _logWarn = createLogger(logWarn, LOG_PRE_FIX); export const storage = getStorageManager({moduleType: MODULE_TYPE_UID, moduleName: MODULE_NAME}); +let uid2Cstg; + /** @type {Submodule} */ export const uid2IdSubmodule = { /** @@ -99,7 +101,7 @@ export const uid2IdSubmodule = { internalStorage: ADVERTISING_COOKIE } _logInfo(`UID2 configuration loaded and mapped.`, mappedConfig); - const result = Uid2GetId(mappedConfig, storage, _logInfo, _logWarn); + const result = Uid2GetId(mappedConfig, storage, uid2Cstg, _logInfo, _logWarn); _logInfo(`UID2 getId returned`, result); return result; }, @@ -133,3 +135,9 @@ function decodeImpl(value) { // Register submodule for userId submodule('userId', uid2IdSubmodule); + +try { + uid2Cstg = await import('./uid2Cstg.js'); +} catch (error) { + console.log('Module not found or failed to load', error); +} diff --git a/modules/uid2IdSystem_shared.js b/modules/uid2IdSystem_shared.js index e9f098cbfee..fc1d14719b2 100644 --- a/modules/uid2IdSystem_shared.js +++ b/modules/uid2IdSystem_shared.js @@ -1,99 +1,12 @@ /* eslint-disable no-console */ import { ajax } from '../src/ajax.js'; -import { cyrb53Hash } from '../src/utils.js'; export const Uid2CodeVersion = '1.1'; -function isValidIdentity(identity) { +export function isValidIdentity(identity) { return !!(typeof identity === 'object' && identity !== null && identity.advertising_token && identity.identity_expires && identity.refresh_from && identity.refresh_token && identity.refresh_expires); } -function isNormalizedPhone(phone) { - return /^\+[0-9]{10,15}$/.test(phone); -} - -function isBase64Hash(value) { - if (!(value && value.length === 44)) { - return false; - } - - try { - return btoa(atob(value)) === value; - } catch (err) { - return false; - } -} - -function isCSTGOptions( - maybeOpts, - _logWarn -) { - if (typeof maybeOpts !== 'object' || maybeOpts === null) { - _logWarn('CSTG opts must be an object'); - return false; - } - - const opts = maybeOpts; - if (typeof opts.serverPublicKey !== 'string') { - _logWarn('CSTG opts.serverPublicKey must be a string'); - return false; - } - const serverPublicKeyPrefix = /^UID2-X-[A-Z]-.+/; - if (!serverPublicKeyPrefix.test(opts.serverPublicKey)) { - _logWarn( - `CSTG opts.serverPublicKey must match the regular expression ${serverPublicKeyPrefix}` - ); - return false; - } - // We don't do any further validation of the public key, as we will find out - // later if it's valid by using importKey. - - if (typeof opts.subscriptionId !== 'string') { - _logWarn('CSTG opts.subscriptionId must be a string'); - return false; - } - if (opts.subscriptionId.length === 0) { - _logWarn('CSTG opts.subscriptionId is empty'); - return false; - } - return true -} - -function getValidIdentity(opts, _logWarn) { - if (opts.emailHash) { - if (!isBase64Hash(opts.emailHash)) { - _logWarn('CSTG opts.emailHash is invalid'); - return; - } - return { email_hash: opts.emailHash } - } - - if (opts.phoneHash) { - if (!isBase64Hash(opts.phoneHash)) { - _logWarn('CSTG opts.phoneHash is invalid'); - return; - } - return { phone_hash: opts.phoneHash } - } - - if (opts.email) { - const normalizedEmail = normalizeEmail(opts.email); - if (normalizedEmail === undefined) { - _logWarn('CSTG opts.email is invalid'); - return; - } - return { email: opts.email } - } - - if (opts.phone) { - if (!isNormalizedPhone(opts.phone)) { - _logWarn('CSTG opts.phone is invalid'); - return; - } - return { phone: opts.phone } - } -} - // This is extracted from an in-progress API client. Once it's available via NPM, this class should be replaced with the NPM package. export class Uid2ApiClient { constructor(opts, clientId, logInfo, logWarn) { @@ -121,24 +34,6 @@ export class Uid2ApiClient { response.status === 'optout' || response.status === 'expired_token' || (response.status === 'success' && response.body && isValidIdentity(response.body)) ); } - isCstgApiSuccessResponse(response) { - return this.hasStatusResponse(response) && ( - response.status === 'success' && - isValidIdentity(response.body) - ); - } - isCstgApiClientErrorResponse(response) { - return this.hasStatusResponse(response) && ( - response.status === 'client_error' && - typeof response.message === 'string' - ); - } - isCstgApiForbiddenResponse(response) { - return this.hasStatusResponse(response) && ( - response.status === 'invalid_http_origin' && - typeof response.message === 'string' - ); - } ResponseToRefreshResult(response) { if (this.isValidRefreshResponse(response)) { if (response.status === 'success') { return { status: response.status, identity: response.body }; } @@ -201,98 +96,6 @@ export class Uid2ApiClient { } }); return promise; } - - async callCstgApi(request) { - this._logInfo('Building CSTG request for', request); - const box = await UID2CstgBox.build(stripPublicKeyPrefix(this._serverPublicKey)) - const encoder = new TextEncoder(); - const now = Date.now(); - const { iv, ciphertext } = await box.encrypt( - encoder.encode(JSON.stringify(request)), - encoder.encode(JSON.stringify([now])) - ); - - const exportedPublicKey = await UID2CstgCrypto.exportPublicKey(box.clientPublicKey); - const requestBody = { - payload: UID2CstgCrypto.bytesToBase64(new Uint8Array(ciphertext)), - iv: UID2CstgCrypto.bytesToBase64(new Uint8Array(iv)), - public_key: UID2CstgCrypto.bytesToBase64(new Uint8Array(exportedPublicKey)), - timestamp: now, - subscription_id: this._subscriptionId, - }; - return this._callCstgApi(requestBody, box) - } - - _callCstgApi(requestBody, box) { - const url = this._baseUrl + '/v2/token/client-generate'; - let resolvePromise; - let rejectPromise; - const promise = new Promise((resolve, reject) => { - resolvePromise = resolve; - rejectPromise = reject; - }); - - this._logInfo('Sending CSTG request', requestBody); - ajax(url, { - success: async(responseText, xhr) => { - try { - const encodedResp = UID2CstgCrypto.base64ToBytes(responseText); - const decrypted = await box.decrypt( - encodedResp.slice(0, 12), - encodedResp.slice(12) - ) - const decryptedResponse = new TextDecoder().decode(decrypted); - const response = JSON.parse(decryptedResponse); - if (this.isCstgApiSuccessResponse(response)) { - resolvePromise({ - status: 'success', - identity: response.body, - }); - } else { - // A 200 should always be a success response. - // Something has gone wrong. - rejectPromise( - `API error: Response body was invalid for HTTP status 200: ${decryptedResponse}` - ); - } - } catch (err) { - rejectPromise(err); - } - }, - error: (error, xhr) => { - try { - if (xhr.status === 400) { - const response = JSON.parse(xhr.responseText); - if (this.isCstgApiClientErrorResponse(response)) { - rejectPromise(`Client error: ${response.message}`); - } else { - // A 400 should always be a client error. - // Something has gone wrong. - rejectPromise( - `API error: Response body was invalid for HTTP status 400: ${xhr.responseText}` - ); - } - } else if (xhr.status === 403) { - const response = JSON.parse(xhr.responseText); - if (this.isCstgApiForbiddenResponse(xhr)) { - rejectPromise(`Forbidden: ${response.message}`); - } else { - // A 403 should always be a forbidden response. - // Something has gone wrong. - rejectPromise( - `API error: Response body was invalid for HTTP status 403: ${xhr.responseText}` - ); - } - } else { - rejectPromise(`API error: Unexpected HTTP status ${xhr.status}: ${error}`); - } - } catch (_e) { - rejectPromise(error) - } - } - }, JSON.stringify(requestBody), { method: 'POST' }); - return promise; - } } export class Uid2StorageManager { constructor(storage, preferLocalStorage, storageName, logInfo) { @@ -363,97 +166,6 @@ export class Uid2StorageManager { } } -class UID2CstgBox { - static _namedCurve = 'P-256' - constructor(clientPublicKey, sharedKey) { - this._clientPublicKey = clientPublicKey; - this._sharedKey = sharedKey; - } - - static async build(serverPublicKey) { - const clientKeyPair = await UID2CstgCrypto.generateKeyPair(UID2CstgBox._namedCurve); - const importedServerPublicKey = await UID2CstgCrypto.importPublicKey( - serverPublicKey, - this._namedCurve - ); - const sharedKey = await UID2CstgCrypto.deriveKey( - importedServerPublicKey, - clientKeyPair.privateKey - ); - return new UID2CstgBox(clientKeyPair.publicKey, sharedKey); - } - - async encrypt(plaintext, additionalData) { - const iv = window.crypto.getRandomValues(new Uint8Array(12)); - const ciphertext = await window.crypto.subtle.encrypt({ - name: 'AES-GCM', - iv, - additionalData, - }, this._sharedKey, plaintext) - return { iv, ciphertext } - } - - async decrypt(iv, ciphertext) { - return window.crypto.subtle.decrypt({ - name: 'AES-GCM', - iv - }, this._sharedKey, ciphertext); - } - - get clientPublicKey() { - return this._clientPublicKey; - } -} - -class UID2CstgCrypto { - static base64ToBytes(base64) { - const binString = atob(base64); - return Uint8Array.from(binString, (m) => m.codePointAt(0)); - } - - static bytesToBase64(bytes) { - const binString = Array.from(bytes, (x) => String.fromCodePoint(x)).join(''); - return btoa(binString); - } - - static async generateKeyPair(namedCurve) { - const params = { - name: 'ECDH', - namedCurve: namedCurve, - }; - return window.crypto.subtle.generateKey(params, false, ['deriveKey']); - } - - static async importPublicKey(publicKey, namedCurve) { - const params = { - name: 'ECDH', - namedCurve: namedCurve, - }; - return window.crypto.subtle.importKey('spki', this.base64ToBytes(publicKey), params, false, []); - } - - static exportPublicKey(publicKey) { - return window.crypto.subtle.exportKey('spki', publicKey); - } - - static async deriveKey(serverPublicKey, clientPrivateKey) { - return window.crypto.subtle.deriveKey({ - name: 'ECDH', - public: serverPublicKey, - }, clientPrivateKey, { - name: 'AES-GCM', - length: 256, - }, false, ['encrypt', 'decrypt']); - } - - static async hash(value) { - const hash = await window.crypto.subtle.digest( - 'SHA-256', - new TextEncoder().encode(value) - ); - return this.bytesToBase64(new Uint8Array(hash)); - } -} function refreshTokenAndStore(baseUrl, token, clientId, storageManager, _logInfo, _logWarn) { _logInfo('UID2 base url provided: ', baseUrl); @@ -471,126 +183,14 @@ function refreshTokenAndStore(baseUrl, token, clientId, storageManager, _logInfo }); } -function normalizeEmail(email) { - if (email == undefined || email.length <= 0) { - return undefined; - } - - const STARTING_STATE = 'Starting'; - const SUBDOMAIN_STATE = 'SubDomain' - - let preSb = ''; - let preSbSpecialized = ''; - let sb = ''; - let wsBuffer = ''; - - let parsingState = STARTING_STATE; - let inExtension = false; - - for (const cGiven of email) { - let c = cGiven; - if (cGiven >= 'A' && cGiven < 'Z') { - c = String.fromCharCode(c.charCodeAt(0) + 32); - } - - switch (parsingState) { - case STARTING_STATE: - if (c == ' ') { - break; - } - if (c == '@') { - parsingState = SUBDOMAIN_STATE; - } else if (c == '.') { - preSb += c; - } else if (c == '+') { - preSb += c; - inExtension = true; - } else { - preSb += c; - if (!inExtension) { - preSbSpecialized += c; - } - } - break; - case SUBDOMAIN_STATE: - if (c == '@') { - return undefined; - } else if (c == ' ') { - wsBuffer += c; - break; - } else if (wsBuffer.length > 0) { - sb += wsBuffer; - wsBuffer = ''; - } - sb += c; - } - } - - if (sb.length == 0) { - return undefined; - } - - let domainPart = sb; - const GMAILDOMAIN = 'gmail.com'; - - let addressPartToUse = domainPart == GMAILDOMAIN ? preSbSpecialized : preSb; - if (addressPartToUse.length == 0) { - return undefined; - } - - return addressPartToUse + '@' + domainPart; -} - -async function generateCstgRequest(cstgIdentity) { - if ('email_hash' in cstgIdentity || 'phone_hash' in cstgIdentity) { - return cstgIdentity; - } - if ('email' in cstgIdentity) { - const emailHash = await UID2CstgCrypto.hash(cstgIdentity.email) - return { email_hash: emailHash }; - } - if ('phone' in cstgIdentity) { - const phoneHash = await UID2CstgCrypto.hash(cstgIdentity.phone) - return {phone_hash: phoneHash} - } -} - -async function generateTokenAndStore(baseUrl, cstgOpts, cstgIdentity, clientId, storageManager, _logInfo, _logWarn) { - _logInfo('UID2 cstg opts provided: ', JSON.stringify(cstgOpts)); - const client = new Uid2ApiClient({baseUrl, cstg: cstgOpts}, clientId, _logInfo, _logWarn); - const request = await generateCstgRequest(cstgIdentity); - const response = await client.callCstgApi(request) - _logInfo('CSTG endpoint responded with:', response); - const tokens = { - originalIdentity: encodeOriginalIdentity(cstgIdentity), - latestToken: response.identity, - }; - storageManager.storeValue(tokens); - return tokens; -} - -function encodeOriginalIdentity(identity) { - const identityValue = Object.values(identity)[0]; - const salt = Math.floor(Math.random() * Math.pow(2, 32)); - return { - identity: cyrb53Hash(identityValue, salt), - salt - } -} - -function storedTokenFromSameIdentity(storedTokens, identity) { - if (!storedTokens.originalIdentity) return false; - return cyrb53Hash(identity, storedTokens.originalIdentity.salt) === storedTokens.originalIdentity.identity -} - -export function Uid2GetId(config, prebidStorageManager, _logInfo, _logWarn) { +export function Uid2GetId(config, prebidStorageManager, uid2Cstg, _logInfo, _logWarn) { let suppliedToken = null; - const cstgIdentity = config.cstg && isCSTGOptions(config.cstg, _logWarn) && getValidIdentity(config.cstg); + const isCstgEnabled = config.cstg && uid2Cstg; const preferLocalStorage = (config.storage !== 'cookie'); const storageManager = new Uid2StorageManager(prebidStorageManager, preferLocalStorage, config.internalStorage, _logInfo); _logInfo(`Module is using ${preferLocalStorage ? 'local storage' : 'cookies'} for internal storage.`); - if (cstgIdentity) { + if (isCstgEnabled) { _logInfo(`Module is using client-side token generation.`); // Ignores config.paramToken and config.serverCookieName if any is provided suppliedToken = null; @@ -607,7 +207,7 @@ export function Uid2GetId(config, prebidStorageManager, _logInfo, _logWarn) { if (storedTokens && typeof storedTokens === 'string') { // Stored value is a plain token - if no token is supplied, just use the stored value. - if (!suppliedToken && !cstgIdentity) { + if (!suppliedToken && !isCstgEnabled) { _logInfo('Returning legacy cookie value.'); return { id: storedTokens }; } @@ -624,26 +224,26 @@ export function Uid2GetId(config, prebidStorageManager, _logInfo, _logWarn) { } } - if (cstgIdentity) { - if (storedTokens) { - const identity = Object.values(cstgIdentity)[0] - if (!storedTokenFromSameIdentity(storedTokens, identity)) { - _logInfo('CSTG supplied new identity - ignoring stored value.', storedTokens.originalIdentity, cstgIdentity); - // Stored token wasn't originally sourced from the provided identity - ignore the stored value. A new user has logged in? + if (isCstgEnabled) { + const cstgIdentity = uid2Cstg.getValidIdentity(config.cstg, _logWarn); + if (cstgIdentity) { + if (storedTokens && !uid2Cstg.isStoredTokenInvalid(cstgIdentity, storedTokens, _logInfo, _logWarn)) { storedTokens = null; } - } - if (!storedTokens || Date.now() > storedTokens.latestToken.refresh_expires) { - const promise = generateTokenAndStore(config.apiBaseUrl, config.cstg, cstgIdentity, config.clientId, storageManager, _logInfo, _logWarn); - _logInfo('Generate token using CSTG'); - return { callback: (cb) => { - promise.then((result) => { - _logInfo('Token generation responded, passing the new token on.', result); - cb(result); - }); - } }; + + if (!storedTokens || Date.now() > storedTokens.latestToken.refresh_expires) { + const promise = uid2Cstg.generateTokenAndStore(config.apiBaseUrl, config.cstg, cstgIdentity, config.clientId, storageManager, _logInfo, _logWarn); + _logInfo('Generate token using CSTG'); + return { callback: (cb) => { + promise.then((result) => { + _logInfo('Token generation responded, passing the new token on.', result); + cb(result); + }); + } }; + } } } + const useSuppliedToken = !(storedTokens?.latestToken) || (suppliedToken && suppliedToken.identity_expires > storedTokens.latestToken.identity_expires); const newestAvailableToken = useSuppliedToken ? suppliedToken : storedTokens.latestToken; _logInfo('UID2 module selected latest token', useSuppliedToken, newestAvailableToken); @@ -670,13 +270,7 @@ export function Uid2GetId(config, prebidStorageManager, _logInfo, _logWarn) { originalToken: suppliedToken ?? storedTokens?.originalToken, latestToken: newestAvailableToken, }; - if (cstgIdentity) tokens.originalIdentity = storedTokens?.originalIdentity + if (isCstgEnabled) tokens.originalIdentity = storedTokens?.originalIdentity storageManager.storeValue(tokens); return { id: tokens }; } - -const SERVER_PUBLIC_KEY_PREFIX_LENGTH = 9; - -function stripPublicKeyPrefix(serverPublicKey) { - return serverPublicKey.substring(SERVER_PUBLIC_KEY_PREFIX_LENGTH); -} diff --git a/package-lock.json b/package-lock.json index 82d5a79adce..d1ce623a3e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "prebid.js", - "version": "8.8.0-pre", + "version": "8.13.0-pre", "license": "Apache-2.0", "dependencies": { "@babel/core": "^7.16.7", @@ -6997,9 +6997,9 @@ "dev": true }, "node_modules/caniuse-lite": { - "version": "1.0.30001429", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001429.tgz", - "integrity": "sha512-511ThLu1hF+5RRRt0zYCf2U2yRr9GPF6m5y90SBCWsvSoYoW7yAGlv/elyPaNfvGCkp6kj/KFZWU0BMA69Prsg==", + "version": "1.0.30001533", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001533.tgz", + "integrity": "sha512-9aY/b05NKU4Yl2sbcJhn4A7MsGwR1EPfW/nrqsnqVA0Oq50wpmPaGI+R1Z0UKlUl96oxUkGEOILWtOHck0eCWw==", "funding": [ { "type": "opencollective", @@ -7008,6 +7008,10 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ] }, @@ -30653,9 +30657,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001429", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001429.tgz", - "integrity": "sha512-511ThLu1hF+5RRRt0zYCf2U2yRr9GPF6m5y90SBCWsvSoYoW7yAGlv/elyPaNfvGCkp6kj/KFZWU0BMA69Prsg==" + "version": "1.0.30001533", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001533.tgz", + "integrity": "sha512-9aY/b05NKU4Yl2sbcJhn4A7MsGwR1EPfW/nrqsnqVA0Oq50wpmPaGI+R1Z0UKlUl96oxUkGEOILWtOHck0eCWw==" }, "caseless": { "version": "0.12.0", From 21905458252d26a07982ee0f0e76c33fe4ab2a24 Mon Sep 17 00:00:00 2001 From: Jingyi Gao Date: Thu, 5 Oct 2023 12:29:44 +1100 Subject: [PATCH 02/14] Move uid2Cstg out --- gulpfile.js | 8 +- integrationExamples/gpt/userId_example.html | 473 +++++----- karma.conf.maker.js | 9 +- modules/euidIdSystem.js | 2 +- modules/uid2Cstg.js | 192 +++-- modules/uid2IdSystem.js | 19 +- modules/uid2IdSystem_shared.js | 11 +- package-lock.json | 905 ++++---------------- package.json | 1 + test/spec/modules/uid2IdSystem_spec.js | 12 +- 10 files changed, 535 insertions(+), 1097 deletions(-) diff --git a/gulpfile.js b/gulpfile.js index 09cb3fb0d79..0ac4c0e76cb 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -309,8 +309,9 @@ function testTaskMaker(options = {}) { } } } -const test = testTaskMaker(); -// const test = testTaskMaker({file: 'test/spec/modules/userid_spec.js'}); +// const test = testTaskMaker(); +const test = testTaskMaker({file: 'test/spec/modules/userid_spec.js'}); +// const test = testTaskMaker({file: 'test/spec/modules/euididsystem_spec.js'}); function runWebdriver({file}) { process.env.TEST_SERVER_HOST = argv.host || 'localhost'; @@ -450,6 +451,9 @@ gulp.task('build-bundle-verbose', gulp.series(makeWebpackPkg({ // public tasks (dependencies are needed for each task since they can be ran on their own) gulp.task('test-only', test); gulp.task('test-all-features-disabled', testTaskMaker({disableFeatures: require('./features.json'), oneBrowser: 'chrome', watch: false})); +// gulp.task('test-watch', testTaskMaker({watch: true, file: ['test/spec/modules/uid2idsystem_spec.js']})); +gulp.task('test-watch', testTaskMaker({watch: true, file: ['test/spec/modules/uid2idsystem_spec.js', 'test/spec/modules/euididsystem_spec.js']})); +// gulp.task('test-all-features-disabled', testTaskMaker({disableFeatures: require('./features.json'), oneBrowser: 'chrome', watch: false, file: 'test/spec/modules/userid_spec.js'})); gulp.task('test', gulp.series(clean, lint, gulp.series('test-all-features-disabled', 'test-only'))); // gulp.task('test-watch', testTaskMaker({watch: true, file: ['test/spec/modules/uid2idsystem_spec.js', 'test/spec/modules/euididsystem_spec.js', 'test/spec/modules/userid_spec.js']})); // // gulp.task('test-watch', testTaskMaker({watch: true, file: ['test/spec/modules/euididsystem_spec.js']})); diff --git a/integrationExamples/gpt/userId_example.html b/integrationExamples/gpt/userId_example.html index 836c03c4657..2ceaae5a647 100644 --- a/integrationExamples/gpt/userId_example.html +++ b/integrationExamples/gpt/userId_example.html @@ -31,7 +31,7 @@ var pbjs = pbjs || {}; pbjs.que = pbjs.que || []; - + - - + +