yubikey.go

  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}