yubikey.go

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