MFA fix, added IP blacklist, update webmail client
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user