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 }