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() {