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 <koni.marti@gmail.com>
Tested-by: Tim Culverhouse <tim@timculverhouse.com>
Acked-by: Robin Jarry <robin@jarry.cc>
This commit is contained in:
Koni Marti 2022-05-30 00:20:41 +02:00 committed by Robin Jarry
parent 115447e57f
commit 83e0e2638d
7 changed files with 295 additions and 6 deletions

View file

@ -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 {

View file

@ -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

1
go.mod
View file

@ -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

7
go.sum
View file

@ -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=

145
lib/auth/auth.go Normal file
View file

@ -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
}
}

93
widgets/authinfo.go Normal file
View file

@ -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)
}

View file

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