-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
post: how to verify joyid webauthn signature
- Loading branch information
Showing
2 changed files
with
326 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
323 changes: 323 additions & 0 deletions
323
... to Verify JoyID WebAuthn Signature/§ How to Verify JoyID WebAuthn Signature.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,323 @@ | ||
--- | ||
date: 2023-12-17T17:35:33+0800 | ||
draft: false | ||
description: This post shows how to verify the signature from the method signChallenge of the `@joyid/ckb` package using using the OpenSSL command line and the Python library PyCryptodome | ||
tags: | ||
- cryptography | ||
--- | ||
# How to Verify JoyID WebAuthn Signature | ||
|
||
**Status**:: #i | ||
**Zettel**:: #zettel/permanent | ||
**Created**:: [[2023-12-17]] | ||
|
||
[JoyID](https://docs.joy.id/guide) is a multichain, cross-platform, passwordless and mnemonic-free wallet solution based on FIDO WebAuthn protocol and Nervos CKB. | ||
|
||
This post shows how to verify the signature from the method [signChallenge][] of the `@joyid/ckb` package. The method reference page has a demo. I use the demo to obtain an example response then verify the response using the OpenSSL command line and the Python library [PyCryptodome](https://pycryptodome.readthedocs.io/en/latest/src/introduction.html). | ||
|
||
[signChallenge]: https://docs.joy.id/guide/ckb/sign-message | ||
|
||
The JoyID follows the WebAuthn specification and employs secp256r1 for signing. Although the guide references [section 6.3.3](https://www.w3.org/TR/webauthn-2/#sctn-op-get-assertion) of the WebAuthn specification, titled "The authenticatorGetAssertion Operation", I discovered that the example in [this repository](https://github.com/duo-labs/py_webauthn/blob/master/webauthn/authentication/verify_authentication_response.py) provided me much more helps. | ||
|
||
<!--more--> | ||
|
||
## The Response Parsing | ||
|
||
This is the example I obtained from the demo. | ||
|
||
```json | ||
{ | ||
"signature": "MEUCICF25qdO6nLreEoBHnyaw-9R6XFHbIu-NwsAI53t016qAiEAgmhlwTEMxoWxKj79R1rUkB_6nrhJfws82DqHkY_HnqQ", | ||
"message": "K4sF4fAwPvuJj-TW3mARmMenuGSrvmohxzsueH4YfFIFAAAAAHsidHlwZSI6IndlYmF1dGhuLmdldCIsImNoYWxsZW5nZSI6IlUybG5iaUIwYUdseklHWnZjaUJ0WlEiLCJvcmlnaW4iOiJodHRwczovL3Rlc3RuZXQuam95aWQuZGV2IiwiY3Jvc3NPcmlnaW4iOmZhbHNlLCJvdGhlcl9rZXlzX2Nhbl9iZV9hZGRlZF9oZXJlIjoiZG8gbm90IGNvbXBhcmUgY2xpZW50RGF0YUpTT04gYWdhaW5zdCBhIHRlbXBsYXRlLiBTZWUgaHR0cHM6Ly9nb28uZ2wveWFiUGV4In0", | ||
"challenge": "Sign this for me", | ||
"alg": -7, | ||
"pubkey": "3538dfd53ad93d2e0a6e7f470295dcd71057d825e1f87229e5afe2a906aa7cfc099fdfa04442dac33548b6988af8af58d2052529088f7b73ef00800f7fbcddb3", | ||
"keyType": "main_key" | ||
} | ||
``` | ||
|
||
### pubkey | ||
|
||
The `pubkey` field represents the uncompressed public key concatenating two 32-byte integers in hex. PyCryptodome can import the key by prepending the flag `0x04`. OpenSSL uses PEM to encode keys, and PyCryptodome can help here to export the key in PEM format. | ||
|
||
```python | ||
|
||
from Crypto.PublicKey import ECC | ||
|
||
pubkey_raw_hex = "3538dfd53ad93d2e0a6e7f470295dcd71057d825e1f87229e5afe2a906aa7cfc099fdfa04442dac33548b6988af8af58d2052529088f7b73ef00800f7fbcddb3" | ||
pubkey = ECC.import_key(bytes.fromhex("04" + pubkey_raw_hex), curve_name="secp256r1") | ||
with open("pubkey.pem", "wt") as pemfile: | ||
pemfile.write(pubkey.export_key(format="PEM")) | ||
``` | ||
|
||
Double check the key using OpenSSL: | ||
|
||
```shell-session | ||
$ openssl ec -text -inform PEM -in pubkey.pem -pubin | ||
... | ||
Public-Key: (256 bit) | ||
pub: | ||
04:35:38:df:d5:3a:d9:3d:2e:0a:6e:7f:47:02:95: | ||
dc:d7:10:57:d8:25:e1:f8:72:29:e5:af:e2:a9:06: | ||
aa:7c:fc:09:9f:df:a0:44:42:da:c3:35:48:b6:98: | ||
8a:f8:af:58:d2:05:25:29:08:8f:7b:73:ef:00:80: | ||
0f:7f:bc:dd:b3 | ||
ASN1 OID: prime256v1 | ||
NIST CURVE: P-256 | ||
... | ||
``` | ||
|
||
### message | ||
|
||
The `message` is a binary encoded by base64 [RFC 4648 §5](https://datatracker.ietf.org/doc/html/rfc4648#section-5) without the equal sign (`=`) paddings. Many base64 tools and libraries require padding equal sign (`=`) in the end of the string to make the length multiple of 4. The `message` in the example response has a length 351, which requires one `=` padding. | ||
|
||
The first 37 bytes in `message` are authenticator data, and the following bytes are client data in JSON. | ||
|
||
The section [section 6.1](https://www.w3.org/TR/webauthn-2/#sctn-authenticator-data) in the WebAuthn specification defines the layout of the authenticator data. | ||
|
||
- `rpIdHash`, 32 bytes: the sha256 checksum of the text `testnet.joyid.dev` | ||
- `flags`, 1 byte: `0x05` in JoyID | ||
- `signCount`, 4 bytes: all zeros | ||
|
||
```sh | ||
base64 -d <<<' | ||
K4sF4fAwPvuJj-TW3mARmMenuGSrvmohxzsueH4YfFIFAAAAAHsidHlwZSI6Indl | ||
YmF1dGhuLmdldCIsImNoYWxsZW5nZSI6IlUybG5iaUIwYUdseklHWnZjaUJ0WlEi | ||
LCJvcmlnaW4iOiJodHRwczovL3Rlc3RuZXQuam95aWQuZGV2IiwiY3Jvc3NPcmln | ||
aW4iOmZhbHNlLCJvdGhlcl9rZXlzX2Nhbl9iZV9hZGRlZF9oZXJlIjoiZG8gbm90 | ||
IGNvbXBhcmUgY2xpZW50RGF0YUpTT04gYWdhaW5zdCBhIHRlbXBsYXRlLiBTZWUg | ||
aHR0cHM6Ly9nb28uZ2wveWFiUGV4In0=' | | ||
dd bs=1 count=37 2>/dev/null | | ||
xxd | ||
#=> 00000000: 2b8b 05e1 f030 3efb 898f e4d6 de60 1198 | ||
#=> 00000010: c7a7 b864 abbe 6a21 c73b 2e78 7e18 7c52 | ||
#=> 00000020: 0500 0000 00 | ||
``` | ||
|
||
Check the first two lines with the sha256 checksum: | ||
|
||
```sh | ||
echo -n 'testnet.joyid.dev' | sha256sum | ||
#=> 2b8b05e1f0303efb898fe4d6de601198c7a7b864abbe6a21c73b2e787e187c52 - | ||
``` | ||
|
||
The client data JSON looks like this: | ||
|
||
```sh | ||
base64 -d <<<' | ||
K4sF4fAwPvuJj-TW3mARmMenuGSrvmohxzsueH4YfFIFAAAAAHsidHlwZSI6Indl | ||
YmF1dGhuLmdldCIsImNoYWxsZW5nZSI6IlUybG5iaUIwYUdseklHWnZjaUJ0WlEi | ||
LCJvcmlnaW4iOiJodHRwczovL3Rlc3RuZXQuam95aWQuZGV2IiwiY3Jvc3NPcmln | ||
aW4iOmZhbHNlLCJvdGhlcl9rZXlzX2Nhbl9iZV9hZGRlZF9oZXJlIjoiZG8gbm90 | ||
IGNvbXBhcmUgY2xpZW50RGF0YUpTT04gYWdhaW5zdCBhIHRlbXBsYXRlLiBTZWUg | ||
aHR0cHM6Ly9nb28uZ2wveWFiUGV4In0=' | | ||
dd bs=1 skip=37 2>/dev/null | | ||
jq | ||
{ | ||
"type": "webauthn.get", | ||
"challenge": "U2lnbiB0aGlzIGZvciBtZQ", | ||
"origin": "https://testnet.joyid.dev", | ||
"crossOrigin": false, | ||
... | ||
} | ||
``` | ||
|
||
Notice the `challenge` field. It is the parameter passed to `signChallenge`, in base64. | ||
|
||
```sh | ||
base64 -d <<<'U2lnbiB0aGlzIGZvciBtZQ==' | ||
#=> Sign this for me | ||
``` | ||
|
||
Attention that message is not the binary to be signed. According to the Figure 4, Generating an assertion signature, in [the WebAuthn specification](https://www.w3.org/TR/webauthn-2/#sctn-authenticator-data), the binary to be signed is a concatenation of the authenticator data and the sha256 checksum of the client data JSON. | ||
|
||
The following code shows how to prepare the message to sign and save it into the file `message.bin`. Attention that base64 must use the alternative keys `-` and `_` to replace `+` and `/` respectively. | ||
|
||
> [!attention] | ||
> To decode base64 [RFC 4648 §5] in python, use either `base64.b64decode(s, altchars="-_")` or `binascii.a2b_base64`. | ||
```python | ||
import base64 | ||
from Crypto.Hash import SHA256 | ||
|
||
message_bin = base64.b64decode( | ||
"K4sF4fAwPvuJj-TW3mARmMenuGSrvmohxzsueH4YfFIFAAAAAHsidHlwZSI6Indl" | ||
"YmF1dGhuLmdldCIsImNoYWxsZW5nZSI6IlUybG5iaUIwYUdseklHWnZjaUJ0WlEi" | ||
"LCJvcmlnaW4iOiJodHRwczovL3Rlc3RuZXQuam95aWQuZGV2IiwiY3Jvc3NPcmln" | ||
"aW4iOmZhbHNlLCJvdGhlcl9rZXlzX2Nhbl9iZV9hZGRlZF9oZXJlIjoiZG8gbm90" | ||
"IGNvbXBhcmUgY2xpZW50RGF0YUpTT04gYWdhaW5zdCBhIHRlbXBsYXRlLiBTZWUg" | ||
"aHR0cHM6Ly9nb28uZ2wveWFiUGV4In0==", | ||
"-_" | ||
) | ||
authenticator_data = message_bin[:37] | ||
client_data = message_bin[37:] | ||
message_to_sign = authenticator_data + SHA256.new(client_data).digest() | ||
|
||
with open("message.bin", "wb") as fout: | ||
fout.write(message_to_sign) | ||
``` | ||
|
||
> [!attention] | ||
> The `message` in the response is not the binary to be signed. Instead, the binary to be signed is a concatenation of the authenticator data and the sha256 checksum of the client data JSON. | ||
### signature | ||
|
||
The field signature are two 32-byte integers first encoded in [DER][], then base64 [RFC 4648 §5](https://datatracker.ietf.org/doc/html/rfc4648#section-5) without the equal sign (`=`) paddings. | ||
|
||
[DER]: https://wiki.openssl.org/index.php/DER | ||
|
||
Many base64 tools and libraries require padding equal sign (`=`) in the end of the string to make the length multiple of 4. The signature in the example response has a length 95, which requires one `=` padding. | ||
|
||
OpenSSL also stores signature in DER, let's save one in the file `signature.der`: | ||
|
||
``` | ||
base64 -d <<<"MEUCICF25qdO6nLreEoBHnyaw-9R6XFHbIu-NwsAI53t016qAiEAgmhlwTEMxoWxKj79R1rUkB_6nrhJfws82DqHkY_HnqQ=" > signature.der | ||
``` | ||
|
||
The command `openssl asn1parse` can parse the file `signature.der` in the DER format. | ||
|
||
```bash | ||
openssl asn1parse -dump -inform DER -in signature.der | ||
# Output => | ||
# 0:d=0 hl=2 l= 69 cons: SEQUENCE | ||
# 2:d=1 hl=2 l= 32 prim: INTEGER :2176E6A74EEA72EB784A011E7C9AC3EF51E971476C8BBE370B00239DEDD35EAA | ||
# 36:d=1 hl=2 l= 33 prim: INTEGER :826865C1310CC685B12A3EFD475AD4901FFA9EB8497F0B3CD83A87918FC79EA4 | ||
``` | ||
|
||
PyCryptodome expects the signature of 64 bytes for two 32-byte integers. Following code uses a simple parser to extract the raw signature from the DER binary. | ||
|
||
```python | ||
import base64 | ||
|
||
|
||
# byte 0: SEQ (0x30) | ||
# byte 1: SEQ length = n1 + n2 + 4 | ||
# byte 2: INT (0x02) | ||
# byte 3: INT length n1 | ||
# byte 4 ~ 3 + n1: the first int payload | ||
# byte 4 + n1: INT (0x02) | ||
# byte 5 + n1: INT length n2 | ||
# remaining: the second int payload | ||
def decode_signature(signature): | ||
if signature[0] != 0x30 or signature[1] != len(signature) - 2: | ||
raise ValueError("invalid asn1 DER") | ||
|
||
x = decode_u32(signature[2:]) | ||
y = decode_u32(signature[2 + 2 + signature[3] :]) | ||
|
||
return x + y | ||
|
||
|
||
def decode_u32(bytes): | ||
if bytes[0] != 0x02: | ||
raise ValueError("invalid asn1 DER") | ||
u32 = bytes[2 : 2 + bytes[1]] | ||
|
||
if len(u32) == 32: | ||
return u32 | ||
elif len(u32) > 32: | ||
return u32[(len(u32) - 32) :] | ||
else: | ||
return b"\0" * (32 - len(u32)) + u32 | ||
|
||
|
||
signature_der = base64.b64decode( | ||
"MEUCICF25qdO6nLreEoBHnyaw-9R6XFHbIu-NwsAI53t016qAiEAgmhlwTEMxoWx" | ||
"Kj79R1rUkB_6nrhJfws82DqHkY_HnqQ=", | ||
"-_", | ||
) | ||
|
||
signature = decode_signature(signature_der) | ||
print(signature[0:32].hex()) | ||
print(signature[32:].hex()) | ||
# => | ||
``` | ||
|
||
## Verifying | ||
|
||
PyCryptodome: | ||
|
||
```python | ||
from Crypto.Hash import SHA256 | ||
from Crypto.Signature import DSS | ||
|
||
DSS.new(pubkey, "fips-186-3").verify(SHA256.new(message_to_sign), signature) | ||
print("Verified OK") | ||
``` | ||
|
||
OpenSSL: | ||
|
||
```sh | ||
openssl dgst -sha256 -verify pubkey.pem -signature signature.der message.bin | ||
``` | ||
|
||
> [!code]- Full Python code ([Gist](https://gist.github.com/doitian/b1f5c60203e9dbaffccff7d0920d9529)) | ||
> | ||
> ```python | ||
> import base64 | ||
> from Crypto.Hash import SHA256 | ||
> from Crypto.PublicKey import ECC | ||
> from Crypto.Signature import DSS | ||
> | ||
> | ||
> def decode_signature(signature): | ||
> if signature[0] != 0x30 or signature[1] != len(signature) - 2: | ||
> raise ValueError("invalid asn1 DER") | ||
> | ||
> x = decode_u32(signature[2:]) | ||
> y = decode_u32(signature[2 + 2 + signature[3] :]) | ||
> | ||
> return x + y | ||
> | ||
> | ||
> def decode_u32(bytes): | ||
> if bytes[0] != 0x02: | ||
> raise ValueError("invalid asn1 DER") | ||
> u32 = bytes[2 : 2 + bytes[1]] | ||
> | ||
> if len(u32) == 32: | ||
> return u32 | ||
> elif len(u32) > 32: | ||
> return u32[(len(u32) - 32) :] | ||
> else: | ||
> return b"\0" * (32 - len(u32)) + u32 | ||
> | ||
> | ||
> def b64decode(encoded_string): | ||
> if len(encoded_string) % 4 != 0: | ||
> encoded_string = encoded_string + "=" * (4 - len(encoded_string) % 4) | ||
> | ||
> # RFC 4648 | ||
> return base64.b64decode(encoded_string, "-_") | ||
> | ||
> | ||
> response = { | ||
> "signature": "MEUCICF25qdO6nLreEoBHnyaw-9R6XFHbIu-NwsAI53t016qAiEAgmhlwTEMxoWxKj79R1rUkB_6nrhJfws82DqHkY_HnqQ", | ||
> "message": "K4sF4fAwPvuJj-TW3mARmMenuGSrvmohxzsueH4YfFIFAAAAAHsidHlwZSI6IndlYmF1dGhuLmdldCIsImNoYWxsZW5nZSI6IlUybG5iaUIwYUdseklHWnZjaUJ0WlEiLCJvcmlnaW4iOiJodHRwczovL3Rlc3RuZXQuam95aWQuZGV2IiwiY3Jvc3NPcmlnaW4iOmZhbHNlLCJvdGhlcl9rZXlzX2Nhbl9iZV9hZGRlZF9oZXJlIjoiZG8gbm90IGNvbXBhcmUgY2xpZW50RGF0YUpTT04gYWdhaW5zdCBhIHRlbXBsYXRlLiBTZWUgaHR0cHM6Ly9nb28uZ2wveWFiUGV4In0", | ||
> "challenge": "Sign this for me", | ||
> "alg": -7, | ||
> "pubkey": "3538dfd53ad93d2e0a6e7f470295dcd71057d825e1f87229e5afe2a906aa7cfc099fdfa04442dac33548b6988af8af58d2052529088f7b73ef00800f7fbcddb3", | ||
> "keyType": "main_key", | ||
> } | ||
> | ||
> pubkey = ECC.import_key( | ||
> bytes.fromhex("04" + response["pubkey"]), | ||
> curve_name="secp256r1", | ||
> ) | ||
> with open("pubkey.pem", "wt") as fout: | ||
> fout.write(pubkey.export_key(format="PEM")) | ||
> message_bin = b64decode(response["message"]) | ||
> authenticator_data = message_bin[:37] | ||
> client_data = message_bin[37:] | ||
> # https://github.com/duo-labs/py_webauthn/blob/master/webauthn/authentication/verify_authentication_response.py | ||
> message_to_sign = authenticator_data + SHA256.new(client_data).digest() | ||
> with open("message.bin", "wb") as fout: | ||
> fout.write(message_to_sign) | ||
> | ||
> signature_der = b64decode(response["signature"]) | ||
> with open("signature.der", "wb") as fout: | ||
> fout.write(signature_der) | ||
> signature = decode_signature(signature_der) | ||
> DSS.new(pubkey, "fips-186-3").verify(SHA256.new(message_to_sign), signature) | ||
> print("Verified OK") | ||
> ``` |