-
-
Notifications
You must be signed in to change notification settings - Fork 146
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
Fix ECDSA Signature Unwrapping #594
Conversation
Hello @nlordell, thank you very much for the submission. My past-self has required me to start off by saying that this project is not open to external contributions as per the README. That said, given that I don't work with WebCrypto day-to-day, the code in the PR looks high quality to me, and your rationale is explained quite well, I'm going to make an exception. Crypto is hard to do right and it seems to be dumb luck that I've managed to get away this long with EC2 signature wrapping as it's been. Thank you again for bringing this to my attention, and for helping improve use of WebCrypto in SimpleWebAuthn 🙇 Tests are passing so I'm going to start reviewing the PR "this weekend" (I might try for tonight, otherwise sometime before Monday.) After a quick perusal just now I have only a few nitpicks here and there, otherwise the bulk of the code looks good to me. |
packages/server/src/helpers/iso/isoCrypto/unwrapEC2Signature.ts
Outdated
Show resolved
Hide resolved
packages/server/src/helpers/iso/isoCrypto/unwrapEC2Signature.ts
Outdated
Show resolved
Hide resolved
packages/server/src/helpers/iso/isoCrypto/unwrapEC2Signature.ts
Outdated
Show resolved
Hide resolved
packages/server/src/helpers/iso/isoCrypto/unwrapEC2Signature.ts
Outdated
Show resolved
Hide resolved
packages/server/src/helpers/iso/isoCrypto/unwrapEC2Signature.ts
Outdated
Show resolved
Hide resolved
packages/server/src/helpers/iso/isoCrypto/unwrapEC2Signature.ts
Outdated
Show resolved
Hide resolved
packages/server/src/helpers/iso/isoCrypto/unwrapEC2Signature.ts
Outdated
Show resolved
Hide resolved
const iBytes = new Uint8Array(i); | ||
|
||
const normalizedBytes = new Uint8Array(n); | ||
if (iBytes.length <= n) { | ||
normalizedBytes.set(iBytes, n - iBytes.length); | ||
} else if (iBytes.length === n + 1 && iBytes[0] === 0 && (iBytes[1] & 0x80) === 0x80) { | ||
normalizedBytes.set(iBytes.slice(1)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's possible to reduce the number of copies of the input bytes that are made if there's more consistent use of .slice()
(I'm assuming the method arguments are renamed as I requested above):
const iBytes = new Uint8Array(i); | |
const normalizedBytes = new Uint8Array(n); | |
if (iBytes.length <= n) { | |
normalizedBytes.set(iBytes, n - iBytes.length); | |
} else if (iBytes.length === n + 1 && iBytes[0] === 0 && (iBytes[1] & 0x80) === 0x80) { | |
normalizedBytes.set(iBytes.slice(1)); | |
const normalizedBytes = new Uint8Array(componentLength); | |
if (bytes.length <= componentLength) { | |
normalizedBytes.set(bytes.slice(0), componentLength - bytes.length); | |
} else if (bytes.length === componentLength + 1 && bytes[0] === 0 && (bytes[1] & 0x80) === 0x80) { | |
normalizedBytes.set(bytes.slice(1)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I implemented something I'm happy with here, where the underlying buffer only gets copied if the signature component needs to be extended. In the more common cases with the signature components are already the correct length (or have a leading 0
), we reuse the underlying buffer without copying.
Note that instead of using slice
here, I opted for subarray
so that the underlying buffer does not get copied.
} else if (iBytes.length === n + 1 && iBytes[0] === 0 && (iBytes[1] & 0x80) === 0x80) { | ||
normalizedBytes.set(iBytes.slice(1)); | ||
} else { | ||
throw new Error("invalid signature component length"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's add a bit more debugging info to the error message:
throw new Error("invalid signature component length"); | |
throw new Error(`invalid signature length: ${bytes.length} bytes (component length: ${componentLength}`); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would still say "invalid signature component length" since this is the length of the r
or s
value and not the whole signature.
Adding more debugging info makes sense!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, r
and s
are called "components", one day I'll finish this Real-World Cryptography book I swear 🫠
I'll defer to you on exact verbiage here, my intent here was to add lengths to the error message to help library users better troubleshoot this error 😅
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, r and s are called "components"
Just for transparency, this isn't a technical term I found in literature, just a more general term that fits here... I tried to find what people actually call r
and s
individually, but couldn't find any real consensus.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added a bit more text to the comments so the meaning of "component" hopefully clearer
Oh shoot, I'm really sorry, should have checked before making a PR 🙈. With this in mind, if you feel strongly about sticking to your rule, feel free to close this PR and implement the change as you see fit (and borrow some of the code from the PR if you like) - in a way consider this PR as a detailed issue report 😅. I definitely won't be disappointed and am happy in being able to contribute a detailed bug finding 😄.
Just some wild speculation, but maybe ECDSA credentials aren't as widely used as EdDSA ones? Also, it only happens when the leading 8 bits are 0, so a roughly 1/256 chance (well, a little less because |
Thanks for the thoughtful review! I will squash the suggestions into a single commit and play around with your idea of reducing input byte copies when I'm back home tonight. |
I implemented the suggestions from the PR review - quick question, do you have any preference for formatting for the file? I figured you were using Let me know if you want me to run an automated code formatter on this. |
Co-authored-by: Matthew Miller <[email protected]>
83d7161
to
215dc5d
Compare
Looking at my command history, in the past I've run this from the root of the monorepo: $> deno fmt packages/server/src/ It's because it's the root deno.jsonc file that defines single quotes and line length: Lines 1 to 6 in a169def
Husky git pre-commit hooks are supposed to take care of this for you but I'm not surprised they didn't work for you - I haven't had great confidence in them in the past and it seems not much has improved about them. |
Formatted with `deno fmt --config deno.jsonc`
Thanks for the tip! Changes should be formatted now. |
@nlordell This all looks good to me, thank you for lending this project some of your SubtleCrypto expertise 🙇 |
@nlordell I've released @simplewebauthn/[email protected] that has this fix in it. Thank you again! 🚀 |
We ran into some unexpected and inconsistent signature verification errors. After some digging I traced it down to what I believe is an issue in the
unwrapEC2Signature
method.From what I understood, this is responsible for taking the ASN.1 encoded ECDSA signature, and encoding it into a format that the WebCrypto library expects (which is
r ‖ s
- where‖
is the byte concatenation operator). In particular the Web Crypto API documents1 that:So, this means that the signature components have very specific expected byte lengths when "unwrapping" the signature for use with the Web Crypto "subtle" API. In practice, I found that it was not always producing signatures of the correct length, and in particular the
rBytes
andsBytes
values were not always of lengthn
as defined above. Specifically, I was working with P-256 signatures and noticed that sometimesr
and/ors
had a length of 31 instead of 32 (which is the smallest byte-length required to represent an integer equal to the order of the P-256 curve).Looking into ASN.1 specification2:
After more closely inspecting the signature I was using, it would occasionally produce signatures where the MSB of the
r
ors
value was0
, which would cause the BER encoding of the signature component to be shorter than expected. From the P-256 test case, the signature in hex can be broken down to:Note that, the leading
00
byte from the signaturer
value is removed in the ASN.1 encoding, while an additional leading00
byte is added to thes
value (so that it isn't confused with02 20 aeb47b3d78206408830fdf203d9a63512847c577e4449a1198d866c7a33682ef
, which would represent-0x514b84c287df9bf77cf020dfc2659caed7b83a881bbb65ee672799385cc97d11
).This PR fixes the
unwrapEC2Signature
implementation in order such that it computesr ‖ s
where the byte length ofr
ands
are guaranteed to be the same (and specificallyn
as it is defined in the Web Crypto API specification1).I added some test vectors to make sure things work (that would fail on
master
). I also added a P-521 test vector which does not work (as it does not seem to be implemented by Deno). I left it in asignored
as the vector itself should be correct (as manually porting the test to run in my browser passed) and it might be nice to add once P-521 is supported.Footnotes
https://www.w3.org/TR/WebCryptoAPI/#ecdsa-operations ↩ ↩2
https://www.itu.int/rec/T-REC-X.690-202102-I/en ↩