152 lines
5.0 KiB
Go
152 lines
5.0 KiB
Go
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
|
||
|
|
}
|