<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>~iany/ Webauthn</title><link>https://blog.iany.me/tags/webauthn/</link><description>Recent content in Webauthn «~iany/»</description><language>en-US</language><managingEditor>me@iany.me (Ian Yang)</managingEditor><webMaster>me@iany.me (Ian Yang)</webMaster><copyright>CC-BY-SA 4.0</copyright><lastBuildDate>Sun, 17 Dec 2023 17:35:33 +0800</lastBuildDate><atom:link href="https://blog.iany.me/tags/webauthn/index.xml" rel="self" type="application/rss+xml"/><item><title>How to Verify JoyID WebAuthn Signature</title><link>https://blog.iany.me/2023/12/how-to-verify-joyid-webauthn-signature/</link><pubDate>Sun, 17 Dec 2023 17:35:33 +0800</pubDate><author>me@iany.me (Ian Yang)</author><guid>https://blog.iany.me/2023/12/how-to-verify-joyid-webauthn-signature/</guid><description>&lt;p&gt;&lt;a href="https://docs.joy.id/guide"&gt;JoyID&lt;/a&gt; is a multichain, cross-platform, passwordless and mnemonic-free wallet solution based on FIDO WebAuthn protocol and Nervos CKB.&lt;/p&gt;
&lt;p&gt;This post shows how to verify the signature from the method &lt;a href="https://docs.joy.id/guide/ckb/sign-message"&gt;signChallenge&lt;/a&gt; of the &lt;code&gt;@joyid/ckb&lt;/code&gt; 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 &lt;a href="https://pycryptodome.readthedocs.io/en/latest/src/introduction.html"&gt;PyCryptodome&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The JoyID follows the WebAuthn specification and employs secp256r1 for signing. Although the guide references &lt;a href="https://www.w3.org/TR/webauthn-2/#sctn-op-get-assertion"&gt;section 6.3.3&lt;/a&gt; of the WebAuthn specification, titled &amp;ldquo;The authenticatorGetAssertion Operation&amp;rdquo;, I discovered that the example in &lt;a href="https://github.com/duo-labs/py_webauthn/blob/master/webauthn/authentication/verify_authentication_response.py"&gt;this repository&lt;/a&gt; provided me much more helps.&lt;/p&gt;
&lt;h2 id="the-response-parsing"&gt;The Response Parsing&lt;/h2&gt;
&lt;p&gt;This is the example I obtained from the demo.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
&amp;quot;signature&amp;quot;: &amp;quot;MEUCICF25qdO6nLreEoBHnyaw-9R6XFHbIu-NwsAI53t016qAiEAgmhlwTEMxoWxKj79R1rUkB_6nrhJfws82DqHkY_HnqQ&amp;quot;,
&amp;quot;message&amp;quot;: &amp;quot;K4sF4fAwPvuJj-TW3mARmMenuGSrvmohxzsueH4YfFIFAAAAAHsidHlwZSI6IndlYmF1dGhuLmdldCIsImNoYWxsZW5nZSI6IlUybG5iaUIwYUdseklHWnZjaUJ0WlEiLCJvcmlnaW4iOiJodHRwczovL3Rlc3RuZXQuam95aWQuZGV2IiwiY3Jvc3NPcmlnaW4iOmZhbHNlLCJvdGhlcl9rZXlzX2Nhbl9iZV9hZGRlZF9oZXJlIjoiZG8gbm90IGNvbXBhcmUgY2xpZW50RGF0YUpTT04gYWdhaW5zdCBhIHRlbXBsYXRlLiBTZWUgaHR0cHM6Ly9nb28uZ2wveWFiUGV4In0&amp;quot;,
&amp;quot;challenge&amp;quot;: &amp;quot;Sign this for me&amp;quot;,
&amp;quot;alg&amp;quot;: -7,
&amp;quot;pubkey&amp;quot;: &amp;quot;3538dfd53ad93d2e0a6e7f470295dcd71057d825e1f87229e5afe2a906aa7cfc099fdfa04442dac33548b6988af8af58d2052529088f7b73ef00800f7fbcddb3&amp;quot;,
&amp;quot;keyType&amp;quot;: &amp;quot;main_key&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="pubkey"&gt;pubkey&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;pubkey&lt;/code&gt; field represents the uncompressed public key concatenating two 32-byte integers in hex. PyCryptodome can import the key by prepending the flag &lt;code&gt;0x04&lt;/code&gt;. OpenSSL uses PEM to encode keys, and PyCryptodome can help here to export the key in PEM format.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-python"&gt;from Crypto.PublicKey import ECC
pubkey_raw_hex = &amp;quot;3538dfd53ad93d2e0a6e7f470295dcd71057d825e1f87229e5afe2a906aa7cfc099fdfa04442dac33548b6988af8af58d2052529088f7b73ef00800f7fbcddb3&amp;quot;
pubkey = ECC.import_key(bytes.fromhex(&amp;quot;04&amp;quot; + pubkey_raw_hex), curve_name=&amp;quot;secp256r1&amp;quot;)
with open(&amp;quot;pubkey.pem&amp;quot;, &amp;quot;wt&amp;quot;) as pemfile:
pemfile.write(pubkey.export_key(format=&amp;quot;PEM&amp;quot;))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Double check the key using OpenSSL:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-shell-session"&gt;$ 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
...
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="message"&gt;message&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;message&lt;/code&gt; is a binary encoded by base64 &lt;a href="https://datatracker.ietf.org/doc/html/rfc4648#section-5"&gt;RFC 4648 §5&lt;/a&gt; without the equal sign (&lt;code&gt;=&lt;/code&gt;) paddings. Many base64 tools and libraries require padding equal sign (&lt;code&gt;=&lt;/code&gt;) in the end of the string to make the length multiple of 4. The &lt;code&gt;message&lt;/code&gt; in the example response has a length 351, which requires one &lt;code&gt;=&lt;/code&gt; padding. A trick is always padding two equals at the end of the string before decoding.&lt;/p&gt;
&lt;p&gt;The first 37 bytes in &lt;code&gt;message&lt;/code&gt; are authenticator data, and the following bytes are client data in JSON.&lt;/p&gt;
&lt;p&gt;The section &lt;a href="https://www.w3.org/TR/webauthn-2/#sctn-authenticator-data"&gt;section 6.1&lt;/a&gt; in the WebAuthn specification defines the layout of the authenticator data.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;rpIdHash&lt;/code&gt;, 32 bytes: the sha256 checksum of the text &lt;code&gt;testnet.joyid.dev&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;flags&lt;/code&gt;, 1 byte: &lt;code&gt;0x05&lt;/code&gt; in JoyID&lt;/li&gt;
&lt;li&gt;&lt;code&gt;signCount&lt;/code&gt;, 4 bytes: all zeros&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class="language-shell"&gt;base64 -d &amp;lt;&amp;lt;&amp;lt;'
K4sF4fAwPvuJj-TW3mARmMenuGSrvmohxzsueH4YfFIFAAAAAHsidHlwZSI6Indl
YmF1dGhuLmdldCIsImNoYWxsZW5nZSI6IlUybG5iaUIwYUdseklHWnZjaUJ0WlEi
LCJvcmlnaW4iOiJodHRwczovL3Rlc3RuZXQuam95aWQuZGV2IiwiY3Jvc3NPcmln
aW4iOmZhbHNlLCJvdGhlcl9rZXlzX2Nhbl9iZV9hZGRlZF9oZXJlIjoiZG8gbm90
IGNvbXBhcmUgY2xpZW50RGF0YUpTT04gYWdhaW5zdCBhIHRlbXBsYXRlLiBTZWUg
aHR0cHM6Ly9nb28uZ2wveWFiUGV4In0=' |
dd bs=1 count=37 2&amp;gt;/dev/null |
xxd
#=&amp;gt; 00000000: 2b8b 05e1 f030 3efb 898f e4d6 de60 1198
#=&amp;gt; 00000010: c7a7 b864 abbe 6a21 c73b 2e78 7e18 7c52
#=&amp;gt; 00000020: 0500 0000 00
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Check the first two lines with the sha256 checksum:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-shell"&gt;echo -n 'testnet.joyid.dev' | sha256sum
#=&amp;gt; 2b8b05e1f0303efb898fe4d6de601198c7a7b864abbe6a21c73b2e787e187c52 -
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The client data JSON looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-shell"&gt;base64 -d &amp;lt;&amp;lt;&amp;lt;'
K4sF4fAwPvuJj-TW3mARmMenuGSrvmohxzsueH4YfFIFAAAAAHsidHlwZSI6Indl
YmF1dGhuLmdldCIsImNoYWxsZW5nZSI6IlUybG5iaUIwYUdseklHWnZjaUJ0WlEi
LCJvcmlnaW4iOiJodHRwczovL3Rlc3RuZXQuam95aWQuZGV2IiwiY3Jvc3NPcmln
aW4iOmZhbHNlLCJvdGhlcl9rZXlzX2Nhbl9iZV9hZGRlZF9oZXJlIjoiZG8gbm90
IGNvbXBhcmUgY2xpZW50RGF0YUpTT04gYWdhaW5zdCBhIHRlbXBsYXRlLiBTZWUg
aHR0cHM6Ly9nb28uZ2wveWFiUGV4In0=' |
dd bs=1 skip=37 2&amp;gt;/dev/null |
jq
{
&amp;quot;type&amp;quot;: &amp;quot;webauthn.get&amp;quot;,
&amp;quot;challenge&amp;quot;: &amp;quot;U2lnbiB0aGlzIGZvciBtZQ&amp;quot;,
&amp;quot;origin&amp;quot;: &amp;quot;https://testnet.joyid.dev&amp;quot;,
&amp;quot;crossOrigin&amp;quot;: false,
...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice the &lt;code&gt;challenge&lt;/code&gt; field. It is the parameter passed to &lt;code&gt;signChallenge&lt;/code&gt;, in base64.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-shell"&gt;base64 -d &amp;lt;&amp;lt;&amp;lt;'U2lnbiB0aGlzIGZvciBtZQ=='
#=&amp;gt; Sign this for me
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Attention that message is not the binary to be signed. According to the Figure 4, Generating an assertion signature, in &lt;a href="https://www.w3.org/TR/webauthn-2/#sctn-authenticator-data"&gt;the WebAuthn specification&lt;/a&gt;, the binary to be signed is a concatenation of the authenticator data and the sha256 checksum of the client data JSON.&lt;/p&gt;
&lt;p&gt;The following code shows how to prepare the message to sign and save it into the file &lt;code&gt;message.bin&lt;/code&gt;. Attention that base64 must use the alternative keys &lt;code&gt;-&lt;/code&gt; and &lt;code&gt;_&lt;/code&gt; to replace &lt;code&gt;+&lt;/code&gt; and &lt;code&gt;/&lt;/code&gt; respectively.&lt;/p&gt;
&lt;details open disabled class="kg-card kg-callout kg-callout-attention" data-callout-type="attention"&gt;
&lt;summary class="kg-callout-title" tabindex="-1"&gt;
&lt;i aria-hidden="true" class="kg-callout-type fas fa-exclamation-triangle"&gt;&lt;/i&gt;
Attention
&lt;/summary&gt;
&lt;div class="kg-callout-content"&gt;
To decode base64 &amp;ldquo;RFC 4648 §5&amp;rdquo; in python, use either &lt;code&gt;base64.b64decode(s, altchars=&amp;quot;-_&amp;quot;)&lt;/code&gt; or &lt;code&gt;binascii.urlsafe_b64decode(s)&lt;/code&gt;.
&lt;/div&gt;
&lt;/details&gt;
&lt;pre&gt;&lt;code class="language-python"&gt;import base64
from Crypto.Hash import SHA256
message_bin = base64.urlsafe_b64decode(
&amp;quot;K4sF4fAwPvuJj-TW3mARmMenuGSrvmohxzsueH4YfFIFAAAAAHsidHlwZSI6Indl&amp;quot;
&amp;quot;YmF1dGhuLmdldCIsImNoYWxsZW5nZSI6IlUybG5iaUIwYUdseklHWnZjaUJ0WlEi&amp;quot;
&amp;quot;LCJvcmlnaW4iOiJodHRwczovL3Rlc3RuZXQuam95aWQuZGV2IiwiY3Jvc3NPcmln&amp;quot;
&amp;quot;aW4iOmZhbHNlLCJvdGhlcl9rZXlzX2Nhbl9iZV9hZGRlZF9oZXJlIjoiZG8gbm90&amp;quot;
&amp;quot;IGNvbXBhcmUgY2xpZW50RGF0YUpTT04gYWdhaW5zdCBhIHRlbXBsYXRlLiBTZWUg&amp;quot;
&amp;quot;aHR0cHM6Ly9nb28uZ2wveWFiUGV4In0==&amp;quot;,
)
authenticator_data = message_bin[:37]
client_data = message_bin[37:]
message_to_sign = authenticator_data + SHA256.new(client_data).digest()
with open(&amp;quot;message.bin&amp;quot;, &amp;quot;wb&amp;quot;) as fout:
fout.write(message_to_sign)
&lt;/code&gt;&lt;/pre&gt;
&lt;details open disabled class="kg-card kg-callout kg-callout-attention" data-callout-type="attention"&gt;
&lt;summary class="kg-callout-title" tabindex="-1"&gt;
&lt;i aria-hidden="true" class="kg-callout-type fas fa-exclamation-triangle"&gt;&lt;/i&gt;
Attention
&lt;/summary&gt;
&lt;div class="kg-callout-content"&gt;
The &lt;code&gt;message&lt;/code&gt; 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.
&lt;/div&gt;
&lt;/details&gt;
&lt;h3 id="signature"&gt;signature&lt;/h3&gt;
&lt;p&gt;The field signature are two 32-byte integers first encoded in &lt;a href="https://wiki.openssl.org/index.php/DER"&gt;DER&lt;/a&gt;, then base64 &lt;a href="https://datatracker.ietf.org/doc/html/rfc4648#section-5"&gt;RFC 4648 §5&lt;/a&gt; without the equal sign (&lt;code&gt;=&lt;/code&gt;) paddings.&lt;/p&gt;
&lt;p&gt;Many base64 tools and libraries require padding equal sign (&lt;code&gt;=&lt;/code&gt;) 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 &lt;code&gt;=&lt;/code&gt; padding.&lt;/p&gt;
&lt;p&gt;OpenSSL also stores signature in DER, let&amp;rsquo;s save one in the file &lt;code&gt;signature.der&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;base64 -d &amp;lt;&amp;lt;&amp;lt;&amp;quot;MEUCICF25qdO6nLreEoBHnyaw-9R6XFHbIu-NwsAI53t016qAiEAgmhlwTEMxoWxKj79R1rUkB_6nrhJfws82DqHkY_HnqQ=&amp;quot; &amp;gt; signature.der
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The command &lt;code&gt;openssl asn1parse&lt;/code&gt; can parse the file &lt;code&gt;signature.der&lt;/code&gt; in the DER format.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;openssl asn1parse -dump -inform DER -in signature.der
# Output =&amp;gt;
# 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
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PyCryptodome expects the signature of 64 bytes for two 32-byte integers. Following code uses the &lt;code&gt;asn1&lt;/code&gt; module to extract the raw signature from the DER binary.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-python"&gt;import base64
from Crypto.Util.asn1 import DerSequence
signature_der = base64.urlsafe_b64decode(
&amp;quot;MEUCICF25qdO6nLreEoBHnyaw-9R6XFHbIu-NwsAI53t016qAiEAgmhlwTEMxoWx&amp;quot;
&amp;quot;Kj79R1rUkB_6nrhJfws82DqHkY_HnqQ=&amp;quot;,
)
signature_seq = DerSequence()
signature_seq.decode(signature_der)
print(signature_seq[0].to_bytes(32).hex())
# =&amp;gt; 2176e6a74eea72eb784a011e7c9ac3ef51e971476c8bbe370b00239dedd35eaa
print(signature_seq[1].to_bytes(32).hex())
# =&amp;gt; 826865c1310cc685b12a3efd475ad4901ffa9eb8497f0b3cd83a87918fc79ea4
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="verifying"&gt;Verifying&lt;/h2&gt;
&lt;p&gt;PyCryptodome:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-python"&gt;from Crypto.Hash import SHA256
from Crypto.Signature import DSS
DSS.new(pubkey, &amp;quot;fips-186-3&amp;quot;).verify(SHA256.new(message_to_sign), signature)
print(&amp;quot;Verified OK&amp;quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;OpenSSL:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-shell"&gt;openssl dgst -sha256 -verify pubkey.pem -signature signature.der message.bin
&lt;/code&gt;&lt;/pre&gt;
&lt;details class="kg-card kg-callout kg-callout-code" data-callout-type="code"&gt;
&lt;summary class="kg-callout-title"&gt;
&lt;i class="fas fa-chevron-right"&gt;&lt;/i&gt;
&lt;i aria-hidden="true" class="kg-callout-type fas fa-code"&gt;&lt;/i&gt;
Full Python code (&lt;a href="https://gist.github.com/doitian/b1f5c60203e9dbaffccff7d0920d9529"&gt;Gist&lt;/a&gt;)
&lt;/summary&gt;
&lt;div class="kg-callout-content"&gt;
&lt;pre&gt;&lt;code class="language-python"&gt;import base64
from Crypto.Hash import SHA256
from Crypto.PublicKey import ECC
from Crypto.Signature import DSS
from Crypto.Util.asn1 import DerSequence
response = {
&amp;quot;signature&amp;quot;: &amp;quot;MEUCICF25qdO6nLreEoBHnyaw-9R6XFHbIu-NwsAI53t016qAiEAgmhlwTEMxoWx&amp;quot;
&amp;quot;Kj79R1rUkB_6nrhJfws82DqHkY_HnqQ&amp;quot;,
&amp;quot;message&amp;quot;: &amp;quot;K4sF4fAwPvuJj-TW3mARmMenuGSrvmohxzsueH4YfFIFAAAAAHsidHlwZSI6IndlYmF1dGhuLmdldCIsImNoYWxsZW5nZSI6IlUybG5iaUIwYUdseklHWnZjaUJ0WlEiLCJvcmlnaW4iOiJodHRwczovL3Rlc3RuZXQuam95aWQuZGV2IiwiY3Jvc3NPcmlnaW4iOmZhbHNlLCJvdGhlcl9rZXlzX2Nhbl9iZV9hZGRlZF9oZXJlIjoiZG8gbm90IGNvbXBhcmUgY2xpZW50RGF0YUpTT04gYWdhaW5zdCBhIHRlbXBsYXRlLiBTZWUgaHR0cHM6Ly9nb28uZ2wveWFiUGV4In0&amp;quot;,
&amp;quot;challenge&amp;quot;: &amp;quot;Sign this for me&amp;quot;,
&amp;quot;alg&amp;quot;: -7,
&amp;quot;pubkey&amp;quot;: &amp;quot;3538dfd53ad93d2e0a6e7f470295dcd71057d825e1f87229e5afe2a906aa7cfc099fdfa04442dac33548b6988af8af58d2052529088f7b73ef00800f7fbcddb3&amp;quot;,
&amp;quot;keyType&amp;quot;: &amp;quot;main_key&amp;quot;,
}
pubkey = ECC.import_key(
bytes.fromhex(&amp;quot;04&amp;quot; + response[&amp;quot;pubkey&amp;quot;]),
curve_name=&amp;quot;secp256r1&amp;quot;,
)
with open(&amp;quot;pubkey.pem&amp;quot;, &amp;quot;wt&amp;quot;) as fout:
fout.write(pubkey.export_key(format=&amp;quot;PEM&amp;quot;))
message_bin = base64.urlsafe_b64decode(response[&amp;quot;message&amp;quot;] + &amp;quot;==&amp;quot;)
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(&amp;quot;message.bin&amp;quot;, &amp;quot;wb&amp;quot;) as fout:
fout.write(message_to_sign)
signature_der = base64.urlsafe_b64decode(response[&amp;quot;signature&amp;quot;] + &amp;quot;==&amp;quot;)
with open(&amp;quot;signature.der&amp;quot;, &amp;quot;wb&amp;quot;) as fout:
fout.write(signature_der)
signature_seq = DerSequence()
signature_seq.decode(signature_der)
signature = signature_seq[0].to_bytes(32) + signature_seq[1].to_bytes(32)
DSS.new(pubkey, &amp;quot;fips-186-3&amp;quot;).verify(SHA256.new(message_to_sign), signature)
print(&amp;quot;Verified OK&amp;quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/details&gt;</description><category domain="https://blog.iany.me/post/">Posts</category><category domain="https://blog.iany.me/tags/cryptography/">Cryptography</category><category domain="https://blog.iany.me/tags/javascript/">JavaScript</category><category domain="https://blog.iany.me/tags/webauthn/">Webauthn</category></item></channel></rss>