Files
mailgoserver/internal/smime/entity.go
T

152 lines
5.0 KiB
Go
Raw Normal View History

package smime
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"strings"
)
// Entity is a MIME entity: its own part-level headers (Content-Type,
// Content-Transfer-Encoding, Content-Disposition — never envelope headers like
// From/To/Subject/Date) plus its body. Sign/Encrypt/Decrypt/VerifySigned all operate
// on an Entity, not a flat raw RFC822 message — the caller (webui's compose/read
// handlers) is responsible for keeping envelope headers separate, since S/MIME only
// ever transforms the message body's own MIME entity, never the envelope.
type Entity struct {
Headers []string
Body []byte
}
// bytes renders the entity as it would appear on the wire: headers, a blank line,
// then the body normalized to CRLF line endings — MIME's canonical form, which is
// what gets hashed/signed/encrypted. Both Sign and Encrypt must operate on exactly
// this rendering so a receiving client's own canonicalization matches ours.
func (e Entity) bytes() []byte {
var buf bytes.Buffer
for _, h := range e.Headers {
buf.WriteString(h)
buf.WriteString("\r\n")
}
buf.WriteString("\r\n")
buf.Write(toCRLF(e.Body))
return buf.Bytes()
}
// parseEntity splits raw bytes (headers, a blank line, then body) back into an
// Entity — used to recover the inner MIME entity after Decrypt or the signed part
// after VerifySigned, both of which hand back a full "headers+body" byte blob.
func parseEntity(raw []byte) (Entity, error) {
idx := bytes.Index(raw, []byte("\r\n\r\n"))
sep := 4
if idx < 0 {
idx = bytes.Index(raw, []byte("\n\n"))
sep = 2
}
if idx < 0 {
return Entity{Body: raw}, nil
}
var headers []string
for _, line := range strings.Split(string(raw[:idx]), "\n") {
line = strings.TrimRight(line, "\r")
if line == "" {
continue
}
headers = append(headers, line)
}
return Entity{Headers: headers, Body: raw[idx+sep:]}, nil
}
// toCRLF normalizes line endings to CRLF — first collapsing any existing CRLF to a
// bare LF so a mixed or already-CRLF input doesn't end up double-terminated.
func toCRLF(b []byte) []byte {
b = bytes.ReplaceAll(b, []byte("\r\n"), []byte("\n"))
return bytes.ReplaceAll(b, []byte("\n"), []byte("\r\n"))
}
// HeaderValue is a case-insensitive lookup over a MIME entity's header lines —
// exported so callers outside this package (e.g. webui's read-integration, which
// must inspect a message's Content-Type before deciding whether to unwrap it) don't
// need to reimplement it.
func HeaderValue(headers []string, name string) string {
for _, h := range headers {
idx := strings.Index(h, ":")
if idx < 0 {
continue
}
if strings.EqualFold(strings.TrimSpace(h[:idx]), name) {
return strings.TrimSpace(h[idx+1:])
}
}
return ""
}
func isBase64CTE(cte string) bool {
return strings.EqualFold(strings.TrimSpace(cte), "base64")
}
func decodeBase64(data []byte) ([]byte, error) {
return base64.StdEncoding.DecodeString(stripWhitespace(string(data)))
}
func stripWhitespace(s string) string {
var b strings.Builder
for _, r := range s {
switch r {
case ' ', '\t', '\r', '\n':
continue
default:
b.WriteRune(r)
}
}
return b.String()
}
func newBoundary() string {
b := make([]byte, 16)
rand.Read(b)
return "----=_SMIME_" + hex.EncodeToString(b)
}
// wrapBase64 base64-encodes data at the RFC 2045-recommended 76 characters per line —
// cosmetic (a decoder doesn't care), but matches what every real MTA/MUA produces.
func wrapBase64(data []byte) string {
encoded := base64.StdEncoding.EncodeToString(data)
var b strings.Builder
for i := 0; i < len(encoded); i += 76 {
end := min(i+76, len(encoded))
b.WriteString(encoded[i:end])
b.WriteString("\r\n")
}
return strings.TrimRight(b.String(), "\r\n")
}
// splitMultipartRaw extracts each part's *exact* original bytes between boundary
// delimiters — deliberately not using mime/multipart.Reader, whose Part API parses
// headers away from the raw body and would require re-serializing them to recover
// signable bytes. A detached S/MIME signature covers the literal octets of the
// signed part (RFC 8551 §3.4.3), so reconstruction-from-parsed-headers risks a
// byte-for-byte mismatch (header order, casing, whitespace) that breaks verification
// even for semantically-identical content. Real S/MIME implementations extract raw
// byte ranges for exactly this reason.
func splitMultipartRaw(body []byte, boundary string) ([][]byte, error) {
delim := []byte("--" + boundary)
segments := bytes.Split(body, delim)
if len(segments) < 3 {
return nil, errors.New("smime: malformed multipart body")
}
// segments[0] is the preamble (ignored); the last segment starts with "--" (the
// closing delimiter) and anything after is the epilogue (ignored). Everything in
// between is one part, each still wrapped in the CRLF that separated it from its
// boundary line.
parts := make([][]byte, 0, len(segments)-2)
for _, seg := range segments[1 : len(segments)-1] {
seg = bytes.TrimPrefix(seg, []byte("\r\n"))
seg = bytes.TrimSuffix(seg, []byte("\r\n"))
parts = append(parts, seg)
}
return parts, nil
}