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}