Search code examples
digital-signaturesmartcardapduyubicosecp256k1

Signing raw SHA512 digest on my Yubikey - Returns invalid signature


I'm in the process of building a tool to sign cryptocurrency transactions with my Yubikey. For that I need to sign arbitrary data using the SHA512 digest algo on the secp256k1 curve. I dug out the OpenPGP specification which the Yubikey implements along with the required APDUs that are needed to sign hashed data directly in the Yubikey. I get a signature, however it is flagged as invalid in most Javascript libraries and this online verification tool for secp256k1 keys and this one for ed25519 keys.

The APDUs I send to my Yubikey are:

  • OpenPGP APDU Command: 00A4040006D27600012401
  • Get Public Key APDU Command: 00478100000002B6000000
  • Pin APDU Command: 0020008106313233343536
  • Sign APDU Command: 002A9E9A409b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec04300

The OpenPGP APDU makes the Yubikey select the OpenPGP applet.

The GET PUBLIC KEY APDU means:

  • c=00: Default command class
  • i=47: Generate asymmetric key pair
  • p1=81: Reading of actual public key
  • p2=00
  • lc=000002
  • le=B600: Signature key
  • em=0000

Extracting the public key in is optional but useful for verification.

(Reference 7.2.14 GENERATE ASYMMETRIC KEY PAIR on page 74 in the OpenPGP Smart Card Spec)

The PIN APDU means:

  • c=00: Default command class
  • i=20: Instruction byte for VERIFY
  • p1=00
  • p2=81: Must be 81 for SIGN as specified in 7.2.10
  • lc=06: Pin length in bytes, hex encoded
  • data=313233343536: hex-encoded default pin 123456

Verifying the pin is a prerequisite for the following signing operation to work.

(Reference 7.2.2 VERIFY on page 52 in the OpenPGP Smart Card Spec)

The SIGN APDU means:

  • c=00: Default command class
  • i=2A: Instruction byte for PERFORM SECURITY OPERATION
  • p1=9E: 9E specifies that the operation is to compute a digital signature
  • p2=9A: 9A specifies that the data is a hash that needs to be signed
  • lc=40: hex for 64, so 512 bits for a SHA512
  • data=9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043: hex-encoded SHA512 of UTF8-encoded string hello used as digest to sign
  • le=00

(Reference 7.2.10 PSO: COMPUTE DIGITAL SIGNATURE on page 63 in the OpenPGP Smart Card Spec)

When I run this on my Yubikey, I get the following responses:

OpenPGP APDU Executed: 00a4040006d27600012401 -> Response: 9000: Success

GET PKEY APDU Executed: 00478100000002b6000000 -> Response: 7f494386410498a5ecbb2e3738c1021f980017a2f47314288e41ab1c435cfceef00a5e63276933ff480a6bd607f80729204c16c9d2d092a187767c2928008d146197f5fe43c39000

Deconstructed this means:

  • 7f49: Response success
  • 43: Length in bytes of remaining result. 43 in hex is 67 in decimal.
  • 86: Constant for ECDSA Signature, as the Signature key on this Yubikey is of type secp256k1
  • 41: Length of following key. 41 in hex is 65 in decimal
  • 04: First part of the public key, with 04 indicating an uncompressed public key
  • 98a5ecbb2e3738c1021f980017a2f47314288e41ab1c435cfceef00a5e63276933ff480a6bd607f80729204c16c9d2d092a187767c2928008d146197f5fe43c3: The 64 byte public key containing r and s (both 32 bytes) according to my tests
  • 9000 Success status word

PIN APDU Executed: 0020008106313233343536 -> Response: 9000: Success

SIGN APDU Executed: 002a9e9a409b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec04300 -> Response: af679c9010968004f5b3b6dc23795a743676dee39c4f214426f43b703a244448129ce9b72177bc6647ae2cb9caa0af7d26336a4daa8e67fad253b1b8c02a3f829000

Deconstructed this means:

  • af679c9010968004f5b3b6dc23795a743676dee39c4f214426f43b703a244448129ce9b72177bc6647ae2cb9caa0af7d26336a4daa8e67fad253b1b8c02a3f82: The 64 byte digital signature where I can't find any specification of the encoding
  • 9000 Success status word

So all together we have:

  • Raw UTF8 to sign: 68656c6c6f (=hello)
  • SHA512 digest to sign: 9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043
  • Public key: 0498a5ecbb2e3738c1021f980017a2f47314288e41ab1c435cfceef00a5e63276933ff480a6bd607f80729204c16c9d2d092a187767c2928008d146197f5fe43c3 (with out without 04 prefix)
  • Signature: af679c9010968004f5b3b6dc23795a743676dee39c4f214426f43b703a244448129ce9b72177bc6647ae2cb9caa0af7d26336a4daa8e67fad253b1b8c02a3f82

But for some reason, most verifier do not accept this signature as valid. I tried with both secp256k1 type sig keys and ed25519 type signature keys.

Where did I go wrong here?

Links for online verification:

I also checked this using the scdaemon log file while using gpg --sign --digest-algo SHA512, where gpg sends the following APDU to sign a 64 byte SHA512 digest:

DBG: send apdu: c=00 i=20 p1=00 p2=81 lc=6 le=-1 em=0
DBG: PCSC_data: 00 20 00 81 06 [hidden]
DBG:  response: sw=9000  datalen=0
DBG: chan_0x00000324 -> S PINCACHE_PUT 0/openpgp/1 3ED853D574CD8F2CF0B5E1EF602E296AF823642870AFD078
DBG: send apdu: c=00 i=2A p1=9E p2=9A lc=64 le=256 em=0
DBG: PCSC_data: 002a9e9a409fb0ff1714c8c6cc7e01399c2d95bde1d60e76d738bc340c9f67f0 \
DBG:  62bf82ef51a44cd17e9ddb0cce9560e3efba0c2d17844a4f7f69d22f8c0fbedb \
DBG:  e39adf434500
DBG:  response: sw=9000  datalen=64
DBG:      dump: 6a1a138e709c5615547281ac3236b35f6dbf952c0b1f4e8e47b07074acf18bed \
DBG:  c742874929e8152b6578bb53b7c040d7e5efb63d87aee5daf9d6d8645e4c7acc
operation sign result: Success

Then in the gpg --list-packets --verbose of the generated PGP message, that generated signature can be found in the data fields with digest 9f b0 matching the PCSC_data as well:

# off=0 ctb=a3 tag=8 hlen=1 plen=0 indeterminate
:compressed packet: algo=1
# off=2 ctb=90 tag=4 hlen=2 plen=13
:onepass_sig packet: keyid 9B70B27E1322DFEE
        version 3, sigclass 0x00, digest 10, pubkey 19, last=1
# off=17 ctb=cb tag=11 hlen=2 plen=19 new-ctb
:literal data packet:
        mode b (62), created 1725290603, name="",
        raw data: 13 bytes
# off=38 ctb=88 tag=2 hlen=2 plen=117
:signature packet: algo 19, keyid 9B70B27E1322DFEE
        version 4, created 1725290603, md5len 0, sigclass 0x00
        digest algo 10, begin of digest 9f b0
        hashed subpkt 33 len 21 (issuer fpr v4 79825D85C8A09191DB3C53DC9B70B27E1322DFEE)
        hashed subpkt 2 len 4 (sig created 2024-09-02)
        subpkt 16 len 8 (issuer key ID 9B70B27E1322DFEE)
        data: 6A1A138E709C5615547281AC3236B35F6DBF952C0B1F4E8E47B07074ACF18BED
        data: C742874929E8152B6578BB53B7C040D7E5EFB63D87AEE5DAF9D6D8645E4C7ACC

If even GPG does it this way, how is the generated signature invalid for other verifiers? The only verifier that indicates a valid signature is the noble-secp256k1 Javascript that says about half of the generated signatures are valid and I don't see why.


Solution

  • Verifying ECDSA signatures is tricky, as there are different encodings for keys and signatures. The online tools you are referring to apparently uses signatures wrapped in an ASN1 struture. See rfc5480.

    Your PGP applet is however returning the raw signature (64 bytes) af679c9010968004f5b3b6dc23795a743676dee39c4f214426f43b703a244448129ce9b72177bc6647ae2cb9caa0af7d26336a4daa8e67fad253b1b8c02a3f82

    Your signature will validate using that tool if you convert the signature to ASN1: (3045022100af679c9010968004f5b3b6dc23795a743676dee39c4f214426f43b703a2444480220129ce9b72177bc6647ae2cb9caa0af7d26336a4daa8e67fad253b1b8c02a3f82)

    See also this discussion.