package tlsrpt import ( "fmt" "net/url" "strings" ) // Extension is an additional key/value pair for a TLSRPT record. type Extension struct { Key string Value string } // Record is a parsed TLSRPT record, to be served under "_smtp._tls.". // // Example: // // v=TLSRPTv1; rua=mailto:tlsrpt@mox.example; type Record struct { Version string // "TLSRPTv1", for "v=". RUAs [][]string // Aggregate reporting URI, for "rua=". "rua=" can occur multiple times, each can be a list. Must be URL-encoded strings, with ",", "!" and ";" encoded. Extensions []Extension } // String returns a string or use as a TLSRPT DNS TXT record. func (r Record) String() string { b := &strings.Builder{} fmt.Fprint(b, "v="+r.Version) for _, rua := range r.RUAs { fmt.Fprint(b, "; rua="+strings.Join(rua, ",")) } for _, p := range r.Extensions { fmt.Fprint(b, "; "+p.Key+"="+p.Value) } return b.String() } type parseErr string func (e parseErr) Error() string { return string(e) } var _ error = parseErr("") // ParseRecord parses a TLSRPT record. func ParseRecord(txt string) (record *Record, istlsrpt bool, err error) { defer func() { x := recover() if x == nil { return } if xerr, ok := x.(parseErr); ok { record = nil err = fmt.Errorf("%w: %s", ErrRecordSyntax, xerr) return } panic(x) }() p := newParser(txt) record = &Record{ Version: "TLSRPTv1", } p.xtake("v=TLSRPTv1") p.xdelim() istlsrpt = true for { k := p.xkey() p.xtake("=") // note: duplicates are allowed. switch k { case "rua": record.RUAs = append(record.RUAs, p.xruas()) default: v := p.xvalue() record.Extensions = append(record.Extensions, Extension{k, v}) } if !p.delim() || p.empty() { break } } if !p.empty() { p.xerrorf("leftover chars") } if record.RUAs == nil { p.xerrorf("missing rua") } return } type parser struct { s string o int } func newParser(s string) *parser { return &parser{s: s} } func (p *parser) xerrorf(format string, args ...any) { msg := fmt.Sprintf(format, args...) if p.o < len(p.s) { msg += fmt.Sprintf(" (remain %q)", p.s[p.o:]) } panic(parseErr(msg)) } func (p *parser) xtake(s string) string { if !p.prefix(s) { p.xerrorf("expected %q", s) } p.o += len(s) return s } func (p *parser) xdelim() { if !p.delim() { p.xerrorf("expected semicolon") } } func (p *parser) xtaken(n int) string { r := p.s[p.o : p.o+n] p.o += n return r } func (p *parser) prefix(s string) bool { return strings.HasPrefix(p.s[p.o:], s) } func (p *parser) take(s string) bool { if p.prefix(s) { p.o += len(s) return true } return false } func (p *parser) xtakefn1(fn func(rune, int) bool) string { for i, b := range p.s[p.o:] { if !fn(b, i) { if i == 0 { p.xerrorf("expected at least one char") } return p.xtaken(i) } } if p.empty() { p.xerrorf("expected at least 1 char") } return p.xtaken(len(p.s) - p.o) } // ../rfc/8460:368 func (p *parser) xkey() string { return p.xtakefn1(func(b rune, i int) bool { return i < 32 && (b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || (i > 0 && b == '_' || b == '-' || b == '.')) }) } // ../rfc/8460:371 func (p *parser) xvalue() string { return p.xtakefn1(func(b rune, i int) bool { return b > ' ' && b < 0x7f && b != '=' && b != ';' }) } // ../rfc/8460:399 func (p *parser) delim() bool { o := p.o e := len(p.s) for o < e && (p.s[o] == ' ' || p.s[o] == '\t') { o++ } if o >= e || p.s[o] != ';' { return false } o++ for o < e && (p.s[o] == ' ' || p.s[o] == '\t') { o++ } p.o = o return true } func (p *parser) empty() bool { return p.o >= len(p.s) } func (p *parser) wsp() { for p.o < len(p.s) && (p.s[p.o] == ' ' || p.s[p.o] == '\t') { p.o++ } } // ../rfc/8460:358 func (p *parser) xruas() []string { l := []string{p.xuri()} p.wsp() for p.take(",") { p.wsp() l = append(l, p.xuri()) p.wsp() } return l } // ../rfc/8460:360 func (p *parser) xuri() string { v := p.xtakefn1(func(b rune, i int) bool { return b != ',' && b != '!' && b != ' ' && b != '\t' && b != ';' }) u, err := url.Parse(v) if err != nil { p.xerrorf("parsing uri %q: %s", v, err) } if u.Scheme == "" { p.xerrorf("missing scheme in uri") } return v }