yubikey.go

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