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()
 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()
 56		ctx.Release()
 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()
 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 {
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, boundary 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
399	w.Write(data) //nolint:errcheck
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	if length < 192 {
415		w.WriteByte(byte(length))
416	} else if length < 8384 {
417		length -= 192
418		w.WriteByte(byte(length>>8) + 192)
419		w.WriteByte(byte(length))
420	} else {
421		w.WriteByte(255)
422		buf := make([]byte, 4)
423		binary.BigEndian.PutUint32(buf, uint32(length))
424		w.Write(buf)
425	}
426}
427
428// parseASN1Signature extracts r and s from an ASN.1 DER encoded ECDSA signature.
429//
430// Each intermediate slice access is bounds-checked against len(der). A truncated
431// or malformed signature produces a typed error rather than an index-out-of-range
432// panic; the minimum-length check up front only rules out obvious runts (#613).
433func parseASN1Signature(der []byte) (r, s []byte, err error) {
434	// ASN.1 SEQUENCE { INTEGER r, INTEGER s }
435	if len(der) < 6 || der[0] != 0x30 {
436		return nil, nil, fmt.Errorf("invalid ASN.1 signature")
437	}
438
439	pos := 2 // skip SEQUENCE tag and length
440
441	// Parse R
442	if pos >= len(der) || der[pos] != 0x02 {
443		return nil, nil, fmt.Errorf("expected INTEGER tag for R")
444	}
445	pos++
446	if pos >= len(der) {
447		return nil, nil, fmt.Errorf("ASN.1 signature truncated before R length")
448	}
449	rLen := int(der[pos])
450	pos++
451	if pos+rLen > len(der) {
452		return nil, nil, fmt.Errorf("ASN.1 signature truncated: R length overflow")
453	}
454	rVal := new(big.Int).SetBytes(der[pos : pos+rLen])
455	pos += rLen
456
457	// Parse S
458	if pos >= len(der) || der[pos] != 0x02 {
459		return nil, nil, fmt.Errorf("expected INTEGER tag for S")
460	}
461	pos++
462	if pos >= len(der) {
463		return nil, nil, fmt.Errorf("ASN.1 signature truncated before S length")
464	}
465	sLen := int(der[pos])
466	pos++
467	if pos+sLen > len(der) {
468		return nil, nil, fmt.Errorf("ASN.1 signature truncated: S length overflow")
469	}
470	sVal := new(big.Int).SetBytes(der[pos : pos+sLen])
471
472	return rVal.Bytes(), sVal.Bytes(), nil
473}
474
475// VerifyYubiKeyAvailable checks if a YubiKey with OpenPGP support is connected.
476func VerifyYubiKeyAvailable() error {
477	card, err := openCard()
478	if err != nil {
479		return err
480	}
481	card.Close()
482	return nil
483}
484
485// GetYubiKeyInfo returns human-readable information about the connected card.
486func GetYubiKeyInfo() (string, error) {
487	card, err := openCard()
488	if err != nil {
489		return "", err
490	}
491	defer card.Close()
492
493	var info strings.Builder
494
495	aid := card.ApplicationRelated.AID
496	info.WriteString(fmt.Sprintf("Manufacturer: %s\n", aid.Manufacturer))
497	info.WriteString(fmt.Sprintf("Serial:       %X\n", aid.Serial))
498	info.WriteString(fmt.Sprintf("Version:      %s\n", aid.Version))
499
500	ch, err := card.GetCardholder()
501	if err == nil && ch.Name != "" {
502		info.WriteString(fmt.Sprintf("Cardholder:   %s\n", ch.Name))
503	}
504
505	if keys := card.ApplicationRelated.Keys; keys != nil {
506		if ki, ok := keys[openpgp.KeySign]; ok {
507			info.WriteString(fmt.Sprintf("Sign Key:     %s", ki.AlgAttrs))
508			if ki.Status == openpgp.KeyGenerated {
509				info.WriteString(" (generated)")
510			} else if ki.Status == openpgp.KeyImported {
511				info.WriteString(" (imported)")
512			}
513			info.WriteString("\n")
514		}
515	}
516
517	return info.String(), nil
518}