Conversation
src/experiments.js
Outdated
|
|
||
| function getExperimentsKey_() { | ||
| let key; | ||
| const url = '' //TODO(kmh287): fill this in |
There was a problem hiding this comment.
Not sure how to set up a key to download. Is the extra request worth the time it would take to perform another request?
There was a problem hiding this comment.
I don't think we want to do that since it'll prevent offline functionality. We can patch prod if we really need to kill a feature, though I don't think that will be needed in practice.
src/experiments.js
Outdated
| method: 'GET', | ||
| ampCors: false, | ||
| credentials: 'omit', | ||
| }).then(jwkObj => { |
There was a problem hiding this comment.
How can this be tested? I'm not sure how to generate a JSON web key nor how to use it to sign some fake data.
src/experiments.js
Outdated
| * @param {string} experimentId | ||
| * @return {!Promise<boolean>} | ||
| */ | ||
| export function isExperimentOnForOriginTrial(win, experimentId) { |
There was a problem hiding this comment.
Should this function just run once when the module is loaded?
There was a problem hiding this comment.
Should be up to the caller. May need to be run multiple times in the case where there are multiple AMP documents (e.g. shadow AMPs) but we should cache the result.
src/experiments.js
Outdated
| throw new Error('Config does not match current origin');; | ||
| } | ||
| const experiments = config['experiments']; | ||
| const experiment = experiments[experimentId]; |
There was a problem hiding this comment.
When we generate these tokens, we're going to need to either keep track of all experiments for each origin on our end, or request that all experiments be requested at once. Alternatively we can allow multiple meta tags with tokens, but that sounds like it could get slow very quickly.
There was a problem hiding this comment.
Ack. Publishers that want to be whitelisted for additional experiments will receive a new token that covers old and new experiments.
dreamofabear
left a comment
There was a problem hiding this comment.
Good start! I'll kick off an email thread with the A4A team who owns this code to ask for guidance on usage of this API.
src/experiments.js
Outdated
|
|
||
| function getExperimentsKey_() { | ||
| let key; | ||
| const url = '' //TODO(kmh287): fill this in |
There was a problem hiding this comment.
I don't think we want to do that since it'll prevent offline functionality. We can patch prod if we really need to kill a feature, though I don't think that will be needed in practice.
src/experiments.js
Outdated
| method: 'GET', | ||
| ampCors: false, | ||
| credentials: 'omit', | ||
| }).then(jwkObj => { |
src/experiments.js
Outdated
| * @param {string} experimentId | ||
| * @return {!Promise<boolean>} | ||
| */ | ||
| export function isExperimentOnForOriginTrial(win, experimentId) { |
There was a problem hiding this comment.
Should be up to the caller. May need to be run multiple times in the case where there are multiple AMP documents (e.g. shadow AMPs) but we should cache the result.
src/experiments.js
Outdated
| throw new Error('Config does not match current origin');; | ||
| } | ||
| const experiments = config['experiments']; | ||
| const experiment = experiments[experimentId]; |
There was a problem hiding this comment.
Ack. Publishers that want to be whitelisted for additional experiments will receive a new token that covers old and new experiments.
src/experiments.js
Outdated
| return false; | ||
| } | ||
| // TODO(kmh287): Transient experiment? | ||
| toggleExperiment(win, experimentId, /* opt_on */ true); |
There was a problem hiding this comment.
We shouldn't do this since origin trials have expiry dates. We want the feature to be disabled when the expiry date passes (unless the user has explicitly opted-in).
There was a problem hiding this comment.
Doesn't the check on the previous few lines handle that?
There was a problem hiding this comment.
Not really since the publisher could just remove the token.
There was a problem hiding this comment.
Oh, because the experiment toggle is saved in a cookie by default. Then why not just toggle it as a transient experiment?
|
So there's good news and bad news. 👍 The good news is that everyone that needs to sign a CLA (the pull request submitter and all commit authors) have done so. Everything is all good there. 😕 The bad news is that it appears that one or more commits were authored by someone other than the pull request submitter. We need to confirm that they're okay with their commits being contributed to this project. Please have them confirm that here in the pull request. Note to project maintainer: This is a terminal state, meaning the |
kmh287
left a comment
There was a problem hiding this comment.
Will, I've updated the PR with some tests on tokens I generated. I've got a script I threw together that generates the tokens, but there were a few complications
- The crypto service expects a modified signature format.
- The crypto service expects the signature to have been generated by signing modified data
I had to account for both when generating the tokens.
|
CLAs look good, thanks! |
dreamofabear
left a comment
There was a problem hiding this comment.
Nice! Is this still [WIP]? If not we can remove and ask for review from one of the A4A guys.
src/experiments.js
Outdated
| */ | ||
| const bytes = stringToBytes(atob(token)); | ||
| const version = bytes[0]; | ||
| if (version === 0) { |
There was a problem hiding this comment.
Minor: Early out here to reduce brace nesting below.
There was a problem hiding this comment.
done, though we'll need to revert if another version is added
src/experiments.js
Outdated
| /** | ||
| * Version 0: | ||
| * length = 4 bytes representing number of bytes in config | ||
| * config = string containing the experiment ID, origin URL, etc. |
There was a problem hiding this comment.
Readability suggestion: Use separate vars for each magic number, e.g.
let current = 1;
const bytesForConfigSize = 4;
// Parse config size.
const bytesForConfig = bytesToInt(bytes.subarray(current, bytesForConfigSize));
current += bytesForConfigSize;
src/experiments.js
Outdated
|
|
||
| /** | ||
| * Whether the specified experiment is on or off for origin trials. You should | ||
| * check if the experiment is already enabled before calling this function. |
There was a problem hiding this comment.
Describe when/how the promise is rejected.
There was a problem hiding this comment.
done, let me know if you think it's too much detail
src/experiments.js
Outdated
| throw new Error('Failed to verify config signature'); | ||
| } | ||
| const configStr = utf8DecodeSync(configBytes); | ||
| const config = JSON.parse(configStr); |
There was a problem hiding this comment.
This can throw errors on invalid JSON. Is the default error message good enough? If not, we have tryParseJson.
There was a problem hiding this comment.
This should only fail if we generated a bad token, or if the token has been modified. Do you think we should catch the error, add more context, and then reject?
src/experiments.js
Outdated
| const url = ampdocServiceFor(win).getAmpDoc().getUrl(); | ||
| const sourceOrigin = getSourceOrigin(url); | ||
| if (approvedOrigin !== sourceOrigin) { | ||
| throw new Error('Config does not match current origin');; |
| * Assumes bytes are big endian. | ||
| * @param {!Uint8Array} bytes | ||
| * @return {number} | ||
| */ |
There was a problem hiding this comment.
Please add a unit test for this method.
test/functional/test-experiments.js
Outdated
| 'AAAAAJF7Im9yaWdpbiI6Imh0dHBzOi8vd3d3Lmdvb2dsZS5jb20iLCJleHBlcmltZW50' + | ||
| 'cyI6eyJhbXAtZXhwaXJlcy1sYXRlciI6eyJleHBpcmF0aW9uIjo5NTYxNzYwMjAwMDAw' + | ||
| 'MH0sImFtcC1leHBpcmVkLSI6eyJleHBpcmF0aW9uIjoxMjMyNDI3NjAwMDAwfX19YmFk' + | ||
| 'c2lnbmF0dXJlISEhISEh'; |
There was a problem hiding this comment.
publicJwk and the tokens need to be in beforeEach?
test/functional/test-experiments.js
Outdated
| beforeEach(() => { | ||
| win = env.win; | ||
| ampdoc = env.ampdoc; | ||
| sandbox = sinon.sandbox.create(); |
There was a problem hiding this comment.
describes.js provides env.sandbox.
test/functional/test-experiments.js
Outdated
| // origin: 'https://www.google.com', | ||
| // experiments: { | ||
| // 'amp-expires-later': {expiration: 95617602000000}, | ||
| // 'amp-expired-': {expiration: 1232427600000}, |
| }); | ||
|
|
||
| it('should throw for missing token', () => { | ||
| if (!crypto.isCryptoAvailable()) { return; } |
There was a problem hiding this comment.
Extract this to wrap it methods instead? Probably better to not run than appear to pass.
There was a problem hiding this comment.
I think this is how the crypto tests are current setup. (not that that's the best solution here) What do you mean by wrap the it methods?
There was a problem hiding this comment.
if (crypto.isCryptoAvailable()) {
it(...);
}
There was a problem hiding this comment.
crypto isn't setup until the beforeEach runs. Besides, I don't think this is really an issue as the case where crypto is unavailable is already tested.
Interesting, looks like an optimization. Let's ask A4A -- maybe we can use this too. |
src/experiments.js
Outdated
|
|
||
| /** | ||
| * Whether the specified experiment is on or off for origin trials. You should | ||
| * check if the experiment is already enabled before calling this function. |
There was a problem hiding this comment.
done, let me know if you think it's too much detail
src/experiments.js
Outdated
| */ | ||
| const bytes = stringToBytes(atob(token)); | ||
| const version = bytes[0]; | ||
| if (version === 0) { |
There was a problem hiding this comment.
done, though we'll need to revert if another version is added
src/experiments.js
Outdated
| /** | ||
| * Version 0: | ||
| * length = 4 bytes representing number of bytes in config | ||
| * config = string containing the experiment ID, origin URL, etc. |
src/experiments.js
Outdated
| * @param {!Object} opt_publicJwk Used for testing only. | ||
| * @return {!Promise<boolean>} | ||
| */ | ||
| export function isExperimentOnForOriginTrial(win, experimentId, opt_publicJwk) { |
There was a problem hiding this comment.
@choumx Do you think there's a better way to provide the public key for testing besides the optional third param here?
There was a problem hiding this comment.
Depends on how the actual public key is stored. OK for now.
There was a problem hiding this comment.
I imagine anyone able to modify the public key (and therefore use tokens they signed themselves) could also just do AMP.toggleExperiment, right?
src/experiments.js
Outdated
| throw new Error('Failed to verify config signature'); | ||
| } | ||
| const configStr = utf8DecodeSync(configBytes); | ||
| const config = JSON.parse(configStr); |
There was a problem hiding this comment.
This should only fail if we generated a bad token, or if the token has been modified. Do you think we should catch the error, add more context, and then reject?
| * Assumes bytes are big endian. | ||
| * @param {!Uint8Array} bytes | ||
| * @return {number} | ||
| */ |
test/functional/test-experiments.js
Outdated
| beforeEach(() => { | ||
| win = env.win; | ||
| ampdoc = env.ampdoc; | ||
| sandbox = sinon.sandbox.create(); |
test/functional/test-experiments.js
Outdated
| // origin: 'https://www.google.com', | ||
| // experiments: { | ||
| // 'amp-expires-later': {expiration: 95617602000000}, | ||
| // 'amp-expired-': {expiration: 1232427600000}, |
test/functional/test-experiments.js
Outdated
| 'AAAAAJF7Im9yaWdpbiI6Imh0dHBzOi8vd3d3Lmdvb2dsZS5jb20iLCJleHBlcmltZW50' + | ||
| 'cyI6eyJhbXAtZXhwaXJlcy1sYXRlciI6eyJleHBpcmF0aW9uIjo5NTYxNzYwMjAwMDAw' + | ||
| 'MH0sImFtcC1leHBpcmVkLSI6eyJleHBpcmF0aW9uIjoxMjMyNDI3NjAwMDAwfX19YmFk' + | ||
| 'c2lnbmF0dXJlISEhISEh'; |
| }); | ||
|
|
||
| it('should throw for missing token', () => { | ||
| if (!crypto.isCryptoAvailable()) { return; } |
There was a problem hiding this comment.
I think this is how the crypto tests are current setup. (not that that's the best solution here) What do you mean by wrap the it methods?
dreamofabear
left a comment
There was a problem hiding this comment.
Can you follow-up on that email thread with two questions:
- Why the hash in crypto-impl.js and should we use it?
- Storage recommendation for private keys?
| }); | ||
|
|
||
| it('should throw for missing token', () => { | ||
| if (!crypto.isCryptoAvailable()) { return; } |
There was a problem hiding this comment.
if (crypto.isCryptoAvailable()) {
it(...);
}
src/experiments.js
Outdated
| * config = string containing the experiment ID, origin URL, etc. | ||
| */ | ||
| const bytesForConfigSize = 4; | ||
| const configLen = |
There was a problem hiding this comment.
Nit: configSize to be consistent, or change the other var to bytesForConfigLen.
src/experiments.js
Outdated
| const config = JSON.parse(configStr); | ||
|
|
||
| const approvedOrigin = config['origin']; | ||
| const url = ampdocServiceFor(win).getAmpDoc().getUrl(); |
There was a problem hiding this comment.
I think this means that a document (e.g. shadow AMP) could enable experiments for the window. We probably should just use win.location as amp-experiments-opt-in does.
src/experiments.js
Outdated
| const approvedOrigin = config['origin']; | ||
| const url = ampdocServiceFor(win).getAmpDoc().getUrl(); | ||
| const sourceOrigin = getSourceOrigin(url); | ||
| if (approvedOrigin !== sourceOrigin) { |
There was a problem hiding this comment.
Is there an issue with optional trailing slash?
There was a problem hiding this comment.
I believe Location.origin always returns without a trailing slash. We'll need to make sure we create and sign the tokens that way.
src/experiments.js
Outdated
| * @param {!Object} opt_publicJwk Used for testing only. | ||
| * @return {!Promise<boolean>} | ||
| */ | ||
| export function isExperimentOnForOriginTrial(win, experimentId, opt_publicJwk) { |
There was a problem hiding this comment.
Depends on how the actual public key is stored. OK for now.
| if (token) { | ||
| meta.setAttribute('content', token); | ||
| } | ||
| win.document.head.appendChild(meta); |
There was a problem hiding this comment.
I assume this is automatically torn down after each unit test?
src/experiments.js
Outdated
| const experiment = experiments[experimentId]; | ||
| if (!experiment) { | ||
| return false; | ||
| } |
There was a problem hiding this comment.
Now I think of it -- for two origin experiments A and B, we probably want to allow publishers to have the flexibility to choose to enable origin trial A && !B on some pages. Chrome also only has one experiment per token.
There was a problem hiding this comment.
Then we should probably run this at page load. Otherwise we will need to apply this process to every token on the page. We could do it once lazily, but then we could still have a promise that takes a very long time to resolve.
WDYT?
There was a problem hiding this comment.
These should all be pre-evaluated, that way error reporting can log that each experiment is enabled.
src/experiments.js
Outdated
| /** | ||
| * Determines if the specified experiment is on or off for origin trials. | ||
| * Callers shouldcheck if the experiment is already enabled before calling this | ||
| * function. |
There was a problem hiding this comment.
Since we're just using a transient experiment, we can check this internally. This should avoid parsing a token more than once.
| * config = string containing the experiment ID, origin URL, etc. | ||
| */ | ||
| const bytesForConfigSize = 4; | ||
| const configSize = |
There was a problem hiding this comment.
All of this byte array stuff is way more complicated than necessary. Why not just use a comma delimited string?
${VERSION = 0},${BASE_64},${SIGNATURE}There was a problem hiding this comment.
We can decide on the actual delimiter (commas are used to separate Origin-Trial headers), but anything will do. We have the benefit of not requiring super-high-performance can-never-exhaust-memory strings.
There was a problem hiding this comment.
That may be a way to go, considering that the version number, base64 encoded experiments object, and signature can't contain commas.
There was a problem hiding this comment.
I believe including the length of the config prevents length extension attack.
There was a problem hiding this comment.
In that case, we would need to sign the length, too. I still don't see this as a high priority, though.
src/experiments.js
Outdated
|
|
||
| const expiration = experiment['expiration']; | ||
| const now = Date.now(); | ||
| if (expiration < now) { |
There was a problem hiding this comment.
We need to ensure expiration is in milliseconds, or we need to normalize now into seconds.
There was a problem hiding this comment.
We control the contents of the token, so we can do the former.
src/experiments.js
Outdated
| const experiment = experiments[experimentId]; | ||
| if (!experiment) { | ||
| return false; | ||
| } |
There was a problem hiding this comment.
These should all be pre-evaluated, that way error reporting can log that each experiment is enabled.
|
@choumx The hash in crypto-impl is definitely there to bail out of verification early. We don't really have a choice but to include it, unless we want to add similar methods to crypto-impl that don't require it. I'll ask them about recommendations for private key storage. EDIT: There would be no need to modify cryppto-impl. If we wanted to get around the hash and version number additions to the signature and version number addition to the pre-signed data, we should just use the browser's crypto directly. |
| * config = string containing the experiment ID, origin URL, etc. | ||
| */ | ||
| const bytesForConfigSize = 4; | ||
| const configSize = |
There was a problem hiding this comment.
I believe including the length of the config prevents length extension attack.
src/experiments.js
Outdated
| const approvedOrigin = config['origin']; | ||
| const url = win.location; | ||
| const sourceOrigin = getSourceOrigin(url); | ||
| if (approvedOrigin !== sourceOrigin) { |
There was a problem hiding this comment.
Since approvedOrigin is just from unfiltered, user-submitted form data, I still think we should make this robust enough such that inclusion of optional fragments like www. and trailing slash will still work.
There was a problem hiding this comment.
I agree about trailing slash, but some of that should be handled when we validate the forms.
src/experiments.js
Outdated
| const experiment = experiments[experimentId]; | ||
| if (!experiment) { | ||
| return false; | ||
| } |
|
@choumx @jridgewell PTAL |
dreamofabear
left a comment
There was a problem hiding this comment.
Did you speak with @taymonbeal about the upcoming crypto-impl.js API changes and key rotation strategy?
src/experiments.js
Outdated
| let toggles_ = null; | ||
|
|
||
| /** @type {!Promise} */ | ||
| const originTrialsPromise = enableExperimentsForOriginTrials(self); |
There was a problem hiding this comment.
We should avoid competing with startup tasks. The non-startup flavor of chunk may be a good fit here.
How long does it take to verify a single token? That should decide the granularity of the chunk.
There was a problem hiding this comment.
Any suggestions on how to get that to work with the async nature of the token parsing? enableExperimentsForOriginTrials needs to be able to return a promise.
I profiled enableExperimentsFromToken and at 10x throttling, Chomr reported about 5ms, though I'm dubious. Does that number sound reasonable to you?
There was a problem hiding this comment.
You can create a new Promise, store the resolve function and call it manually when it completes.
At 5ms it might be fine to have the entire thing in a single chunk.
There was a problem hiding this comment.
But how can chunk control when the different parts of the task run if the task has a long promise chain?
|
I've put something on our calendars for next week. Taymon took a look over this PR and, I'm assuming, didn't see any glaring issues. Reviewing the PR that changes crypto-impl, I can tel I'll need to tweak the token generation process (and, therefore, the test tokens) but the logic here should be unaffected. Of course, I'll test again after that code is merged to be sure. |
src/experiments.js
Outdated
| return crypto.importPublicKey('experiments', publicJwk).then(keyInfo => { | ||
| return crypto.verifySignature(configBytes, signatureBytes, keyInfo); | ||
| }).then(verified => { | ||
| if (!verified) { |
There was a problem hiding this comment.
This needs to be an Error instance, and you can just throw it.
src/experiments.js
Outdated
|
|
||
| const approvedOrigin = parseUrl(config['origin']).origin; | ||
| const sourceOrigin = getSourceOrigin(win.location); | ||
| if (approvedOrigin !== sourceOrigin) { |
src/experiments.js
Outdated
| if (approvedOrigin !== sourceOrigin) { | ||
| return Promise.reject('Config does not match current origin'); | ||
| } | ||
|
|
There was a problem hiding this comment.
Multiple experiments per token breaks with Chrome's Origin Trials.
There was a problem hiding this comment.
can you link or send me info on that?
There was a problem hiding this comment.
https://github.com/jpchase/OriginTrials/blob/gh-pages/explainer.md#trial-tokens
Why I think this is a good idea: #8944 (comment)
There was a problem hiding this comment.
I don't understand the argument. Any two trials can be turned on independently, accomplishing #8944 (comment).
| const configBytes = bytes.subarray(current, current + configSize); | ||
| current += configSize; | ||
| const signatureBytes = bytes.subarray(current); | ||
|
|
There was a problem hiding this comment.
This may need to be filled in later once we determine the proper way to store the key pair.
| user().warn(TAG, 'Crypto is unavailable'); | ||
| return Promise.resolve(); | ||
| } | ||
| /** |
There was a problem hiding this comment.
Continuing from: #8944 (comment)
Should be sign(version + length + config, private_key). My mistake on the doc.
kmh287
left a comment
There was a problem hiding this comment.
Changed to one experiment per token and updated the signature verification.
| user().warn(TAG, 'Crypto is unavailable'); | ||
| return Promise.resolve(); | ||
| } | ||
| /** |
| export function bytesToUInt32(bytes) { | ||
| if (bytes.length != 4) { | ||
| throw new Error('Received byte array with length != 4'); | ||
| } |
There was a problem hiding this comment.
Nit: We can shorten this a bit by forcing 8 bit ints before shifting:
const val = (bytes[0] & 0xFF) << 24 |
(bytes[1] & 0xFF) << 16 |
(bytes[2] & 0xFF) << 8 |
(bytes[3] & 0xFF);…ivate key were disclosed and we needed to invalidate all tokens
dreamofabear
left a comment
There was a problem hiding this comment.
One change and then let's get this in. Do you have the token generation steps in a shared doc?
src/experiments.js
Outdated
| //TODO(kmh287, #8331) Replaced empty object literal with real experiment public | ||
| // key jwk. | ||
| /** @type {!Promise} */ | ||
| const originTrialsPromise = enableExperimentsForOriginTrials(self, {}); |
There was a problem hiding this comment.
Let's comment this out for now pending resolution of the startup latency issue.
|
I sent you a js file a few days ago. |
Partial for #8331.
Allows publishers to enable certain experiments, provided that a page includes a token that describes the experiments that should be enabled. The token is formatted as follows:
A base64 encoding of:
The experiments config will contain the experiments to enable, their expirations, and the origin to enable the experiments for. (To prevent copying of one token from one site to another).
/to @choumx @jridgewell