From 83e0e2638df9da0801af7ad35058938dc8eb1cdc Mon Sep 17 00:00:00 2001 From: Koni Marti Date: Mon, 30 May 2022 00:20:41 +0200 Subject: [PATCH] msgviewer: parse and display authentication results Parse the Authentication-Results header and display it in the message viewer (not enabled by default). DKIM, SPF and DMARC authentication methods are supported. Implement recommendation from RFC 7601 Sec 7.1 to have an explicit list of trustworthy hostnames before displaying the authentication results. Be aware that the authentication headers can be forged. To display the results for a specific authentication method, add the corresponding name to the layout of headers in the viewer section of aerc.conf, e.g. to display all three, use: header-layout = From|To,Cc|Bcc,Date,Subject,DKIM|SPF|DMARC More information will be displayed when "+" is appended to the authentication method name, e.g. DKIM+ or SPF+ or DMARC+. Also, add the trustworthy hosts per account with the trusted-authres parameter, e.g. trusted-authres = * to trust every host or use regular expressions for a finer control. Multiple hosts can be entered as a comma-separated list. Authentication results will only be displayed when the host is listed in the trusted-authres list. Link: https://datatracker.ietf.org/doc/html/rfc7601 Signed-off-by: Koni Marti Tested-by: Tim Culverhouse Acked-by: Robin Jarry --- config/config.go | 3 + doc/aerc-config.5.scd | 11 ++++ go.mod | 1 + go.sum | 7 ++ lib/auth/auth.go | 145 ++++++++++++++++++++++++++++++++++++++++++ widgets/authinfo.go | 93 +++++++++++++++++++++++++++ widgets/msgviewer.go | 41 ++++++++++-- 7 files changed, 295 insertions(+), 6 deletions(-) create mode 100644 lib/auth/auth.go create mode 100644 widgets/authinfo.go diff --git a/config/config.go b/config/config.go index f87649c..923a1a0 100644 --- a/config/config.go +++ b/config/config.go @@ -116,6 +116,9 @@ type AccountConfig struct { PgpKeyId string `ini:"pgp-key-id"` PgpAutoSign bool `ini:"pgp-auto-sign"` PgpOpportunisticEncrypt bool `ini:"pgp-opportunistic-encrypt"` + + // AuthRes + TrustedAuthRes []string `ini:"trusted-authres" delim:","` } type BindingConfig struct { diff --git a/doc/aerc-config.5.scd b/doc/aerc-config.5.scd index b7fba82..15c6ca3 100644 --- a/doc/aerc-config.5.scd +++ b/doc/aerc-config.5.scd @@ -383,6 +383,11 @@ These options are configured in the *[viewer]* section of aerc.conf. Rows will be hidden if none of their specified headers are present in the message. + Authentication information from the Authentication-Results header can be + displayed by adding DKIM, SPF or DMARC. To show more information + than just the authentication result, append a plus sign (+) to the header name + (e.g. DKIM+). + Default: From|To,Cc|Bcc,Date,Subject *show-headers* @@ -649,6 +654,12 @@ Note that many of these configuration options are written for you, such as signature to be added to emails sent from this account. If the command fails then *signature-file* is used instead. +*trusted-authres* + Comma-separated list of trustworthy hostnames from which the + Authentication Results header will be displayed. Entries can be regular + expressions. If you want to trust any host (e.g. for debugging), + use the wildcard \*. + # BINDS.CONF This file is used for configuring keybindings used in the aerc interactive diff --git a/go.mod b/go.mod index 923ec07..a3b32be 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/emersion/go-imap-sortthread v1.2.0 github.com/emersion/go-maildir v0.2.0 github.com/emersion/go-message v0.15.0 + github.com/emersion/go-msgauth v0.6.5 // indirect github.com/emersion/go-pgpmail v0.2.0 github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac github.com/emersion/go-smtp v0.15.0 diff --git a/go.sum b/go.sum index 92d3246..4539610 100644 --- a/go.sum +++ b/go.sum @@ -69,8 +69,13 @@ github.com/emersion/go-imap-sortthread v1.2.0/go.mod h1:UhenCBupR+vSYRnqJkpjSq84 github.com/emersion/go-maildir v0.2.0 h1:fC4+UVGl8GcQGbFF7AWab2JMf4VbKz+bMNv07xxhzs8= github.com/emersion/go-maildir v0.2.0/go.mod h1:I2j27lND/SRLgxROe50Vam81MSaqPFvJ0OHNnDZ7n84= github.com/emersion/go-message v0.11.1/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY= +github.com/emersion/go-message v0.11.2/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY= +github.com/emersion/go-message v0.14.1/go.mod h1:N1JWdZQ2WRUalmdHAX308CWBq747VJ8oUorFI3VCBwU= github.com/emersion/go-message v0.15.0 h1:urgKGqt2JAc9NFJcgncQcohHdiYb803YTH9OQwHBHIY= github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= +github.com/emersion/go-milter v0.3.2/go.mod h1:ablHK0pbLB83kMFBznp/Rj8aV+Kc3jw8cxzzmCNLIOY= +github.com/emersion/go-msgauth v0.6.5 h1:UaXBtrjYBM3SWw9BBODeSp0uYtScx3CuIF7/RQfkeWo= +github.com/emersion/go-msgauth v0.6.5/go.mod h1:/jbQISFJgtT12T8akRs20l+wI4HcyN/kWy7VRdHEAmA= github.com/emersion/go-pgpmail v0.2.0 h1:BU9kEGQcDVXi6n0v3JBsWAikyo63xsUGZ1lnVaWa6ks= github.com/emersion/go-pgpmail v0.2.0/go.mod h1:8mQ8Rpn+w28DDaiP8HvJuZjSAymaWr87K3zA/bwwkU0= github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k= @@ -178,6 +183,7 @@ github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8= +github.com/martinlindhe/base36 v1.1.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-pointer v0.0.0-20180825124634-49522c3f3791/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc= @@ -341,6 +347,7 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5-0.20201125200606-c27b9fd57aec/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= diff --git a/lib/auth/auth.go b/lib/auth/auth.go new file mode 100644 index 0000000..8a0a40f --- /dev/null +++ b/lib/auth/auth.go @@ -0,0 +1,145 @@ +package auth + +import ( + "fmt" + "regexp" + "strings" + + "github.com/emersion/go-message/mail" + "github.com/emersion/go-msgauth/authres" +) + +const ( + AuthHeader = "Authentication-Results" +) + +type Method string + +const ( + DKIM Method = "dkim" + SPF Method = "spf" + DMARC Method = "dmarc" +) + +type Result string + +const ( + ResultNone Result = "none" + ResultPass Result = "pass" + ResultFail Result = "fail" + ResultNeutral Result = "neutral" + ResultPolicy Result = "policy" +) + +type Details struct { + Results []Result + Infos []string + Reasons []string + Err error +} + +func (d *Details) add(r Result, info string, reason string) { + d.Results = append(d.Results, r) + d.Infos = append(d.Infos, info) + d.Reasons = append(d.Reasons, reason) +} + +type ParserFunc func(*mail.Header, []string) (*Details, error) + +func New(s string) ParserFunc { + if i := strings.IndexRune(s, '+'); i > 0 { + s = s[:i] + } + m := Method(strings.ToLower(s)) + switch m { + case DKIM, SPF, DMARC: + return CreateParser(m) + } + return nil +} + +func trust(s string, trusted []string) bool { + for _, t := range trusted { + if matched, _ := regexp.MatchString(t, s); matched || t == "*" { + return true + } + } + return false +} + +var cleaner = regexp.MustCompile(`(\(.*);(.*\))`) + +func CreateParser(m Method) func(*mail.Header, []string) (*Details, error) { + return func(header *mail.Header, trusted []string) (*Details, error) { + details := &Details{} + found := false + + hf := header.FieldsByKey(AuthHeader) + for hf.Next() { + headerText, err := hf.Text() + if err != nil { + return nil, err + } + + identifier, results, err := authres.Parse(headerText) + if err != nil && err.Error() == "msgauth: unsupported version" { + // Some MTA write their authres header without an identifier + // which does not conform to RFC but still exists in the wild + identifier, results, err = authres.Parse("unknown;" + headerText) + if err != nil { + return nil, err + } + } else if err != nil && err.Error() == "msgauth: malformed authentication method and value" { + // the go-msgauth parser doesn't like semi-colons in the comments + // as a work-around we remove those + cleanHeader := cleaner.ReplaceAllString(headerText, "${1}${2}") + identifier, results, err = authres.Parse(cleanHeader) + if err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + + // implements recommendation from RFC 7601 Sec 7.1 to + // have an explicit list of trustworthy hostnames + // before displaying AuthRes results + if !trust(identifier, trusted) { + return nil, fmt.Errorf("%s is not trusted", identifier) + } + + for _, result := range results { + switch r := result.(type) { + case *authres.DKIMResult: + if m == DKIM { + info := r.Identifier + if info == "" && r.Domain != "" { + info = r.Domain + } + details.add(Result(r.Value), info, r.Reason) + found = true + } + case *authres.SPFResult: + if m == SPF { + info := r.From + if info == "" && r.Helo != "" { + info = r.Helo + } + details.add(Result(r.Value), info, r.Reason) + found = true + } + case *authres.DMARCResult: + if m == DMARC { + details.add(Result(r.Value), r.From, r.Reason) + found = true + } + } + } + } + + if !found { + details.add(ResultNone, "", "") + } + return details, nil + } +} diff --git a/widgets/authinfo.go b/widgets/authinfo.go new file mode 100644 index 0000000..0879554 --- /dev/null +++ b/widgets/authinfo.go @@ -0,0 +1,93 @@ +package widgets + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/auth" + "git.sr.ht/~rjarry/aerc/lib/ui" + "github.com/gdamore/tcell/v2" + "github.com/mattn/go-runewidth" +) + +type AuthInfo struct { + ui.Invalidatable + authdetails *auth.Details + showInfo bool + uiConfig config.UIConfig +} + +func NewAuthInfo(auth *auth.Details, showInfo bool, uiConfig config.UIConfig) *AuthInfo { + return &AuthInfo{authdetails: auth, showInfo: showInfo, uiConfig: uiConfig} +} + +func (a *AuthInfo) Draw(ctx *ui.Context) { + defaultStyle := a.uiConfig.GetStyle(config.STYLE_DEFAULT) + style := a.uiConfig.GetStyle(config.STYLE_DEFAULT) + ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', defaultStyle) + var text string + if a.authdetails == nil { + text = "(no header)" + ctx.Printf(0, 0, defaultStyle, text) + } else if a.authdetails.Err != nil { + style = a.uiConfig.GetStyle(config.STYLE_ERROR) + text = a.authdetails.Err.Error() + ctx.Printf(0, 0, style, text) + } else { + checkBounds := func(x int) bool { + if x < ctx.Width() { + return true + } else { + return false + } + } + setResult := func(result auth.Result) (string, tcell.Style) { + switch result { + case auth.ResultNone: + return "none", defaultStyle + case auth.ResultNeutral: + return "neutral", a.uiConfig.GetStyle(config.STYLE_WARNING) + case auth.ResultPolicy: + return "policy", a.uiConfig.GetStyle(config.STYLE_WARNING) + case auth.ResultPass: + return "✓", a.uiConfig.GetStyle(config.STYLE_SUCCESS) + case auth.ResultFail: + return "✗", a.uiConfig.GetStyle(config.STYLE_ERROR) + default: + return string(result), a.uiConfig.GetStyle(config.STYLE_ERROR) + } + } + x := 1 + for i := 0; i < len(a.authdetails.Results); i++ { + if checkBounds(x) { + text, style := setResult(a.authdetails.Results[i]) + if i > 0 { + text = " " + text + } + x += ctx.Printf(x, 0, style, text) + } + } + if a.showInfo { + infoText := "" + for i := 0; i < len(a.authdetails.Infos); i++ { + if i > 0 { + infoText += "," + } + infoText += a.authdetails.Infos[i] + if reason := a.authdetails.Reasons[i]; reason != "" { + infoText += reason + } + } + if checkBounds(x) && infoText != "" { + if trunc := ctx.Width() - x - 3; trunc > 0 { + text = runewidth.Truncate(infoText, trunc, "…") + x += ctx.Printf(x, 0, defaultStyle, fmt.Sprintf(" (%s)", text)) + } + } + } + } +} + +func (a *AuthInfo) Invalidate() { + a.DoInvalidate(a) +} diff --git a/widgets/msgviewer.go b/widgets/msgviewer.go index c88c981..9876467 100644 --- a/widgets/msgviewer.go +++ b/widgets/msgviewer.go @@ -17,6 +17,7 @@ import ( "git.sr.ht/~rjarry/aerc/config" "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/auth" "git.sr.ht/~rjarry/aerc/lib/format" "git.sr.ht/~rjarry/aerc/lib/ui" "git.sr.ht/~rjarry/aerc/logging" @@ -61,13 +62,29 @@ func NewMessageViewer(acct *AccountView, layout := hf.forMessage(msg.MessageInfo()) header, headerHeight := layout.grid( func(header string) ui.Drawable { - return &HeaderView{ + hv := &HeaderView{ conf: conf, Name: header, Value: fmtHeader(msg.MessageInfo(), header, acct.UiConfig().TimestampFormat), uiConfig: acct.UiConfig(), } + showInfo := false + if i := strings.IndexRune(header, '+'); i > 0 { + header = header[:i] + hv.Name = header + showInfo = true + } + if parser := auth.New(header); parser != nil { + details, err := parser(msg.MessageInfo().RFC822Headers, acct.AccountConfig().TrustedAuthRes) + if err != nil { + hv.Value = err.Error() + } else { + hv.ValueField = NewAuthInfo(details, showInfo, acct.UiConfig()) + } + hv.Invalidate() + } + return hv }, ) @@ -134,6 +151,10 @@ func fmtHeader(msg *models.MessageInfo, header string, timefmt string) string { return "error: no envelope for this message" } + if v := auth.New(header); v != nil { + return "Fetching.." + } + switch header { case "From": return format.FormatAddresses(msg.Envelope.From) @@ -796,16 +817,20 @@ func (pv *PartViewer) Event(event tcell.Event) bool { type HeaderView struct { ui.Invalidatable - conf *config.AercConfig - Name string - Value string - uiConfig config.UIConfig + conf *config.AercConfig + Name string + Value string + ValueField ui.Drawable + uiConfig config.UIConfig } func (hv *HeaderView) Draw(ctx *ui.Context) { name := hv.Name size := runewidth.StringWidth(name + ":") lim := ctx.Width() - size - 1 + if lim <= 0 || ctx.Height() <= 0 { + return + } value := runewidth.Truncate(" "+hv.Value, lim, "…") vstyle := hv.uiConfig.GetStyle(config.STYLE_DEFAULT) @@ -818,7 +843,11 @@ func (hv *HeaderView) Draw(ctx *ui.Context) { ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', vstyle) ctx.Printf(0, 0, hstyle, "%s:", name) - ctx.Printf(size, 0, vstyle, "%s", value) + if hv.ValueField == nil { + ctx.Printf(size, 0, vstyle, "%s", value) + } else { + hv.ValueField.Draw(ctx.Subcontext(size, 0, lim, 1)) + } } func (hv *HeaderView) Invalidate() {