cardhl

package module
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 20, 2026 License: MIT Imports: 21 Imported by: 0

README

go-openpgp-card-hl

High-level OpenPGP smartcard signer & decryptor for Go — YubiKey, Nitrokey, and friends.

Go Version Go Reference GitHub release (latest by date) CI

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.

Features

  • Detached, armored signatures. Sign produces a standard -----BEGIN PGP SIGNATURE----- block over arbitrary bytes — exactly what git commit signing, multipart/signed mail, 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. Decrypt unwraps the session key on the card via crypto.Decrypter and hands the symmetric layer to go-crypto.
  • Structured card info. Info / ListKeys give you manufacturer, serial, cardholder, and each slot's algorithm, status, and fingerprint.
  • Actionable errors. ErrNoPCSC, ErrNoCard, ErrPIN, ErrUnsupportedKey — matchable with errors.Is, each wrapping a message a user can act on.

Install

go get github.com/floatpane/go-openpgp-card-hl

Requires Go 1.26+, a PC/SC stack (pcscd on Linux), and an OpenPGP smartcard.

Usage

Sign
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-----
}
List keys
info, err := card.Info()
if err != nil {
    log.Fatal(err)
}
fmt.Print(info) // Manufacturer / Serial / Version / Cardholder / per-slot keys
Decrypt (RSA)
key, 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-agent for those. RSA works because go-crypto accepts a crypto.Decrypter.

How signing works

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.

Documentation

Full API reference: pkg.go.dev/github.com/floatpane/go-openpgp-card-hl

Contributing

PRs welcome. See CONTRIBUTING.md.

Security

The private key stays on the card. Report vulnerabilities privately via SECURITY.md.

License

MIT. See LICENSE.

Documentation

Overview

Package cardhl is a high-level signer and decryptor for OpenPGP smartcards (YubiKey, Nitrokey, and other OpenPGP-applet cards) over PC/SC.

It wraps the low-level transport (cunicu.li/go-iso7816 + cunicu.li/go-openpgp-card) and the OpenPGP packet layer (github.com/ProtonMail/go-crypto) behind five operations — Sign, Decrypt, SignMIME, DecryptMIME, and ListKeys — with errors that tell a human what to do next ("is pcscd running?", "is the YubiKey plugged in?") instead of leaking raw APDU codes.

Sign produces a detached, ASCII-armored OpenPGP signature over arbitrary bytes (git commit signing, age-plugin-style tooling, etc.).

SignMIME and DecryptMIME handle RFC 3156 PGP/MIME directly: SignMIME takes a raw MIME message and returns a multipart/signed message; DecryptMIME takes a multipart/encrypted message and returns the plaintext, with all MIME parsing and armor handling done inside the library.

Quick start — raw signing

card, err := cardhl.Open()
if err != nil {
    log.Fatal(err) // friendly, actionable message
}
defer card.Close()

pub, err := cardhl.LoadPublicKey("key.asc") // signing subkey metadata
if err != nil {
    log.Fatal(err)
}

sig, err := card.Sign(payload, pin, pub)
if err != nil {
    log.Fatal(err)
}
os.Stdout.Write(sig) // -----BEGIN PGP SIGNATURE-----

Quick start — PGP/MIME

// Sign a raw MIME message and get back a multipart/signed message.
signed, err := card.SignMIME(rawMIMEMessage, pin, pub)

// Decrypt a multipart/encrypted message.
key, err := cardhl.LoadEntity("recipient.asc")
plain, err := card.DecryptMIME(encryptedMIMEMessage, pin, key)

Security model

The private key never leaves the card; signing and decryption happen on the device. The PIN (PW1) is sent to the card to authorize each operation. This library does not cache PINs, touch the filesystem, or talk to gpg-agent.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrNoPCSC is returned when the PC/SC daemon cannot be reached. On Linux
	// this almost always means pcscd is not running.
	ErrNoPCSC = errors.New("cannot connect to PC/SC daemon")

	// ErrNoCard is returned when no smartcard exposing the OpenPGP applet is
	// present on any reader.
	ErrNoCard = errors.New("no OpenPGP smartcard found")

	// ErrCardInit is returned when a card is present but the OpenPGP applet
	// could not be initialized.
	ErrCardInit = errors.New("failed to initialize OpenPGP card")

	// ErrPIN is returned when PIN (PW1) verification fails.
	ErrPIN = errors.New("PIN verification failed")

	// ErrNoKey is returned when the requested key slot (sign/decrypt) holds no
	// key, or no public-key material could be read for it.
	ErrNoKey = errors.New("no key in slot")

	// ErrUnsupportedKey is returned when the key's algorithm is not supported
	// for the requested operation.
	ErrUnsupportedKey = errors.New("unsupported key algorithm")

	// ErrSign is returned when the card refuses or fails a signing operation.
	ErrSign = errors.New("signing failed")

	// ErrDecrypt is returned when a message cannot be decrypted with the card's
	// decryption key.
	ErrDecrypt = errors.New("decryption failed")

	// ErrBadKeyFile is returned when a public key file cannot be parsed.
	ErrBadKeyFile = errors.New("could not parse public key")

	// ErrMIME is returned when a PGP/MIME message is malformed or cannot be
	// parsed for a card operation (e.g. missing boundary, wrong media type).
	ErrMIME = errors.New("invalid PGP/MIME structure")
)

Sentinel errors returned by this package. Use errors.Is to match them; the concrete error returned usually wraps one of these with extra context from the card or PC/SC layer.

Functions

func LoadEntity

func LoadEntity(path string) (*pgpcrypto.Entity, error)

LoadEntity reads an exported OpenPGP public key from path and returns the first entity. Both ASCII-armored and binary keyrings are accepted.

func LoadPublicKey

func LoadPublicKey(path string) (*packet.PublicKey, error)

LoadPublicKey reads an exported OpenPGP public key from path and returns the signing-capable key (a signing subkey if present, otherwise the primary key). Both ASCII-armored and binary keyrings are accepted.

func ParseEntity

func ParseEntity(r io.Reader) (*pgpcrypto.Entity, error)

ParseEntity reads an exported OpenPGP public key from r and returns the first entity. Both ASCII-armored and binary keyrings are accepted.

func ParsePublicKey

func ParsePublicKey(r io.Reader) (*packet.PublicKey, error)

ParsePublicKey reads an exported OpenPGP public key from r and returns the signing-capable key. Both ASCII-armored and binary keyrings are accepted.

Types

type Card

type Card struct {
	// contains filtered or unexported fields
}

Card is a connected OpenPGP smartcard session. It is not safe for concurrent use; serialize calls or open one Card per goroutine. Always Close it.

func Open

func Open() (*Card, error)

Open connects to the first available OpenPGP smartcard via PC/SC.

Errors are actionable: a missing daemon yields ErrNoPCSC, an absent card yields ErrNoCard, and an applet that will not initialize yields ErrCardInit.

func (*Card) Close

func (c *Card) Close() error

Close releases the card handle and the underlying PC/SC context.

func (*Card) Decrypt

func (c *Card) Decrypt(ciphertext []byte, pin string, key *pgpcrypto.Entity) ([]byte, error)

Decrypt decrypts an OpenPGP message with the card's decryption key and returns the plaintext.

pin authorizes the DECIPHER operation (PW1 in mode 0x82). key is the message recipient's public key — its encryption subkey identifies which session key the card must unwrap; load it with LoadEntity or ParseEntity.

Only RSA decryption keys are supported. The session-key unwrap for RSA runs through the card's crypto.Decrypter; the symmetric layer is handled in process. ECDH/Curve25519 decryption requires scalar access the card does not expose — use gpg-agent for those keys.

func (*Card) DecryptMIME added in v0.1.0

func (c *Card) DecryptMIME(payload []byte, pin string, key *pgpcrypto.Entity) ([]byte, error)

DecryptMIME decrypts a RFC 3156 multipart/encrypted MIME message using the card's on-device decryption key. The private key never leaves the hardware.

payload must be a complete multipart/encrypted MIME message. key is the account's public key entity — its encryption subkey is used to locate the correct session-key packet in the ciphertext; load it with LoadEntity or ParseEntity.

Only RSA decryption keys are supported. ECDH/Curve25519 keys require scalar access the card does not expose — use gpg-agent for those.

func (*Card) Info

func (c *Card) Info() (*Info, error)

Info returns structured metadata about the card and its key slots.

func (*Card) ListKeys

func (c *Card) ListKeys() []KeyInfo

ListKeys returns the sign, decrypt, and auth slots and their state.

func (*Card) OpenPGP

func (c *Card) OpenPGP() *openpgp.Card

OpenPGP exposes the underlying go-openpgp-card handle for advanced use (cardholder data, key generation, PIN management). Most callers do not need it; the high-level Sign, Decrypt, and Info cover the common path.

func (*Card) Sign

func (c *Card) Sign(data []byte, pin string, pub *packet.PublicKey) ([]byte, error)

Sign returns a detached, ASCII-armored OpenPGP signature over data, produced by the card's signing key.

pin authorizes the signing operation (PW1). pub supplies the metadata that goes into the signature packet — key ID, fingerprint, and public-key algorithm — and must correspond to the key on the card; use LoadPublicKey or ParsePublicKey to obtain it from an exported public key.

The signature covers data verbatim as a binary document (signature type 0x00). Callers building higher-level envelopes (multipart/signed, the git signature format) hash the bytes they want covered and pass them here.

EdDSA, RSA, and ECDSA signing keys are supported.

func (*Card) SignMIME added in v0.1.0

func (c *Card) SignMIME(payload []byte, pin string, pub *packet.PublicKey) ([]byte, error)

SignMIME creates a RFC 3156 multipart/signed MIME message from payload using the card's signing key.

payload is a raw MIME message (headers + CRLF + body). The result is a new message with the same transport headers and a multipart/signed body containing the original body as the first part and the detached PGP signature as the second.

pub must be the signing-capable public key corresponding to the card's sign slot — obtained via LoadPublicKey or ParsePublicKey.

type Info

type Info struct {
	Manufacturer string
	Serial       string // hex
	Version      string
	Cardholder   string // empty if unset
	Keys         []KeyInfo
}

Info is human-readable metadata about a connected card.

func (*Info) String

func (i *Info) String() string

String renders Info as the kind of block a CLI would print.

type KeyInfo

type KeyInfo struct {
	Slot        string // "sign", "decrypt", or "auth"
	Algorithm   string // e.g. "ed25519", "rsa2048", "nistp256"
	Status      string // "generated", "imported", or "absent"
	Fingerprint string // hex, uppercase; empty if absent
}

KeyInfo describes one key slot on the card.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL