Files
gowebmail/internal/smime/smime.go
T

290 lines
10 KiB
Go

// Package smime provides S/MIME certificate generation, signing, and encryption
// for outgoing mail (RFC 8551, via detached CMS/PKCS#7).
//
// Posture note: this package is certificate-chain-agnostic — it verifies that a CMS
// signature matches the given certificate, not that the certificate is trusted by any
// PKI. "Verified" means "signed with the key matching this cert," nothing more. Callers
// that want a "known sender" UI hint should compare against the user's own S/MIME
// contact address book, not treat a successful Verify as proof of identity.
package smime
import (
"bytes"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"math/big"
"strings"
"time"
"go.mozilla.org/pkcs7"
pkcs12 "software.sslmate.com/src/go-pkcs12"
)
func init() {
// The pkcs7 library defaults to legacy DES-CBC; use AES-256-GCM instead.
pkcs7.ContentEncryptionAlgorithm = pkcs7.EncryptionAlgorithmAES256GCM
}
// DefaultValidity is the lifetime used for a freshly self-signed identity.
const DefaultValidity = 365 * 24 * time.Hour
// GenerateSelfSigned creates a new RSA-2048 self-signed S/MIME identity for email.
func GenerateSelfSigned(email string, validity time.Duration) (certPEM, keyPEM []byte, err error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, nil, fmt.Errorf("generate key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, nil, fmt.Errorf("generate serial: %w", err)
}
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: email},
EmailAddresses: []string{email},
NotBefore: time.Now().Add(-5 * time.Minute),
NotAfter: time.Now().Add(validity),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection},
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return nil, nil, fmt.Errorf("create certificate: %w", err)
}
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, nil, fmt.Errorf("marshal key: %w", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM, nil
}
// ImportPKCS12 extracts a cert+key pair from a .p12/.pfx bundle. RSA keys only —
// the pkcs7 library used for signing/encrypting can't drive an EC key here.
func ImportPKCS12(data []byte, password string) (certPEM, keyPEM []byte, err error) {
key, cert, err := pkcs12.Decode(data, password)
if err != nil {
return nil, nil, fmt.Errorf("decode p12: %w", err)
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, nil, errors.New("only RSA keys are supported for S/MIME import")
}
keyDER, err := x509.MarshalPKCS8PrivateKey(rsaKey)
if err != nil {
return nil, nil, fmt.Errorf("marshal key: %w", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM, nil
}
// ParseCertPEM decodes a PEM-encoded X.509 certificate.
func ParseCertPEM(certPEM []byte) (*x509.Certificate, error) {
block, _ := pem.Decode(certPEM)
if block == nil {
return nil, errors.New("invalid certificate PEM")
}
return x509.ParseCertificate(block.Bytes)
}
// ParseKeyPEM decodes a PEM-encoded private key, trying PKCS#8 then falling back to PKCS#1.
func ParseKeyPEM(keyPEM []byte) (crypto.PrivateKey, error) {
block, _ := pem.Decode(keyPEM)
if block == nil {
return nil, errors.New("invalid key PEM")
}
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
return key, nil
}
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
return key, nil
}
// Sign produces a detached CMS/PKCS#7 signature (RFC 8551) over raw, using SHA-256.
func Sign(certPEM, keyPEM, raw []byte) ([]byte, error) {
cert, err := ParseCertPEM(certPEM)
if err != nil {
return nil, err
}
key, err := ParseKeyPEM(keyPEM)
if err != nil {
return nil, err
}
sd, err := pkcs7.NewSignedData(raw)
if err != nil {
return nil, fmt.Errorf("new signed data: %w", err)
}
sd.SetDigestAlgorithm(pkcs7.OIDDigestAlgorithmSHA256)
if err := sd.AddSigner(cert, key, pkcs7.SignerInfoConfig{}); err != nil {
return nil, fmt.Errorf("add signer: %w", err)
}
sd.Detach()
return sd.Finish()
}
// VerifySigned checks a detached signature against the original content and returns the
// signer's certificate. It does NOT validate the certificate against any trust store —
// see the package doc comment.
func VerifySigned(raw, signature []byte) (*x509.Certificate, error) {
p7, err := pkcs7.Parse(signature)
if err != nil {
return nil, fmt.Errorf("parse signature: %w", err)
}
p7.Content = raw
if err := p7.Verify(); err != nil {
return nil, fmt.Errorf("verify: %w", err)
}
signer := p7.GetOnlySigner()
if signer == nil {
return nil, errors.New("no signer certificate found in signature")
}
return signer, nil
}
// Encrypt wraps raw in a PKCS#7 enveloped-data structure (application/pkcs7-mime,
// smime-type=enveloped-data) for the given recipient certificates.
func Encrypt(raw []byte, recipients []*x509.Certificate) ([]byte, error) {
return pkcs7.Encrypt(raw, recipients)
}
// Decrypt opens a PKCS#7 enveloped-data structure using the given identity's cert/key.
func Decrypt(enveloped, certPEM, keyPEM []byte) ([]byte, error) {
cert, err := ParseCertPEM(certPEM)
if err != nil {
return nil, err
}
key, err := ParseKeyPEM(keyPEM)
if err != nil {
return nil, err
}
p7, err := pkcs7.Parse(enveloped)
if err != nil {
return nil, fmt.Errorf("parse enveloped data: %w", err)
}
return p7.Decrypt(cert, key)
}
// ---- Whole-message MIME wrapping (RFC 8551 multipart/signed) ----
//
// SignMIME/verifies operate on a *complete* raw RFC 5322 message (headers + body, as
// produced by internal/email's buildMIMEMessage) rather than a bare payload — Sign/Verify
// above only handle the CMS blob itself.
// SignMIME wraps a complete raw MIME message in a multipart/signed structure: the
// original message's Content-Type + body become the first part, and a detached CMS
// signature over that part becomes the second. All other top-level headers (From, To,
// Subject, Date, Message-ID, ...) are preserved unchanged.
func SignMIME(certPEM, keyPEM, raw []byte) ([]byte, error) {
topLines, entity, err := splitMIMEEntity(raw)
if err != nil {
return nil, err
}
// Per RFC 1847 §2.1, the CRLF immediately preceding the boundary delimiter is part of
// the delimiter, not the signed content — a compliant multipart parser hands back the
// part body WITHOUT it. Sign the same bytes a parser will reconstruct, or verification
// on the receiving end (and our own round-trip test) fails on a spurious trailing CRLF.
signedContent := bytes.TrimSuffix(entity, []byte("\r\n"))
sig, err := Sign(certPEM, keyPEM, signedContent)
if err != nil {
return nil, err
}
boundary := fmt.Sprintf("smime_sig_%x", time.Now().UnixNano())
var out bytes.Buffer
for _, l := range topLines {
out.WriteString(l + "\r\n")
}
fmt.Fprintf(&out, "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=sha-256; boundary=\"%s\"\r\n\r\n", boundary)
out.WriteString("--" + boundary + "\r\n")
out.Write(signedContent)
out.WriteString("\r\n--" + boundary + "\r\n")
out.WriteString("Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n")
out.WriteString("Content-Transfer-Encoding: base64\r\n")
out.WriteString("Content-Disposition: attachment; filename=\"smime.p7s\"\r\n\r\n")
out.WriteString(base64Wrap(sig))
out.WriteString("\r\n--" + boundary + "--\r\n")
return out.Bytes(), nil
}
// entityHeaderNames are the headers that describe a MIME entity's own content (as opposed
// to the surrounding message envelope) and so must travel INSIDE the signed/encrypted part,
// not stay behind as a stray top-level header of the wrapper message.
var entityHeaderNames = []string{"Content-Type", "Content-Transfer-Encoding", "Content-Disposition"}
// splitMIMEEntity splits a raw RFC 5322 message into: the top-level headers with the
// entity headers removed (as lines, unfolded continuation joined), and the "entity" being
// protected — its own Content-Type/Content-Transfer-Encoding/Content-Disposition headers
// plus blank line plus body — which is what gets signed/encrypted, per RFC 1847.
func splitMIMEEntity(raw []byte) (topLines []string, entity []byte, err error) {
idx := bytes.Index(raw, []byte("\r\n\r\n"))
if idx < 0 {
return nil, nil, errors.New("no header/body separator found in message")
}
headerBlock := string(raw[:idx])
body := raw[idx+4:]
rest := strings.Split(headerBlock, "\r\n")
var entityLines []string
for _, name := range entityHeaderNames {
var val string
val, rest = extractHeader(rest, name)
if val != "" {
entityLines = append(entityLines, val)
}
}
if len(entityLines) == 0 {
return nil, nil, errors.New("no Content-Type header found in message")
}
entity = append([]byte(strings.Join(entityLines, "\r\n")+"\r\n\r\n"), body...)
return rest, entity, nil
}
// extractHeader pulls the named header (plus any folded continuation lines) out of lines,
// returning its full value and the remaining lines with it removed.
func extractHeader(lines []string, name string) (value string, rest []string) {
prefix := strings.ToLower(name) + ":"
for i, l := range lines {
if strings.HasPrefix(strings.ToLower(l), prefix) {
value = l
j := i + 1
for j < len(lines) && (strings.HasPrefix(lines[j], " ") || strings.HasPrefix(lines[j], "\t")) {
value += "\r\n" + lines[j]
j++
}
rest = append(append([]string{}, lines[:i]...), lines[j:]...)
return value, rest
}
}
return "", lines
}
// base64Wrap base64-encodes data and wraps it at 76 chars per line (RFC 2045).
func base64Wrap(data []byte) string {
encoded := base64.StdEncoding.EncodeToString(data)
var out strings.Builder
for i := 0; i < len(encoded); i += 76 {
end := i + 76
if end > len(encoded) {
end = len(encoded)
}
out.WriteString(encoded[i:end])
if end < len(encoded) {
out.WriteString("\r\n")
}
}
return out.String()
}