yubikey.go

  1package pgp
  2
  3import (
  4	"bytes"
  5	"crypto"
  6	"crypto/rand"
  7	"encoding/binary"
  8	"fmt"
  9	"io"
 10	"math/big"
 11	"os"
 12	"strings"
 13	"time"
 14
 15	pgpcrypto "github.com/ProtonMail/go-crypto/openpgp"
 16	"github.com/ProtonMail/go-crypto/openpgp/armor"
 17	"github.com/ProtonMail/go-crypto/openpgp/packet"
 18	"github.com/ebfe/scard"
 19
 20	iso "cunicu.li/go-iso7816"
 21	"cunicu.li/go-iso7816/drivers/pcsc"
 22	"cunicu.li/go-iso7816/filter"
 23
 24	openpgp "cunicu.li/go-openpgp-card"
 25)
 26
 27var randRead = rand.Read
 28
 29// openCard connects to the first available OpenPGP smartcard via PC/SC.
 30func openCard() (*openpgp.Card, error) {
 31	ctx, err := scard.EstablishContext()
 32	if err != nil {
 33		return nil, fmt.Errorf(
 34			"failed to connect to PC/SC daemon: %w\n"+
 35				"Make sure pcscd is running:\n"+
 36				"  sudo systemctl enable --now pcscd.socket\n"+
 37				"You may also need the ccid package for USB smartcard support",
 38			err,
 39		)
 40	}
 41
 42	pcscCard, err := pcsc.OpenFirstCard(ctx, filter.HasApplet(iso.AidOpenPGP), true)
 43	if err != nil {
 44		ctx.Release() //nolint:errcheck,gosec
 45		return nil, fmt.Errorf(
 46			"no OpenPGP smartcard found: %w\n"+
 47				"Make sure your YubiKey is plugged in and has an OpenPGP key configured",
 48			err,
 49		)
 50	}
 51
 52	isoCard := iso.NewCard(pcscCard)
 53	card, err := openpgp.NewCard(isoCard)
 54	if err != nil {
 55		pcscCard.Close() //nolint:errcheck,gosec
 56		ctx.Release()    //nolint:errcheck,gosec
 57		return nil, fmt.Errorf("failed to initialize OpenPGP card: %w", err)
 58	}
 59
 60	return card, nil
 61}
 62
 63// BuildPGPSignedMessage creates a multipart/signed MIME message using a YubiKey.
 64// publicKeyPath is the path to the account's PGP public key file, used to read
 65// key metadata (fingerprint, key ID, algorithm) for building a valid OpenPGP
 66// signature packet.
 67func BuildPGPSignedMessage(payload []byte, pin string, publicKeyPath string) ([]byte, error) {
 68	card, err := openCard()
 69	if err != nil {
 70		return nil, err
 71	}
 72	defer card.Close() //nolint:errcheck
 73
 74	// Verify PIN (PW1 for signing operations)
 75	if err := card.VerifyPassword(openpgp.PW1, pin); err != nil {
 76		return nil, fmt.Errorf("PIN verification failed: %w", err)
 77	}
 78
 79	// Get the signing private key from the card.
 80	privKey, err := card.PrivateKey(openpgp.KeySign, nil)
 81	if err != nil {
 82		return nil, fmt.Errorf("failed to get signing key from card: %w", err)
 83	}
 84
 85	signer, ok := privKey.(crypto.Signer)
 86	if !ok {
 87		return nil, fmt.Errorf("signing key does not implement crypto.Signer")
 88	}
 89
 90	// Load the public key entity to get metadata for the signature packet
 91	signingKey, err := loadSigningPublicKey(publicKeyPath)
 92	if err != nil {
 93		return nil, fmt.Errorf("failed to load public key: %w", err)
 94	}
 95
 96	// Split payload into headers and body for MIME structure
 97	headers, body := splitPayload(payload)
 98
 99	// Build the signed body part (this is what gets hashed)
100	boundary := generateMIMEBoundary()
101	signedPart := buildSignedPart(headers, body, boundary)
102
103	// Build the OpenPGP signature packet
104	sigPacket, err := buildSignaturePacket(signedPart, signer, signingKey)
105	if err != nil {
106		return nil, fmt.Errorf("failed to build signature: %w", err)
107	}
108
109	// Armor the signature
110	armoredSig, err := armorSignature(sigPacket)
111	if err != nil {
112		return nil, fmt.Errorf("failed to armor signature: %w", err)
113	}
114
115	return buildMultipartSigned(headers, body, boundary, armoredSig), nil
116}
117
118func generateMIMEBoundary() string {
119	var buf [16]byte
120	if n, err := randRead(buf[:]); err == nil && n == len(buf) {
121		return fmt.Sprintf("----=_Part_%x", buf[:])
122	}
123	return fmt.Sprintf("----=_Part_%d", time.Now().UnixNano())
124}
125
126// loadSigningPublicKey reads a PGP public key file and returns the signing
127// subkey's PublicKey (or the primary key if no signing subkey exists).
128func loadSigningPublicKey(path string) (*packet.PublicKey, error) {
129	keyData, err := os.ReadFile(path)
130	if err != nil {
131		return nil, err
132	}
133
134	entities, err := pgpcrypto.ReadArmoredKeyRing(bytes.NewReader(keyData))
135	if err != nil {
136		entities, err = pgpcrypto.ReadKeyRing(bytes.NewReader(keyData))
137		if err != nil {
138			return nil, fmt.Errorf("failed to parse PGP key: %w", err)
139		}
140	}
141	if len(entities) == 0 {
142		return nil, fmt.Errorf("no keys found in keyring")
143	}
144
145	entity := entities[0]
146
147	// Look for a signing subkey first
148	now := time.Now()
149	for _, subkey := range entity.Subkeys {
150		if subkey.Sig != nil && subkey.Sig.FlagsValid && subkey.Sig.FlagSign && !subkey.PublicKey.KeyExpired(subkey.Sig, now) {
151			return subkey.PublicKey, nil
152		}
153	}
154
155	// Fall back to primary key
156	return entity.PrimaryKey, nil
157}
158
159// buildSignaturePacket creates a valid OpenPGP v4 signature packet.
160func buildSignaturePacket(signedContent []byte, signer crypto.Signer, pubKey *packet.PublicKey) ([]byte, error) {
161	now := time.Now()
162	hashAlgo := crypto.SHA256
163	hashAlgoID := byte(8) // SHA-256 in OpenPGP
164
165	// Build hashed subpackets
166	var hashedSubpackets bytes.Buffer
167
168	// Subpacket: signature creation time (type 2)
169	writeSubpacket(&hashedSubpackets, 2, func(buf *bytes.Buffer) {
170		ts := make([]byte, 4)
171		binary.BigEndian.PutUint32(ts, uint32(now.Unix()))
172		buf.Write(ts)
173	})
174
175	// Subpacket: issuer key ID (type 16)
176	writeSubpacket(&hashedSubpackets, 16, func(buf *bytes.Buffer) {
177		kid := make([]byte, 8)
178		binary.BigEndian.PutUint64(kid, pubKey.KeyId)
179		buf.Write(kid)
180	})
181
182	// Subpacket: issuer fingerprint (type 33)
183	writeSubpacket(&hashedSubpackets, 33, func(buf *bytes.Buffer) {
184		buf.WriteByte(byte(pubKey.Version))
185		buf.Write(pubKey.Fingerprint)
186	})
187
188	// Build hash suffix (RFC 4880, Section 5.2.4)
189	var hashSuffix bytes.Buffer
190	hashSuffix.WriteByte(4)                       // version
191	hashSuffix.WriteByte(0x00)                    // signature type: binary
192	hashSuffix.WriteByte(byte(pubKey.PubKeyAlgo)) // public key algorithm
193	hashSuffix.WriteByte(hashAlgoID)              // hash algorithm
194	hsLen := hashedSubpackets.Len()
195	hashSuffix.WriteByte(byte(hsLen >> 8))
196	hashSuffix.WriteByte(byte(hsLen))
197	hashSuffix.Write(hashedSubpackets.Bytes())
198
199	// V4 hash trailer
200	trailer := hashSuffix.Bytes()
201	var hashTrailer bytes.Buffer
202	hashTrailer.WriteByte(4)    // version
203	hashTrailer.WriteByte(0xff) // marker
204	tLen := make([]byte, 4)
205	binary.BigEndian.PutUint32(tLen, uint32(len(trailer)))
206	hashTrailer.Write(tLen)
207
208	// Hash the signed content + hash suffix + trailer
209	hasher := hashAlgo.New()
210	hasher.Write(signedContent)
211	hasher.Write(trailer)
212	hasher.Write(hashTrailer.Bytes())
213	digest := hasher.Sum(nil)
214
215	// Sign with the YubiKey
216	rawSig, err := signer.Sign(nil, digest, hashAlgo)
217	if err != nil {
218		return nil, fmt.Errorf("signing failed: %w", err)
219	}
220
221	// Build the complete signature packet body
222	var body bytes.Buffer
223	body.Write(trailer) // version + sig type + algo + hash algo + hashed subpackets
224
225	// Unhashed subpackets (empty)
226	body.WriteByte(0)
227	body.WriteByte(0)
228
229	// Hash tag (first 2 bytes of digest)
230	body.WriteByte(digest[0])
231	body.WriteByte(digest[1])
232
233	// Encode the signature MPIs based on algorithm
234	switch pubKey.PubKeyAlgo { //nolint:exhaustive
235	case packet.PubKeyAlgoEdDSA:
236		// EdDSA: raw signature is r || s, 32 bytes each
237		if len(rawSig) != 64 {
238			return nil, fmt.Errorf("unexpected EdDSA signature length: %d", len(rawSig))
239		}
240		writeMPI(&body, rawSig[:32]) // r
241		writeMPI(&body, rawSig[32:]) // s
242
243	case packet.PubKeyAlgoRSA, packet.PubKeyAlgoRSASignOnly:
244		// RSA: single MPI
245		writeMPI(&body, rawSig)
246
247	case packet.PubKeyAlgoECDSA:
248		// ECDSA: card returns ASN.1 DER encoded (R, S)
249		r, s, err := parseASN1Signature(rawSig)
250		if err != nil {
251			return nil, fmt.Errorf("failed to parse ECDSA signature: %w", err)
252		}
253		writeMPI(&body, r)
254		writeMPI(&body, s)
255
256	default:
257		return nil, fmt.Errorf("unsupported key algorithm: %d", pubKey.PubKeyAlgo)
258	}
259
260	// Wrap in an OpenPGP packet (new-format header)
261	var pkt bytes.Buffer
262	bodyBytes := body.Bytes()
263	pkt.WriteByte(0xC2) // new-format packet tag for signature (type 2)
264	writeNewFormatLength(&pkt, len(bodyBytes))
265	pkt.Write(bodyBytes)
266
267	return pkt.Bytes(), nil
268}
269
270// armorSignature wraps a binary OpenPGP signature in ASCII armor.
271func armorSignature(sigPacket []byte) ([]byte, error) {
272	var buf bytes.Buffer
273	w, err := armor.Encode(&buf, "PGP SIGNATURE", nil)
274	if err != nil {
275		return nil, err
276	}
277	if _, err := w.Write(sigPacket); err != nil {
278		return nil, err
279	}
280	if err := w.Close(); err != nil {
281		return nil, err
282	}
283	return buf.Bytes(), nil
284}
285
286// splitPayload splits a MIME message into headers and body.
287func splitPayload(payload []byte) (headers, body []byte) {
288	if idx := bytes.Index(payload, []byte("\r\n\r\n")); idx >= 0 {
289		return payload[:idx], payload[idx+4:]
290	}
291	return nil, payload
292}
293
294// buildSignedPart constructs the first MIME part content that gets hashed.
295// This must exactly match what appears between the boundary markers.
296func buildSignedPart(headers, body []byte, _ string) []byte {
297	var originalContentType []byte
298	if len(headers) > 0 {
299		for _, line := range bytes.Split(headers, []byte("\r\n")) {
300			upper := bytes.ToUpper(line)
301			if bytes.HasPrefix(upper, []byte("CONTENT-TYPE:")) {
302				originalContentType = line
303				break
304			}
305		}
306	}
307
308	var part bytes.Buffer
309	if len(originalContentType) > 0 {
310		part.Write(originalContentType)
311		part.WriteString("\r\n\r\n")
312	}
313	part.Write(body)
314	return part.Bytes()
315}
316
317// buildMultipartSigned assembles the complete multipart/signed MIME message.
318func buildMultipartSigned(headers, body []byte, boundary string, armoredSig []byte) []byte {
319	var result bytes.Buffer
320
321	// Write transport headers (From, To, Subject, etc.) excluding Content-Type and MIME-Version
322	var originalContentType []byte
323	if len(headers) > 0 {
324		for _, line := range bytes.Split(headers, []byte("\r\n")) {
325			upper := bytes.ToUpper(line)
326			if bytes.HasPrefix(upper, []byte("CONTENT-TYPE:")) {
327				originalContentType = line
328				continue
329			}
330			if bytes.HasPrefix(upper, []byte("MIME-VERSION:")) {
331				continue
332			}
333			if len(line) > 0 {
334				result.Write(line)
335				result.WriteString("\r\n")
336			}
337		}
338	}
339
340	// Write the new top-level Content-Type for multipart/signed
341	result.WriteString("MIME-Version: 1.0\r\n")
342	result.WriteString("Content-Type: multipart/signed; ")
343	result.WriteString("boundary=\"" + boundary + "\"; ")
344	result.WriteString("micalg=pgp-sha256; ")
345	result.WriteString("protocol=\"application/pgp-signature\"\r\n")
346	result.WriteString("\r\n")
347
348	// Write first part (original body with its original Content-Type)
349	result.WriteString("--" + boundary + "\r\n")
350	if len(originalContentType) > 0 {
351		result.Write(originalContentType)
352		result.WriteString("\r\n\r\n")
353	}
354	result.Write(body)
355	result.WriteString("\r\n")
356
357	// Write second part (signature)
358	result.WriteString("--" + boundary + "\r\n")
359	result.WriteString("Content-Type: application/pgp-signature; name=\"signature.asc\"\r\n")
360	result.WriteString("Content-Description: OpenPGP digital signature\r\n")
361	result.WriteString("Content-Disposition: attachment; filename=\"signature.asc\"\r\n\r\n")
362	result.Write(armoredSig)
363	result.WriteString("\r\n")
364	result.WriteString("--" + boundary + "--\r\n")
365
366	return result.Bytes()
367}
368
369// writeSubpacket writes a single OpenPGP subpacket.
370func writeSubpacket(w *bytes.Buffer, typ byte, writeContent func(*bytes.Buffer)) {
371	var content bytes.Buffer
372	writeContent(&content)
373	length := content.Len() + 1 // +1 for type byte
374	if length < 192 {
375		w.WriteByte(byte(length))
376	} else {
377		// Two-octet length
378		length -= 192
379		w.WriteByte(byte(length>>8) + 192)
380		w.WriteByte(byte(length))
381	}
382	w.WriteByte(typ)
383	w.Write(content.Bytes())
384}
385
386// writeMPI writes a big-endian integer as an OpenPGP MPI (2-byte bit count + data).
387func writeMPI(w io.Writer, data []byte) {
388	// Strip leading zero bytes
389	for len(data) > 0 && data[0] == 0 {
390		data = data[1:]
391	}
392	if len(data) == 0 {
393		data = []byte{0}
394	}
395	bitLen := uint16((len(data)-1)*8 + bitLength(data[0]))
396	buf := make([]byte, 2)
397	binary.BigEndian.PutUint16(buf, bitLen)
398	w.Write(buf)  //nolint:errcheck,gosec
399	w.Write(data) //nolint:errcheck,gosec
400}
401
402// bitLength returns the number of significant bits in a byte.
403func bitLength(b byte) int {
404	n := 0
405	for b > 0 {
406		n++
407		b >>= 1
408	}
409	return n
410}
411
412// writeNewFormatLength writes an OpenPGP new-format packet body length.
413func writeNewFormatLength(w *bytes.Buffer, length int) {
414	switch {
415	case length < 192:
416		w.WriteByte(byte(length))
417	case length < 8384:
418		length -= 192
419		w.WriteByte(byte(length>>8) + 192)
420		w.WriteByte(byte(length))
421	default:
422		w.WriteByte(255)
423		buf := make([]byte, 4)
424		binary.BigEndian.PutUint32(buf, uint32(length))
425		_, _ = w.Write(buf)
426	}
427}
428
429// parseASN1Signature extracts r and s from an ASN.1 DER encoded ECDSA signature.
430//
431// Each intermediate slice access is bounds-checked against len(der). A truncated
432// or malformed signature produces a typed error rather than an index-out-of-range
433// panic; the minimum-length check up front only rules out obvious runts (#613).
434func parseASN1Signature(der []byte) (r, s []byte, err error) {
435	// ASN.1 SEQUENCE { INTEGER r, INTEGER s }
436	if len(der) < 6 || der[0] != 0x30 {
437		return nil, nil, fmt.Errorf("invalid ASN.1 signature")
438	}
439
440	pos := 2 // skip SEQUENCE tag and length
441
442	// Parse R
443	if pos >= len(der) || der[pos] != 0x02 {
444		return nil, nil, fmt.Errorf("expected INTEGER tag for R")
445	}
446	pos++
447	if pos >= len(der) {
448		return nil, nil, fmt.Errorf("ASN.1 signature truncated before R length")
449	}
450	rLen := int(der[pos])
451	pos++
452	if pos+rLen > len(der) {
453		return nil, nil, fmt.Errorf("ASN.1 signature truncated: R length overflow")
454	}
455	rVal := new(big.Int).SetBytes(der[pos : pos+rLen])
456	pos += rLen
457
458	// Parse S
459	if pos >= len(der) || der[pos] != 0x02 {
460		return nil, nil, fmt.Errorf("expected INTEGER tag for S")
461	}
462	pos++
463	if pos >= len(der) {
464		return nil, nil, fmt.Errorf("ASN.1 signature truncated before S length")
465	}
466	sLen := int(der[pos])
467	pos++
468	if pos+sLen > len(der) {
469		return nil, nil, fmt.Errorf("ASN.1 signature truncated: S length overflow")
470	}
471	sVal := new(big.Int).SetBytes(der[pos : pos+sLen])
472
473	return rVal.Bytes(), sVal.Bytes(), nil
474}
475
476// VerifyYubiKeyAvailable checks if a YubiKey with OpenPGP support is connected.
477func VerifyYubiKeyAvailable() error {
478	card, err := openCard()
479	if err != nil {
480		return err
481	}
482	card.Close() //nolint:errcheck,gosec
483	return nil
484}
485
486// GetYubiKeyInfo returns human-readable information about the connected card.
487func GetYubiKeyInfo() (string, error) {
488	card, err := openCard()
489	if err != nil {
490		return "", err
491	}
492	defer card.Close() //nolint:errcheck
493
494	var info strings.Builder
495
496	aid := card.AID
497	fmt.Fprintf(&info, "Manufacturer: %s\n", aid.Manufacturer)
498	fmt.Fprintf(&info, "Serial:       %X\n", aid.Serial)
499	fmt.Fprintf(&info, "Version:      %s\n", aid.Version)
500
501	ch, err := card.GetCardholder()
502	if err == nil && ch.Name != "" {
503		fmt.Fprintf(&info, "Cardholder:   %s\n", ch.Name)
504	}
505
506	if keys := card.Keys; keys != nil {
507		if ki, ok := keys[openpgp.KeySign]; ok {
508			fmt.Fprintf(&info, "Sign Key:     %s", ki.AlgAttrs)
509			switch ki.Status {
510			case openpgp.KeyGenerated:
511				info.WriteString(" (generated)")
512			case openpgp.KeyImported:
513				info.WriteString(" (imported)")
514			case openpgp.KeyNotPresent:
515				// no key on card
516			}
517			info.WriteString("\n")
518		}
519	}
520
521	return info.String(), nil
522}