-
Notifications
You must be signed in to change notification settings - Fork 107
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Stringify JSON deterministically #83
base: master
Are you sure you want to change the base?
Conversation
binary payloads would get mangled due to the unnecessary string conversion, which should go the other way around Fixes: auth0#50
sign: dont convert input buffers to utf8 strings
Just put some curly braces as JS doesn't have such syntax (yet).
Fix typo in code example
deps: replace base64url with inline definition
This only works one level deep which should not be a problem because the user can pre-stringify the payload. This is only needed for the header. |
Another option would be to use https://github.com/substack/json-stable-stringify but I didn't want to add another dependency. |
Do we need test cases to prove this is deterministic? |
Hey guys! Just a proposal from my side to consider an initial request on the same topic in the Node repository and the response there nodejs/node#15628 (comment) . I think we can rely on proper ECMAScript implementation in Node so no sense worrying and considering an additional dependency. |
Interesting - and apologies for taking so long to follow up. I'm a little conflicted, since it's just a simple change it would be easy to just merge, and I don't expect it would break anything... On the other hand, as noted in auth0/node-jsonwebtoken#404, this doesn't actually break any intended usage (for compact representation). Verifying takes the input string and checks the signature part against the payload part, doesn't involve re-generating the payload. If generating a JWT twice, I might expect fields like So then where does this come up? I could imaging a testing scenario: const header = { alg: 'HS256', kid: '1234' };
const payload = { foo: 'bar', baz: 'quux' };
const secret = crypto.randomBytes(...);
const sig1 = jws.sign({ header, payload, secret });
const sig2 = jws.sign({ header, payload, secret });
assert.strictEqual(sig1, sig2); But then that's making an unnecessary assumption about the library internals. Alternatively, I could imagine someone writing code that does something similar: // Don't do this
const USER_TOKENS = {};
app.use((req, res, next) => {
const inputToken = req.headers.Authorization.split(' ')[1];
if (!inputToken) {
return next(new Error('No token!'));
}
const userId = req.params.userId;
if (inputToken !== USER_TOKENS[userId]) {
return next(new Error('Invalid token!'));
}
next();
}); But that's very much the wrong way to be using JWTs. I am sure there's other ways, but I am struggling to image one that isn't against the spirit of the ecosystem. I would almost be inclined to go the exact opposite of this PR and shuffle the keys in order to make the exact signature unpredictable for any sort of usage. If there's something I'm missing, please let me know, but otherwise I'm going to close this out for now. |
@omsmith I completely agree with everything you are saying. Expecting JWTs to have the keys in a deterministic order is stupid. However, that is what the telephone industry will be requiring for signed telephone calls. My PR doesn't even completely solve this because it only works on the first level. But because the payload can be manually stringified and then passed to this library, the only issue is that the header can't be manually stringified. Another option would be to add a flag could be added to allow for true (muli level) deterministic handling of the header and payload for those who need it? Alternatively, allowing a function to be passed in that does the stringification to override the default behavior of using JSON.stringify? References: [1] https://tools.ietf.org/html/draft-ietf-stir-passport-shaken-07#section-8 (references RFC 8225 must be followed) My code to generate a token because of this (basically I can't use the JWT library): const jwa = require('jwa');
const stringifyStable = require('json-stable-stringify');
const stiSigner = jwa('ES256');
const stiHeader = stringifyStable({
alg: stiAlg,
ppt: stiPpt,
typ: stiTyp,
x5u: stiX5u,
});
const stiPayload = stringifyStable({
attest: stiAttest,
dest: {
tn: [
stiDestTn,
],
},
iat: stiIat,
orig: {
tn: stiOrigTn,
},
origid: stiOrigid,
});
const unsignedToken = `${base64url(stiHeader)}.${base64url(stiPayload)}`;
const token = `${unsignedToken}.${stiSigner.sign(unsignedToken, privateKey)}`; |
@fenichelar I appreciate the extra information! This is the first time I've heard of RFC8225, and from a quick skim I guess it's another unfortunate example of "we use RFCfoo, except"? I'll have to have a better read through later on. One thing I did wonder about, which seems to be confirmed by your other examples:
Except the JW* specs, clearly defines its members as being generally case-sensitive, such as the "alg" parameter, and while "typ" is not, it's "RECOMMENDED" that JWT be uppercase. And the example page clearly shows alg being uppercase, and an "attest" field in the payload being uppercase. Anyway, like I said, I still have to actually read through RFC8225; however, in the mean time an |
Haha I didn't even notice that. Literally right below that in the example (https://tools.ietf.org/html/rfc8225#section-9.1) there are capital letters in the value...
I'm thinking that this was written incorrectly and should have been:
I'll bring this up with the authors of the RFC for further clarification.
Of course that would work great for me :) Do you think that is the best thing? |
It looks like the reason that rfc specifies a serialization process, is because the recommend not actually sending the header and payload, but just the signature?
And then the receiving side constructs the header and payload from other information it already has? |
@omsmith That is correct: https://tools.ietf.org/html/rfc8225#section-7 The reasoning behind this has been to try not to exceed MTU because SIP is generally done over UDP and UDP fragmentation can be problematic. The SIP standard actually says that TCP should be used instead of UDP packet fragmentation, but I only know of one telephone switch vendor that follows this. Behavior defined here: https://tools.ietf.org/html/rfc3261#section-18.1.1. However, for SHAKEN (the framework to prevent telephone number spoofing) compact headers have been abandoned. SHAKEN will require the full form:
from https://tools.ietf.org/html/draft-ietf-stir-passport-shaken-07#section-3
from https://access.atis.org/apps/group_public/download.php/32237/ATIS-1000074.pdf |
To clarify, reconstructing the SHAKEN adds |
So to be clear SHAKEN doesn't try to do any truncation, and always sends the full header and payload (and not a subset of the header and payload in order to try to fit within a frame)? Will PASSporT's "compact" form be used in practice, if SHAKEN refuses it, and STIR seemingly suggests against it? |
Yes.
Good questions. I'm not knowledgeable enough about all of the PASSporT extensions outside of SHAKEN to answer that. There are a bunch of related documents here: https://datatracker.ietf.org/wg/stir/documents/. It's probably safe to assume that the 3 listed extensions are currently all that is in the works.
|
FYI, I doubt Rich Call Data (RCD) will ever be used in practice without SHAKEN. Even the RCD draft discusses how it would be combined with SHAKEN in a single PASSporT. |
Well you know know more than me :P. I'm aware of SIP and STUN and ICE, but it's the first time I'm hearing if these telephony protocols. What I'm interpreting is that PASSporT's "compact" form was perhaps a mistake, and the ecosystem is ignoring it, but I'm biased and have no real knowledge of that. Do you have a way if verifying that? Perhaps the RFC authors will have a sense of it when you reach out about the lowercase situation. I don't terribly want to support an unused part of a generally unrelated spec if that's the case. I'm happy to work with you either way though and appreciate the information you've been able to provide. |
Agreed.
I will try and find out more about the compact form.
There is a SHAKEN testbed that is run by Neustar at the request of The Alliance for Telecommunications Industry Solutions (ATIS): https://www.home.neustar/atis-testbed/index.php. This is purely for SHAKEN and helps telephone service provides test their implementation to ensure it will work with other telephone service provides. This testbed checks that the order is lexicographical even though the full form is used. Hard to say what other implementations do but they could easily do the same thing. I had to abandon our use of the JWT library and directly create the token to be able to sign telephone calls for the testbed, before that the tests failed. For verifying telephone calls, I do use the JWT library and therefore don't check the order. So for SHAKEN, I think the order needs to be lexicographical even though the reasoning is no longer relevant because the compact form has seemingly been abandoned :( |
You might be interested in this related standards effort: Dropping the compact mode of Passport seems like a good idea. |
FWIW If there's the need for canonical payload i believe this should be a JWS extension (similar to b64:false) using a "crit" member being registered somewhere in the specifications. You can then easily see if a library supports this or not. "crit" is there for exactly this reason. |
And we have a published implementation of JCS here https://www.npmjs.com/package/canonicalize |
See auth0/node-jsonwebtoken#404 for more details of why.