High-level OpenPGP smartcard signer & decryptor for Go — YubiKey, Nitrokey, and friends.
go-openpgp-card-hl is the friendly front door to an OpenPGP smartcard. It
wraps the low-level transport (cunicu.li/go-iso7816
cunicu.li/go-openpgp-card) and the OpenPGP packet layer (ProtonMail/go-crypto) behind three operations — sign, decrypt, list-keys — with errors that tell a human what to do next instead of leaking raw APDU status words.
The private key never leaves the card. Signing and decryption run on the device.
- Detached, armored signatures.
Signproduces a standard-----BEGIN PGP SIGNATURE-----block over arbitrary bytes — exactly what git commit signing,multipart/signedmail, and age-plugin-style tooling need. - EdDSA, RSA, and ECDSA signing. The signature packet is built to the right MPI shape per algorithm; the card just signs the digest.
- RSA decryption.
Decryptunwraps the session key on the card viacrypto.Decrypterand hands the symmetric layer togo-crypto. - Structured card info.
Info/ListKeysgive you manufacturer, serial, cardholder, and each slot's algorithm, status, and fingerprint. - Actionable errors.
ErrNoPCSC,ErrNoCard,ErrPIN,ErrUnsupportedKey— matchable witherrors.Is, each wrapping a message a user can act on.
go get github.com/floatpane/go-openpgp-card-hlRequires Go 1.26+, a PC/SC stack (pcscd on Linux), and an OpenPGP smartcard.
package main
import (
"fmt"
"log"
"os"
cardhl "github.com/floatpane/go-openpgp-card-hl"
)
func main() {
card, err := cardhl.Open()
if err != nil {
log.Fatal(err) // e.g. "no OpenPGP smartcard found: … plug in your YubiKey"
}
defer card.Close()
// The signing key's public half supplies the signature-packet metadata.
pub, err := cardhl.LoadPublicKey("key.asc")
if err != nil {
log.Fatal(err)
}
sig, err := card.Sign([]byte("hello, world"), os.Getenv("PIN"), pub)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(sig)) // -----BEGIN PGP SIGNATURE-----
}info, err := card.Info()
if err != nil {
log.Fatal(err)
}
fmt.Print(info) // Manufacturer / Serial / Version / Cardholder / per-slot keyskey, err := cardhl.LoadEntity("recipient.asc") // public key with an encryption subkey
if err != nil {
log.Fatal(err)
}
plain, err := card.Decrypt(ciphertext, os.Getenv("PIN"), key)
if err != nil {
log.Fatal(err)
}ECDH / Curve25519 decryption keys are not supported — the unwrap needs scalar access the card does not expose. Use
gpg-agentfor those. RSA works becausego-cryptoaccepts acrypto.Decrypter.
Sign builds a v4 OpenPGP signature packet by hand: it assembles the hashed
subpackets (creation time, issuer key ID, issuer fingerprint), computes the
RFC 4880 hash over data || hash-suffix || trailer, and asks the card to sign
the digest. The raw signature is encoded into the right MPI form for the key's
algorithm (two MPIs for EdDSA/ECDSA, one for RSA) and wrapped in ASCII armor.
The signature covers data verbatim as a binary document (type 0x00).
Higher-level framing — the MIME multipart/signed envelope, the git signature
format — is the caller's job; hash the bytes you want covered and pass them in.
Full API reference: pkg.go.dev/github.com/floatpane/go-openpgp-card-hl
PRs welcome. See CONTRIBUTING.md.
The private key stays on the card. Report vulnerabilities privately via SECURITY.md.
MIT. See LICENSE.