Files
mailgoserver/internal/webui/webmail_smime_read.go
T

209 lines
6.5 KiB
Go
Raw Normal View History

package webui
import (
"bytes"
"crypto/x509"
"encoding/pem"
"io"
"mime"
"net/http"
"net/mail"
"strings"
"mailgoserver/internal/db"
"mailgoserver/internal/pgp"
"mailgoserver/internal/smime"
)
// smimeInfo summarizes what unwrapSMIME found, for the message view to show as
// badges. A message can be both signed and encrypted (either nesting order); the
// zero value means "plain, no S/MIME involved."
type smimeInfo struct {
Signed bool
SignatureOK bool
SignatureErr string
SignerEmail string
Encrypted bool
Decrypted bool
DecryptErr string
}
// pgpInfo summarizes what unwrapCrypto found about PGP encryption — parallel to
// smimeInfo, but PGP has no signing role in this codebase (S/MIME handles that), so
// there's no Signed/SignatureOK equivalent here.
type pgpInfo struct {
Encrypted bool
Decrypted bool
DecryptErr string
NeedsUnlock bool
Identities []db.MailboxPGPIdentity
}
// entityHeaderNames are the only headers that belong to a MIME entity (as opposed to
// the message envelope) — see smime.Entity's doc comment.
var entityHeaderNames = []string{"Content-Type", "Content-Transfer-Encoding", "Content-Disposition"}
func isEntityHeader(name string) bool {
for _, n := range entityHeaderNames {
if strings.EqualFold(n, name) {
return true
}
}
return false
}
// tryDecryptWithIdentities attempts a pkcs7-mime decrypt using every S/MIME
// identity this mailbox holds — deliberately not inspecting the CMS RecipientInfo
// to figure out which identity a message targets; trying each key against
// smime.Decrypt is cheap and simple, and a mailbox realistically holds only a
// handful of identities. No passphrase/unlock step: S/MIME keys are stored plain.
func tryDecryptWithIdentities(entity smime.Entity, identities []db.MailboxSMIMEIdentity) (smime.Entity, bool) {
for _, id := range identities {
cert, err := smime.ParseCertPEM([]byte(id.CertPEM))
if err != nil {
continue
}
key, err := smime.ParseKeyPEM([]byte(id.KeyPEM))
if err != nil {
continue
}
if inner, err := smime.Decrypt(entity, cert, key); err == nil {
return inner, true
}
}
return smime.Entity{}, false
}
// tryPGPDecryptWithUnlockedIdentities mirrors tryDecryptWithUnlockedIdentities for
// PGP: tries every identity already unlocked in this session's key cache.
func (a *App) tryPGPDecryptWithUnlockedIdentities(token string, entity pgp.Entity, identities []db.MailboxPGPIdentity) (pgp.Entity, bool) {
for _, id := range identities {
unlocked, ok := a.pgpKeys.get(token, id.ID)
if !ok {
continue
}
if inner, err := pgp.DecryptEntity(entity, unlocked); err == nil {
return inner, true
}
}
return pgp.Entity{}, false
}
// unwrapCrypto strips any S/MIME signing and/or PGP encryption layers off raw (in
// whichever order they were applied — sign-then-encrypt or encrypt-then-sign, and
// either protocol can be outermost), returning a flat RFC822 message with the
// original envelope headers and the innermost plaintext MIME entity, ready for
// mailview.Parse. A verified S/MIME signature also gets the signer's certificate
// auto-captured into the mailbox's S/MIME contacts, the same way any mail client
// would on receiving a good signature (PGP has no signing role here, so no
// equivalent auto-capture on that side). Never returns an error for "this isn't
// S/MIME or PGP" — that's the common case, and raw is returned unchanged.
func (a *App) unwrapCrypto(r *http.Request, mailboxID int64, raw []byte) ([]byte, smimeInfo, pgpInfo) {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return raw, smimeInfo{}, pgpInfo{}
}
body, err := io.ReadAll(msg.Body)
if err != nil {
return raw, smimeInfo{}, pgpInfo{}
}
entity := smime.Entity{Body: body}
for _, name := range entityHeaderNames {
if v := msg.Header.Get(name); v != "" {
entity.Headers = append(entity.Headers, name+": "+v)
}
}
var sInfo smimeInfo
var pInfo pgpInfo
var envelopeHeaders []string
for key, vals := range msg.Header {
if isEntityHeader(key) {
continue
}
for _, v := range vals {
envelopeHeaders = append(envelopeHeaders, key+": "+v)
}
}
unwrap:
for i := 0; i < 5; i++ { // cap against pathological nesting; real S/MIME/PGP never nests this deep
ct := smime.HeaderValue(entity.Headers, "Content-Type")
mediaType := strings.ToLower(strings.TrimSpace(strings.SplitN(ct, ";", 2)[0]))
switch mediaType {
case "multipart/signed":
sInfo.Signed = true
inner, signer, verr := smime.VerifySigned(entity)
if verr != nil {
sInfo.SignatureErr = verr.Error()
} else {
sInfo.SignatureOK = true
if len(signer.EmailAddresses) > 0 {
sInfo.SignerEmail = signer.EmailAddresses[0]
a.captureSignerContact(mailboxID, sInfo.SignerEmail, signer)
}
}
entity = inner
case "application/pkcs7-mime":
sInfo.Encrypted = true
identities, ierr := a.DB.ListSMIMEIdentities(mailboxID)
if ierr != nil {
sInfo.DecryptErr = ierr.Error()
break unwrap
}
if inner, ok := tryDecryptWithIdentities(entity, identities); ok {
sInfo.Decrypted = true
entity = inner
} else {
sInfo.DecryptErr = "no S/MIME certificate on file could decrypt this message"
break unwrap
}
case "multipart/encrypted":
_, params, perr := mime.ParseMediaType(ct)
if perr != nil || !strings.EqualFold(params["protocol"], "application/pgp-encrypted") {
break unwrap
}
pInfo.Encrypted = true
pgpIdentities, ierr := a.DB.ListPGPIdentities(mailboxID)
if ierr != nil {
pInfo.DecryptErr = ierr.Error()
break unwrap
}
if inner, ok := a.tryPGPDecryptWithUnlockedIdentities(sessionToken(r), pgp.Entity(entity), pgpIdentities); ok {
pInfo.Decrypted = true
entity = smime.Entity(inner)
} else if len(pgpIdentities) == 0 {
pInfo.DecryptErr = "no PGP key to decrypt with — set one up on the Certs page"
break unwrap
} else {
pInfo.NeedsUnlock = true
pInfo.Identities = pgpIdentities
break unwrap
}
default:
break unwrap
}
}
var buf bytes.Buffer
for _, h := range envelopeHeaders {
buf.WriteString(h)
buf.WriteString("\r\n")
}
for _, h := range entity.Headers {
buf.WriteString(h)
buf.WriteString("\r\n")
}
buf.WriteString("\r\n")
buf.Write(entity.Body)
return buf.Bytes(), sInfo, pInfo
}
func (a *App) captureSignerContact(mailboxID int64, email string, cert *x509.Certificate) {
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
if err := a.DB.UpsertSMIMEContact(mailboxID, email, string(certPEM)); err != nil {
a.Logger.Error("auto-capture signer cert for mailbox %d: %v", mailboxID, err)
}
}