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}