1package pgp
2
3import (
4 "bytes"
5 "crypto/rand"
6 "fmt"
7 "time"
8
9 cardhl "github.com/floatpane/go-openpgp-card-hl"
10)
11
12var randRead = rand.Read
13
14// BuildPGPSignedMessage creates a multipart/signed MIME message using a YubiKey.
15// publicKeyPath is the path to the account's PGP public key file, used to read
16// key metadata (fingerprint, key ID, algorithm) for building a valid OpenPGP
17// signature packet.
18//
19// The card session, signature packet construction, and ASCII armoring are
20// handled by github.com/floatpane/go-openpgp-card-hl; this function owns only
21// the MIME multipart/signed framing on top of the detached signature.
22func BuildPGPSignedMessage(payload []byte, pin string, publicKeyPath string) ([]byte, error) {
23 card, err := cardhl.Open()
24 if err != nil {
25 return nil, err
26 }
27 defer card.Close() //nolint:errcheck
28
29 // Load the public key entity to get metadata for the signature packet.
30 pub, err := cardhl.LoadPublicKey(publicKeyPath)
31 if err != nil {
32 return nil, fmt.Errorf("failed to load public key: %w", err)
33 }
34
35 // Split payload into headers and body for MIME structure.
36 headers, body := splitPayload(payload)
37
38 // Build the signed body part (this is what gets hashed and signed).
39 boundary := generateMIMEBoundary()
40 signedPart := buildSignedPart(headers, body, boundary)
41
42 // Produce a detached, ASCII-armored signature over the signed part.
43 armoredSig, err := card.Sign(signedPart, pin, pub)
44 if err != nil {
45 return nil, err
46 }
47
48 return buildMultipartSigned(headers, body, boundary, armoredSig), nil
49}
50
51// VerifyYubiKeyAvailable checks if a YubiKey with OpenPGP support is connected.
52func VerifyYubiKeyAvailable() error {
53 card, err := cardhl.Open()
54 if err != nil {
55 return err
56 }
57 return card.Close()
58}
59
60// GetYubiKeyInfo returns human-readable information about the connected card.
61func GetYubiKeyInfo() (string, error) {
62 card, err := cardhl.Open()
63 if err != nil {
64 return "", err
65 }
66 defer card.Close() //nolint:errcheck
67
68 info, err := card.Info()
69 if err != nil {
70 return "", err
71 }
72 return info.String(), nil
73}
74
75func generateMIMEBoundary() string {
76 var buf [16]byte
77 if n, err := randRead(buf[:]); err == nil && n == len(buf) {
78 return fmt.Sprintf("----=_Part_%x", buf[:])
79 }
80 return fmt.Sprintf("----=_Part_%d", time.Now().UnixNano())
81}
82
83// splitPayload splits a MIME message into headers and body.
84func splitPayload(payload []byte) (headers, body []byte) {
85 if idx := bytes.Index(payload, []byte("\r\n\r\n")); idx >= 0 {
86 return payload[:idx], payload[idx+4:]
87 }
88 return nil, payload
89}
90
91// buildSignedPart constructs the first MIME part content that gets hashed.
92// This must exactly match what appears between the boundary markers.
93func buildSignedPart(headers, body []byte, _ string) []byte {
94 var originalContentType []byte
95 if len(headers) > 0 {
96 for _, line := range bytes.Split(headers, []byte("\r\n")) {
97 upper := bytes.ToUpper(line)
98 if bytes.HasPrefix(upper, []byte("CONTENT-TYPE:")) {
99 originalContentType = line
100 break
101 }
102 }
103 }
104
105 var part bytes.Buffer
106 if len(originalContentType) > 0 {
107 part.Write(originalContentType)
108 part.WriteString("\r\n\r\n")
109 }
110 part.Write(body)
111 return part.Bytes()
112}
113
114// buildMultipartSigned assembles the complete multipart/signed MIME message.
115func buildMultipartSigned(headers, body []byte, boundary string, armoredSig []byte) []byte {
116 var result bytes.Buffer
117
118 // Write transport headers (From, To, Subject, etc.) excluding Content-Type and MIME-Version
119 var originalContentType []byte
120 if len(headers) > 0 {
121 for _, line := range bytes.Split(headers, []byte("\r\n")) {
122 upper := bytes.ToUpper(line)
123 if bytes.HasPrefix(upper, []byte("CONTENT-TYPE:")) {
124 originalContentType = line
125 continue
126 }
127 if bytes.HasPrefix(upper, []byte("MIME-VERSION:")) {
128 continue
129 }
130 if len(line) > 0 {
131 result.Write(line)
132 result.WriteString("\r\n")
133 }
134 }
135 }
136
137 // Write the new top-level Content-Type for multipart/signed
138 result.WriteString("MIME-Version: 1.0\r\n")
139 result.WriteString("Content-Type: multipart/signed; ")
140 result.WriteString("boundary=\"" + boundary + "\"; ")
141 result.WriteString("micalg=pgp-sha256; ")
142 result.WriteString("protocol=\"application/pgp-signature\"\r\n")
143 result.WriteString("\r\n")
144
145 // Write first part (original body with its original Content-Type)
146 result.WriteString("--" + boundary + "\r\n")
147 if len(originalContentType) > 0 {
148 result.Write(originalContentType)
149 result.WriteString("\r\n\r\n")
150 }
151 result.Write(body)
152 result.WriteString("\r\n")
153
154 // Write second part (signature)
155 result.WriteString("--" + boundary + "\r\n")
156 result.WriteString("Content-Type: application/pgp-signature; name=\"signature.asc\"\r\n")
157 result.WriteString("Content-Description: OpenPGP digital signature\r\n")
158 result.WriteString("Content-Disposition: attachment; filename=\"signature.asc\"\r\n\r\n")
159 result.Write(armoredSig)
160 result.WriteString("\r\n")
161 result.WriteString("--" + boundary + "--\r\n")
162
163 return result.Bytes()
164}