Yubikey Wallet is a cross-chain compatible extension to the Stargazer Wallet that integrates Yubikey as a hardware wallet for Constellation Network. The GPG signature key (secp256k1
) stored on the YubiKey is used to extract a DAG-Address and to sign messages and transactions.
The project is organized into several workspaces:
yubikey-bridge
: Native messaging host to grant Stargazer Wallet access to a Yubikey. It runs on the host machine and is called by the browser from within the Stargazer Wallet extension. It callsgpg
to extract the public key stored on the YubiKey and to sign messages with the private key stored on the YubiKey.yubikey-bridge-installer
: Installer for the Yubikey Wallet Native Messaging Host. It builds the Yubikey Bridge executable and installs it on the host machine. It also registers it as a native messaging host with the Google Chrome browser.stargazer-wallet-ext
: A fork of the Stargazer Wallet browser extension that integrates the Yubikey Wallet as a hardware wallet.
Ensure you have the following installed:
To install dependencies, run yarn install
from both the root of the repository and the stargazer-wallet-ext
directory.
- Build the extension:
cd stargazer-wallet-ext && yarn build:chrome
- Go to
chrome://extensions
in the browser. - Enable
Developer Mode
. - Click on
Load Unpacked Extension…
. - Select the extension's extracted directory:
stargazer-wallet-ext\source\web\extension\chrome
- Copy the Chrome Extension ID from the
chrome://extensions
page. - Adjust
chromeExtensionId
inyubikey-bridge-installer/install.js
to the copied ID. - Run
yarn yubikey-bridge:build
to build the native messaging host executable called by the Browser Extension. - Run
yarn yubikey-bridge:install
. It will:- Install the
yubikey-bridge
executable in your user directory~/YubikeyWallet
- Register the
yubikey-bridge
executable as a native messaging host with the Google Chrome browser
- Install the
The current implementation expects yubikey-apdu> node yubikey-apdu.js
to be running in the background.
The Yubikey Bridge expects gpg
to be an available command on your system. It uses gpg
to extract the public key stored on the YubiKey and to sign messages with the private key stored on the YubiKey.
Installation instructions for GPG can be found here.
The smard card service must be running.
When you buy a new Yubikey, plug it into your computer and run gpg --card-status
, it looks like this:
> gpg --card-status
Reader ...........: Yubico YubiKey OTP FIDO CCID 0
Application ID ...: D2760001240100000006312547600000
Application type .: OpenPGP
Version ..........: 3.4
Manufacturer .....: Yubico
Serial number ....: 31254760
Name of cardholder: [not set]
Language prefs ...: [not set]
Salutation .......:
URL of public key : [not set]
Login data .......: [not set]
Signature PIN ....: not forced
Key attributes ...: rsa2048 rsa2048 rsa2048
Max. PIN lengths .: 127 127 127
PIN retry counter : 3 0 3
Signature counter : 0
KDF setting ......: off
UIF setting ......: Sign=off Decrypt=off Auth=off
Signature key ....: [none]
Encryption key....: [none]
Authentication key: [none]
General key info..: [none]
To initialize the Yubikey as a Constellation Network wallet, you need to generate a new key pair.
secp256k1
type keys which Constellation uses for message and transaction signing. And even if, there would be no option to back up the key, so it would be lost if the pin is entered incorrectly three consecutive times or when the Yubikey is lost.
So lets generate the keypair on the host machine and then copy it onto the Yubikey. It's generally a good idea to do this on an air-gapped machine, although a passphrase will protect the key on the host machine. Remeber the --expert
flag to see keys of type secp256k1
:
> gpg --expert --full-generate-key
gpg (GnuPG) 2.4.5; Copyright (C) 2024 g10 Code GmbH
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Please select what kind of key you want:
(1) RSA and RSA
(2) DSA and Elgamal
(3) DSA (sign only)
(4) RSA (sign only)
(7) DSA (set your own capabilities)
(8) RSA (set your own capabilities)
(9) ECC (sign and encrypt) *default*
(10) ECC (sign only)
(11) ECC (set your own capabilities)
(13) Existing key
(14) Existing key from card
Your selection? 9
Please select which elliptic curve you want:
(1) Curve 25519 *default*
(2) Curve 448
(3) NIST P-256
(4) NIST P-384
(5) NIST P-521
(6) Brainpool P-256
(7) Brainpool P-384
(8) Brainpool P-512
(9) secp256k1
Your selection? 9
Please specify how long the key should be valid.
0 = key does not expire
<n> = key expires in n days
<n>w = key expires in n weeks
<n>m = key expires in n months
<n>y = key expires in n years
Key is valid for? (0) 0
Key does not expire at all
Is this correct? (y/N) y
GnuPG needs to construct a user ID to identify your key.
Real name: Bruno
Email address: [email protected]
Comment: Constellation Wallet
You selected this USER-ID:
"Bruno (Constellation Wallet) <[email protected]>"
Change (N)ame, (C)omment, (E)mail or (O)kay/(Q)uit?
Type o
to confirm and enter a passphrase that will protect the key material at rest on the host machine. They key will be generated and store it in your GPG key ring. It'll also print the fingerprint, which you'll need in the next steps:
pub secp256k1 2024-08-26 [SC]
F977C90DA4077068CAAD8B299502515330CB5D0F
uid Bruno (Constellation Wallet) <[email protected]>
sub secp256k1 2024-08-26 [E]
ℹ️ The fingerprint F977C90DA4077068CAAD8B299502515330CB5D0F
uniquely identifies your GPG key. It is derived from the public key but is not the public key itself (it's the SHA-1 hash of the public key). To extract the raw public key from the GPG card, you can use gpg --export [--armor] [fingerprint]
. You don't need to do that manually as the Yubikey Wallet Bridge will take care of that.
Now there is an opportunity to back up the private key so it can be recovered in case the Yubikey is lost or reset. For that, run gpg --armor --export-secret-keys F977C90DA4077068CAAD8B299502515330CB5D0F > private-key.asc
. If you don't do that, the private key will be fully removed from the host machine in the next step.
To copy the key to the Yubikey, run gpg --edit-key F977C90DA4077068CAAD8B299502515330CB5D0F
with your keys fingerprint. Within gpg
, type keytocard
, copy it as Authentication key (1
), and then save
:
> gpg --edit-key F977C90DA4077068CAAD8B299502515330CB5D0F
gpg (GnuPG) 2.4.5; Copyright (C) 2024 g10 Code GmbH
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Secret key is available.
sec secp256k1/9502515330CB5D0F
created: 2024-08-26 expires: never usage: SC
trust: ultimate validity: ultimate
ssb secp256k1/4B140C97C6AD3322
created: 2024-08-26 expires: never usage: E
[ultimate] (1). Bruno (Constellation Wallet) <[email protected]>
gpg> keytocard
Really move the primary key? (y/N) y
Please select where to store the key:
(1) Signature key
(3) Authentication key
Your selection? 1
sec secp256k1/9502515330CB5D0F
created: 2024-08-26 expires: never usage: SC
trust: ultimate validity: ultimate
ssb secp256k1/4B140C97C6AD3322
created: 2024-08-26 expires: never usage: E
[ultimate] (1). Bruno (Constellation Wallet) <[email protected]>
Note: the local copy of the secret key will only be deleted with "save".
gpg> save
Now the card status looks like this:
C:\Repos> gpg --card-status
Reader ...........: Yubico YubiKey OTP FIDO CCID 0
Application ID ...: D2760001240100000006312547600000
Application type .: OpenPGP
Version ..........: 3.4
Manufacturer .....: Yubico
Serial number ....: 31254760
Name of cardholder: [not set]
Language prefs ...: [not set]
Salutation .......:
URL of public key : [not set]
Login data .......: [not set]
Signature PIN ....: not forced
Key attributes ...: secp256k1 rsa2048 rsa2048
Max. PIN lengths .: 127 127 127
PIN retry counter : 3 0 3
Signature counter : 0
KDF setting ......: off
UIF setting ......: Sign=off Decrypt=off Auth=off
Signature key ....: F977 C90D A407 7068 CAAD 8B29 9502 5153 30CB 5D0F
created ....: 2024-08-26 17:45:16
Encryption key....: [none]
Authentication key: [none]
General key info..: pub secp256k1/9502515330CB5D0F 2024-08-26 Bruno (Constellation Wallet) <[email protected]>
sec> secp256k1/9502515330CB5D0F created: 2024-08-26 expires: never
card-no: 0006 31254760
ssb secp256k1/4B140C97C6AD3322 created: 2024-08-26 expires: never
By default, a Yubikey signature only requires entering the PIN code on every signature request. The default user pin is 123456, which should be personalized using ``.
To further improve security, configure your Yubikeys touch policy to require a touch for signatures. This is turned off by default (see UIF setting: Sign=off
) but can be changed using ykman
(see install instructions):
> ykman openpgp info
OpenPGP version: 3.4
Application version: 5.7.1
PIN tries remaining: 3
Reset code tries remaining: 0
Admin PIN tries remaining: 3
Require PIN for signature: Once
KDF enabled: False
Touch policies:
Signature key: Off
Encryption key: Off
Authentication key: Off
Attestation key: Off
To change the signature touch policy, run:
> ykman openpgp keys set-touch SIG On
Enter Admin PIN: ********
Set touch policy of SIG key to on? [y/N]: y
Touch policy for slot SIG set.
Now after entering the pin somebody needs to physically touch the Yubikey for the signature to be generated. This protects the key from a leaked PIN code. For more details, see ykman openpgp keys set-touch -h
.
ℹ️ The Yubikey touch policy can also be changed using the Yubikey Manager GUI.
Stargazer prepares a transaction with hash: 82dc7a455095b3a67c484693249499f6790251833185b8c41cf44f9addb65e31
{
"value": {
"source": "DAG53VFwtir9K3WfeCLU7EVsmhJGYZtwf9YJJE1J",
"destination": "DAG08TSeWZhT9GwemE5ioxRZZzLgYTVHVTp3r9g2",
"amount": 100000000,
"fee": 0,
"parent": {
"ordinal": 0,
"hash": "0000000000000000000000000000000000000000000000000000000000000000"
},
"salt": "8824239026179262"
},
"proofs": []
}
The Stargazer Wallet extension will use the browsers native messaging capabilities to call the locally installed yubikey-bridge
with this request:
{
"command": "signHash",
"expectedUncompressedPublicKey": "0498a5ecbb2e3738c1021f980017a2f47314288e41ab1c435cfceef00a5e63276933ff480a6bd607f80729204c16c9d2d092a187767c2928008d146197f5fe43c3",
"sha512DigestToSign": "f7564babead48166c9444b7ea38195074da660e64a57853954adfe56c3449de97dc356559337d1c2855e52cd647d913fb11fe9c81ec2fb5bee94bf9f13082966",
"yubikeyPin": "123456"
}
The bridge then calls gpg --card-status
to do some preliminary checks, e.g. chcking that the signature key exists and is of type secp256k1
.
It then proceeds to execute raw APDU commands to sign the message:
First, the OpenPGP APDU 00A4040006D27600012401
makes the Yubikey select the OpenPGP applet. The typical response is 9000
: Success
Second, the GET PUBLIC KEY APDU is executed:
c=00
: Default command classi=47
: Generate asymmetric key pairp1=81
: Reading of actual public keyp2=00
lc=000002
le=B600
: Signature keyem=0000
Extracting the public key in is optional but useful for verification. A possible response might look like: 7f494386410498a5ecbb2e3738c1021f980017a2f47314288e41ab1c435cfceef00a5e63276933ff480a6bd607f80729204c16c9d2d092a187767c2928008d146197f5fe43c39000
Deconstructed this means:
7f49
: Response success43
: 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 typesecp256k1
41
: Length of following key.41
in hex is 65 in decimal04
: First part of the public key, with04
indicating an uncompressed public key98a5ecbb2e3738c1021f980017a2f47314288e41ab1c435cfceef00a5e63276933ff480a6bd607f80729204c16c9d2d092a187767c2928008d146197f5fe43c3
: The 64 byte public key containingr
ands
(both 32 bytes) according to my tests9000
Success status word
(Reference 7.2.14 GENERATE ASYMMETRIC KEY PAIR on page 74 in the OpenPGP Smart Card Spec)
Then the PIN APDU:
c=00
: Default command classi=20
: Instruction byte for VERIFYp1=00
p2=81
: Must be 81 for SIGN as specified in 7.2.10lc=06
: Pin length in bytes, hex encodeddata=313233343536
: hex-encoded default pin123456
Verifying the pin is a prerequisite for the following signing operation to work. Normally just 9000
: Success is returned.
(Reference 7.2.2 VERIFY on page 52 in the OpenPGP Smart Card Spec)
Finally, the SIGN APDU:
c=00
: Default command classi=2A
: Instruction byte for PERFORM SECURITY OPERATIONp1=9E
: 9E specifies that the operation is to compute a digital signaturep2=9A
: 9A specifies that the data is a hash that needs to be signedlc=40
: hex for 64, so 512 bits for a SHA512data=9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043
: hex-encoded SHA512 of UTF8-encoded stringhello
used as digest to signle=00
With a response looking like: af679c9010968004f5b3b6dc23795a743676dee39c4f214426f43b703a244448129ce9b72177bc6647ae2cb9caa0af7d26336a4daa8e67fad253b1b8c02a3f829000
Deconstructed this means:
af679c9010968004f5b3b6dc23795a743676dee39c4f214426f43b703a244448129ce9b72177bc6647ae2cb9caa0af7d26336a4daa8e67fad253b1b8c02a3f82
: The 64 byte digital signature consisting of a 32 byter
and 32 bytes
9000
Success status word
(Reference 7.2.10 PSO: COMPUTE DIGITAL SIGNATURE on page 63 in the OpenPGP Smart Card Spec)
After the yubikey-bridge
has finished processing the request, it will respond with the signature data:
{
"publicKey": "0498a5ecbb2e3738c1021f980017a2f47314288e41ab1c435cfceef00a5e63276933ff480a6bd607f80729204c16c9d2d092a187767c2928008d146197f5fe43c3",
"digestSigned": "f7564babead48166c9444b7ea38195074da660e64a57853954adfe56c3449de97dc356559337d1c2855e52cd647d913fb11fe9c81ec2fb5bee94bf9f13082966",
"signatureRaw": "4a96924a09c21dd992c500112c18708f45d73971723e84b2753ec214b1f66cf277c18cf96bf12127f8b14a6c990d59acffa1d898293e8aaab720fe41da7e7867",
"signatureAsnDer": "304402204a96924a09c21dd992c500112c18708f45d73971723e84b2753ec214b1f66cf2022077c18cf96bf12127f8b14a6c990d59acffa1d898293e8aaab720fe41da7e7867"
}
The Wallet then checks if the signature is signed with the expected public key, if that key derived the From DAG address, and if the signature is valid:
- From public key:
0498a5ecbb2e3738c1021f980017a2f47314288e41ab1c435cfceef00a5e63276933ff480a6bd607f80729204c16c9d2d092a187767c2928008d146197f5fe43c3
- Signature public key:
0498a5ecbb2e3738c1021f980017a2f47314288e41ab1c435cfceef00a5e63276933ff480a6bd607f80729204c16c9d2d092a187767c2928008d146197f5fe43c3
- Signature public key inferred address:
DAG53VFwtir9K3WfeCLU7EVsmhJGYZtwf9YJJE1J
- Tx Hash:
82dc7a455095b3a67c484693249499f6790251833185b8c41cf44f9addb65e31
- Signed digest:
f7564babead48166c9444b7ea38195074da660e64a57853954adfe56c3449de97dc356559337d1c2855e52cd647d913fb11fe9c81ec2fb5bee94bf9f13082966
- Signature raw:
4a96924a09c21dd992c500112c18708f45d73971723e84b2753ec214b1f66cf277c18cf96bf12127f8b14a6c990d59acffa1d898293e8aaab720fe41da7e7867
- Signature ASN.1 DER:
304402204a96924a09c21dd992c500112c18708f45d73971723e84b2753ec214b1f66cf2022077c18cf96bf12127f8b14a6c990d59acffa1d898293e8aaab720fe41da7e7867
- dag4.keyStore.verify result:
true
It will then add the signature as a proof to the transaction and send it to the network:
{
"value": {
"source": "DAG53VFwtir9K3WfeCLU7EVsmhJGYZtwf9YJJE1J",
"destination": "DAG08TSeWZhT9GwemE5ioxRZZzLgYTVHVTp3r9g2",
"amount": 100000000,
"fee": 0,
"parent": {
"ordinal": 0,
"hash": "0000000000000000000000000000000000000000000000000000000000000000"
},
"salt": "8824239026179262"
},
"proofs": [
{
"id": "98a5ecbb2e3738c1021f980017a2f47314288e41ab1c435cfceef00a5e63276933ff480a6bd607f80729204c16c9d2d092a187767c2928008d146197f5fe43c3",
"signature": "304402204a96924a09c21dd992c500112c18708f45d73971723e84b2753ec214b1f66cf2022077c18cf96bf12127f8b14a6c990d59acffa1d898293e8aaab720fe41da7e7867"
}
]
}
And now the transaction will be sorted into the next DAG L1 block. Finish! 🎉