209 lines
6.5 KiB
Go
209 lines
6.5 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|