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}