Skip to content

Commit

Permalink
webmail: when composing a message, show security status in a bar belo…
Browse files Browse the repository at this point in the history
…w addressee input field

the bar is currently showing 3 properties:
1. mta-sts enforced;
2. mx lookup returned dnssec-signed response;
3. first delivery destination host has dane records

the colors are: red for not-implemented, green for implemented, gray for error,
nothing for unknown/irrelevant.

the plan is to implement "requiretls" soon and start caching per domain whether
delivery can be done with starttls and whether the domain supports requiretls.
and show that in two new parts of the bar.

thanks to damian poddebniak for pointing out that security indicators should
always be visible, not only for positive/negative result. otherwise users won't
notice their absence.
  • Loading branch information
mjl- committed Oct 15, 2023
1 parent 4ab3e6b commit 08995c7
Show file tree
Hide file tree
Showing 9 changed files with 445 additions and 15 deletions.
5 changes: 4 additions & 1 deletion mtastsdb/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,12 @@ func Close() {
}
}

// Lookup looks up a policy for the domain in the database.
// lookup looks up a policy for the domain in the database.
//
// Only non-expired records are returned.
//
// Returns ErrNotFound if record is not present.
// Returns ErrBackoff if a recent attempt to fetch a record failed.
func lookup(ctx context.Context, domain dns.Domain) (*PolicyRecord, error) {
log := xlog.WithContext(ctx)
db, err := database(ctx)
Expand Down
124 changes: 124 additions & 0 deletions webmail/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ import (
"mime"
"mime/multipart"
"mime/quotedprintable"
"net"
"net/http"
"net/mail"
"net/textproto"
"os"
"sort"
"strings"
"sync"
"time"

_ "embed"
Expand All @@ -35,8 +37,11 @@ import (
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/moxio"
"github.com/mjl-/mox/moxvar"
"github.com/mjl-/mox/mtasts"
"github.com/mjl-/mox/mtastsdb"
"github.com/mjl-/mox/queue"
"github.com/mjl-/mox/smtp"
"github.com/mjl-/mox/smtpclient"
"github.com/mjl-/mox/store"
)

Expand Down Expand Up @@ -1617,6 +1622,125 @@ func (Webmail) ThreadMute(ctx context.Context, messageIDs []int64, mute bool) {
})
}

// SecurityResult indicates whether a security feature is supported.
type SecurityResult string

const (
SecurityResultError SecurityResult = "error"
SecurityResultNo SecurityResult = "no"
SecurityResultYes SecurityResult = "yes"
// Unknown whether supported. Finding out may only be (reasonably) possible when
// trying (e.g. SMTP STARTTLS). Once tried, the result may be cached for future
// lookups.
SecurityResultUnknown SecurityResult = "unknown"
)

// RecipientSecurity is a quick analysis of the security properties of delivery to the recipient (domain).
// Fields are nil when an error occurred during analysis.
type RecipientSecurity struct {
MTASTS SecurityResult // Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS record.
DNSSEC SecurityResult // Whether MX lookup response was DNSSEC-signed.
DANE SecurityResult // Whether first delivery destination has DANE records.
}

// RecipientSecurity looks up security properties of the address in the
// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
func (Webmail) RecipientSecurity(ctx context.Context, messageAddressee string) (RecipientSecurity, error) {
resolver := dns.StrictResolver{Pkg: "webmail"}
return recipientSecurity(ctx, resolver, messageAddressee)
}

// separate function for testing with mocked resolver.
func recipientSecurity(ctx context.Context, resolver dns.Resolver, messageAddressee string) (RecipientSecurity, error) {
log := xlog.WithContext(ctx)

rs := RecipientSecurity{
SecurityResultUnknown,
SecurityResultUnknown,
SecurityResultUnknown,
}

msgAddr, err := mail.ParseAddress(messageAddressee)
if err != nil {
return rs, fmt.Errorf("parsing message addressee: %v", err)
}

addr, err := smtp.ParseAddress(msgAddr.Address)
if err != nil {
return rs, fmt.Errorf("parsing address: %v", err)
}

var wg sync.WaitGroup

// MTA-STS.
wg.Add(1)
go func() {
defer wg.Done()

policy, _, err := mtastsdb.Get(ctx, resolver, addr.Domain)
if policy != nil && policy.Mode == mtasts.ModeEnforce {
rs.MTASTS = SecurityResultYes
} else if err == nil {
rs.MTASTS = SecurityResultNo
} else {
rs.MTASTS = SecurityResultError
}
}()

// DNSSEC and DANE.
wg.Add(1)
go func() {
defer wg.Done()

_, origNextHopAuthentic, expandedNextHopAuthentic, _, hosts, _, err := smtpclient.GatherDestinations(ctx, log, resolver, dns.IPDomain{Domain: addr.Domain})
if err != nil {
rs.DNSSEC = SecurityResultError
return
}
if origNextHopAuthentic && expandedNextHopAuthentic {
rs.DNSSEC = SecurityResultYes
} else {
rs.DNSSEC = SecurityResultNo
}

if !origNextHopAuthentic {
rs.DANE = SecurityResultNo
return
}

// We're only looking at the first host to deliver to (typically first mx destination).
if len(hosts) == 0 || hosts[0].Domain.IsZero() {
return // Should not happen.
}
host := hosts[0]

// Resolve the IPs. Required for DANE to prevent bad DNS servers from causing an
// error result instead of no-DANE result.
authentic, expandedAuthentic, expandedHost, _, _, err := smtpclient.GatherIPs(ctx, log, resolver, host, map[string][]net.IP{})
if err != nil {
rs.DANE = SecurityResultError
return
}
if !authentic {
rs.DANE = SecurityResultNo
return
}

daneRequired, _, _, err := smtpclient.GatherTLSA(ctx, log, resolver, host.Domain, expandedAuthentic, expandedHost)
if err != nil {
rs.DANE = SecurityResultError
return
} else if daneRequired {
rs.DANE = SecurityResultYes
} else {
rs.DANE = SecurityResultNo
}
}()

wg.Wait()
return rs, nil
}

func slicesAny[T any](l []T) []any {
r := make([]any, len(l))
for i, v := range l {
Expand Down
73 changes: 73 additions & 0 deletions webmail/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,26 @@
],
"Returns": []
},
{
"Name": "RecipientSecurity",
"Docs": "RecipientSecurity looks up security properties of the address in the\nsingle-address message addressee (as it appears in a To/Cc/Bcc/etc header).",
"Params": [
{
"Name": "messageAddressee",
"Typewords": [
"string"
]
}
],
"Returns": [
{
"Name": "r0",
"Typewords": [
"RecipientSecurity"
]
}
]
},
{
"Name": "SSETypes",
"Docs": "SSETypes exists to ensure the generated API contains the types, for use in SSE events.",
Expand Down Expand Up @@ -1234,6 +1254,33 @@
}
]
},
{
"Name": "RecipientSecurity",
"Docs": "RecipientSecurity is a quick analysis of the security properties of delivery to the recipient (domain).\nFields are nil when an error occurred during analysis.",
"Fields": [
{
"Name": "MTASTS",
"Docs": "Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS record.",
"Typewords": [
"SecurityResult"
]
},
{
"Name": "DNSSEC",
"Docs": "Whether MX lookup response was DNSSEC-signed.",
"Typewords": [
"SecurityResult"
]
},
{
"Name": "DANE",
"Docs": "Whether first delivery destination has DANE records.",
"Typewords": [
"SecurityResult"
]
}
]
},
{
"Name": "EventStart",
"Docs": "EventStart is the first message sent on an SSE connection, giving the client\nbasic data to populate its UI. After this event, messages will follow quickly in\nan EventViewMsgs event.",
Expand Down Expand Up @@ -2587,6 +2634,32 @@
}
]
},
{
"Name": "SecurityResult",
"Docs": "SecurityResult indicates whether a security feature is supported.",
"Values": [
{
"Name": "SecurityResultError",
"Value": "error",
"Docs": ""
},
{
"Name": "SecurityResultNo",
"Value": "no",
"Docs": ""
},
{
"Name": "SecurityResultYes",
"Value": "yes",
"Docs": ""
},
{
"Name": "SecurityResultUnknown",
"Value": "unknown",
"Docs": "Unknown whether supported. Finding out may only be (reasonably) possible when\ntrying (e.g. SMTP STARTTLS). Once tried, the result may be cached for future\nlookups."
}
]
},
{
"Name": "Localpart",
"Docs": "Localpart is a decoded local part of an email address, before the \"@\".\nFor quoted strings, values do not hold the double quote or escaping backslashes.\nAn empty string can be a valid localpart.",
Expand Down
37 changes: 35 additions & 2 deletions webmail/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,14 @@ export interface Mailbox {
Size: number // Number of bytes for all messages.
}

// RecipientSecurity is a quick analysis of the security properties of delivery to the recipient (domain).
// Fields are nil when an error occurred during analysis.
export interface RecipientSecurity {
MTASTS: SecurityResult // Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS record.
DNSSEC: SecurityResult // Whether MX lookup response was DNSSEC-signed.
DANE: SecurityResult // Whether first delivery destination has DANE records.
}

// EventStart is the first message sent on an SSE connection, giving the client
// basic data to populate its UI. After this event, messages will follow quickly in
// an EventViewMsgs event.
Expand Down Expand Up @@ -480,13 +488,24 @@ export enum AttachmentType {
AttachmentPresentation = "presentation", // odp, pptx, ...
}

// SecurityResult indicates whether a security feature is supported.
export enum SecurityResult {
SecurityResultError = "error",
SecurityResultNo = "no",
SecurityResultYes = "yes",
// Unknown whether supported. Finding out may only be (reasonably) possible when
// trying (e.g. SMTP STARTTLS). Once tried, the result may be cached for future
// lookups.
SecurityResultUnknown = "unknown",
}

// Localpart is a decoded local part of an email address, before the "@".
// For quoted strings, values do not hold the double quote or escaping backslashes.
// An empty string can be a valid localpart.
export type Localpart = string

export const structTypes: {[typename: string]: boolean} = {"Address":true,"Attachment":true,"ChangeMailboxAdd":true,"ChangeMailboxCounts":true,"ChangeMailboxKeywords":true,"ChangeMailboxRemove":true,"ChangeMailboxRename":true,"ChangeMailboxSpecialUse":true,"ChangeMsgAdd":true,"ChangeMsgFlags":true,"ChangeMsgRemove":true,"ChangeMsgThread":true,"Domain":true,"DomainAddressConfig":true,"Envelope":true,"EventStart":true,"EventViewChanges":true,"EventViewErr":true,"EventViewMsgs":true,"EventViewReset":true,"File":true,"Filter":true,"Flags":true,"ForwardAttachments":true,"Mailbox":true,"Message":true,"MessageAddress":true,"MessageEnvelope":true,"MessageItem":true,"NotFilter":true,"Page":true,"ParsedMessage":true,"Part":true,"Query":true,"Request":true,"SpecialUse":true,"SubmitMessage":true}
export const stringsTypes: {[typename: string]: boolean} = {"AttachmentType":true,"Localpart":true,"ThreadMode":true}
export const structTypes: {[typename: string]: boolean} = {"Address":true,"Attachment":true,"ChangeMailboxAdd":true,"ChangeMailboxCounts":true,"ChangeMailboxKeywords":true,"ChangeMailboxRemove":true,"ChangeMailboxRename":true,"ChangeMailboxSpecialUse":true,"ChangeMsgAdd":true,"ChangeMsgFlags":true,"ChangeMsgRemove":true,"ChangeMsgThread":true,"Domain":true,"DomainAddressConfig":true,"Envelope":true,"EventStart":true,"EventViewChanges":true,"EventViewErr":true,"EventViewMsgs":true,"EventViewReset":true,"File":true,"Filter":true,"Flags":true,"ForwardAttachments":true,"Mailbox":true,"Message":true,"MessageAddress":true,"MessageEnvelope":true,"MessageItem":true,"NotFilter":true,"Page":true,"ParsedMessage":true,"Part":true,"Query":true,"RecipientSecurity":true,"Request":true,"SpecialUse":true,"SubmitMessage":true}
export const stringsTypes: {[typename: string]: boolean} = {"AttachmentType":true,"Localpart":true,"SecurityResult":true,"ThreadMode":true}
export const intsTypes: {[typename: string]: boolean} = {"ModSeq":true,"UID":true,"Validation":true}
export const types: TypenameMap = {
"Request": {"Name":"Request","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"SSEID","Docs":"","Typewords":["int64"]},{"Name":"ViewID","Docs":"","Typewords":["int64"]},{"Name":"Cancel","Docs":"","Typewords":["bool"]},{"Name":"Query","Docs":"","Typewords":["Query"]},{"Name":"Page","Docs":"","Typewords":["Page"]}]},
Expand All @@ -504,6 +523,7 @@ export const types: TypenameMap = {
"File": {"Name":"File","Docs":"","Fields":[{"Name":"Filename","Docs":"","Typewords":["string"]},{"Name":"DataURI","Docs":"","Typewords":["string"]}]},
"ForwardAttachments": {"Name":"ForwardAttachments","Docs":"","Fields":[{"Name":"MessageID","Docs":"","Typewords":["int64"]},{"Name":"Paths","Docs":"","Typewords":["[]","[]","int32"]}]},
"Mailbox": {"Name":"Mailbox","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"UIDValidity","Docs":"","Typewords":["uint32"]},{"Name":"UIDNext","Docs":"","Typewords":["UID"]},{"Name":"Archive","Docs":"","Typewords":["bool"]},{"Name":"Draft","Docs":"","Typewords":["bool"]},{"Name":"Junk","Docs":"","Typewords":["bool"]},{"Name":"Sent","Docs":"","Typewords":["bool"]},{"Name":"Trash","Docs":"","Typewords":["bool"]},{"Name":"Keywords","Docs":"","Typewords":["[]","string"]},{"Name":"HaveCounts","Docs":"","Typewords":["bool"]},{"Name":"Total","Docs":"","Typewords":["int64"]},{"Name":"Deleted","Docs":"","Typewords":["int64"]},{"Name":"Unread","Docs":"","Typewords":["int64"]},{"Name":"Unseen","Docs":"","Typewords":["int64"]},{"Name":"Size","Docs":"","Typewords":["int64"]}]},
"RecipientSecurity": {"Name":"RecipientSecurity","Docs":"","Fields":[{"Name":"MTASTS","Docs":"","Typewords":["SecurityResult"]},{"Name":"DNSSEC","Docs":"","Typewords":["SecurityResult"]},{"Name":"DANE","Docs":"","Typewords":["SecurityResult"]}]},
"EventStart": {"Name":"EventStart","Docs":"","Fields":[{"Name":"SSEID","Docs":"","Typewords":["int64"]},{"Name":"LoginAddress","Docs":"","Typewords":["MessageAddress"]},{"Name":"Addresses","Docs":"","Typewords":["[]","MessageAddress"]},{"Name":"DomainAddressConfigs","Docs":"","Typewords":["{}","DomainAddressConfig"]},{"Name":"MailboxName","Docs":"","Typewords":["string"]},{"Name":"Mailboxes","Docs":"","Typewords":["[]","Mailbox"]}]},
"DomainAddressConfig": {"Name":"DomainAddressConfig","Docs":"","Fields":[{"Name":"LocalpartCatchallSeparator","Docs":"","Typewords":["string"]},{"Name":"LocalpartCaseSensitive","Docs":"","Typewords":["bool"]}]},
"EventViewErr": {"Name":"EventViewErr","Docs":"","Fields":[{"Name":"ViewID","Docs":"","Typewords":["int64"]},{"Name":"RequestID","Docs":"","Typewords":["int64"]},{"Name":"Err","Docs":"","Typewords":["string"]}]},
Expand Down Expand Up @@ -531,6 +551,7 @@ export const types: TypenameMap = {
"Validation": {"Name":"Validation","Docs":"","Values":[{"Name":"ValidationUnknown","Value":0,"Docs":""},{"Name":"ValidationStrict","Value":1,"Docs":""},{"Name":"ValidationDMARC","Value":2,"Docs":""},{"Name":"ValidationRelaxed","Value":3,"Docs":""},{"Name":"ValidationPass","Value":4,"Docs":""},{"Name":"ValidationNeutral","Value":5,"Docs":""},{"Name":"ValidationTemperror","Value":6,"Docs":""},{"Name":"ValidationPermerror","Value":7,"Docs":""},{"Name":"ValidationFail","Value":8,"Docs":""},{"Name":"ValidationSoftfail","Value":9,"Docs":""},{"Name":"ValidationNone","Value":10,"Docs":""}]},
"ThreadMode": {"Name":"ThreadMode","Docs":"","Values":[{"Name":"ThreadOff","Value":"off","Docs":""},{"Name":"ThreadOn","Value":"on","Docs":""},{"Name":"ThreadUnread","Value":"unread","Docs":""}]},
"AttachmentType": {"Name":"AttachmentType","Docs":"","Values":[{"Name":"AttachmentIndifferent","Value":"","Docs":""},{"Name":"AttachmentNone","Value":"none","Docs":""},{"Name":"AttachmentAny","Value":"any","Docs":""},{"Name":"AttachmentImage","Value":"image","Docs":""},{"Name":"AttachmentPDF","Value":"pdf","Docs":""},{"Name":"AttachmentArchive","Value":"archive","Docs":""},{"Name":"AttachmentSpreadsheet","Value":"spreadsheet","Docs":""},{"Name":"AttachmentDocument","Value":"document","Docs":""},{"Name":"AttachmentPresentation","Value":"presentation","Docs":""}]},
"SecurityResult": {"Name":"SecurityResult","Docs":"","Values":[{"Name":"SecurityResultError","Value":"error","Docs":""},{"Name":"SecurityResultNo","Value":"no","Docs":""},{"Name":"SecurityResultYes","Value":"yes","Docs":""},{"Name":"SecurityResultUnknown","Value":"unknown","Docs":""}]},
"Localpart": {"Name":"Localpart","Docs":"","Values":null},
}

Expand All @@ -550,6 +571,7 @@ export const parser = {
File: (v: any) => parse("File", v) as File,
ForwardAttachments: (v: any) => parse("ForwardAttachments", v) as ForwardAttachments,
Mailbox: (v: any) => parse("Mailbox", v) as Mailbox,
RecipientSecurity: (v: any) => parse("RecipientSecurity", v) as RecipientSecurity,
EventStart: (v: any) => parse("EventStart", v) as EventStart,
DomainAddressConfig: (v: any) => parse("DomainAddressConfig", v) as DomainAddressConfig,
EventViewErr: (v: any) => parse("EventViewErr", v) as EventViewErr,
Expand Down Expand Up @@ -577,6 +599,7 @@ export const parser = {
Validation: (v: any) => parse("Validation", v) as Validation,
ThreadMode: (v: any) => parse("ThreadMode", v) as ThreadMode,
AttachmentType: (v: any) => parse("AttachmentType", v) as AttachmentType,
SecurityResult: (v: any) => parse("SecurityResult", v) as SecurityResult,
Localpart: (v: any) => parse("Localpart", v) as Localpart,
}

Expand Down Expand Up @@ -758,6 +781,16 @@ export class Client {
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}

// RecipientSecurity looks up security properties of the address in the
// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
async RecipientSecurity(messageAddressee: string): Promise<RecipientSecurity> {
const fn: string = "RecipientSecurity"
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = [["RecipientSecurity"]]
const params: any[] = [messageAddressee]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as RecipientSecurity
}

// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
async SSETypes(): Promise<[EventStart, EventViewErr, EventViewReset, EventViewMsgs, EventViewChanges, ChangeMsgAdd, ChangeMsgRemove, ChangeMsgFlags, ChangeMsgThread, ChangeMailboxRemove, ChangeMailboxAdd, ChangeMailboxRename, ChangeMailboxCounts, ChangeMailboxSpecialUse, ChangeMailboxKeywords, Flags]> {
const fn: string = "SSETypes"
Expand Down
7 changes: 7 additions & 0 deletions webmail/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/mjl-/bstore"
"github.com/mjl-/sherpa"

"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/queue"
"github.com/mjl-/mox/store"
Expand Down Expand Up @@ -362,4 +363,10 @@ func TestAPI(t *testing.T) {
l, full = api.CompleteRecipient(ctx, "cc2")
tcompare(t, l, []string{"mjl cc2 <[email protected]>"})
tcompare(t, full, true)

// RecipientSecurity
resolver := dns.MockResolver{}
rs, err := recipientSecurity(ctxbg, resolver, "[email protected]")
tcompare(t, err, nil)
tcompare(t, rs, RecipientSecurity{SecurityResultNo, SecurityResultNo, SecurityResultNo})
}
Loading

0 comments on commit 08995c7

Please sign in to comment.