-// Package document implements epoint document parsing and creation.
-//
-// An epoint document is an OpenPGP (RFC 4880) clear signed
-// utf-8 text of key-value pairs.
-// The body contains a content-type MIME header so the document
-// can be used in OpenPGP/MIME (RFC 3156) emails.
-// The format of the key-value pairs are similar to MIME header
-// fields: keys and values are separated by ": ", repeated keys
-// are not allowed, long values can be split before a space.
-//
-// Example:
-//
-// -----BEGIN PGP SIGNED MESSAGE-----
-// Hash: SHA1
-//
-// Content-Type: text/plain.epoint.type; charset=utf-8
-//
-// Key: Value1
-// Another-Key: Value2
-// Last-Key: Long
-// value that spans
-// multiple lines
-// -----BEGIN PGP SIGNATURE-----
-//
-// pgp signature
-// -----END PGP SIGNATURE-----
-package document
-
-// TODO: error wrapper (so reporting to user or creating bounce cert is simple)
-// TODO: optional fields: exact semantics ("" vs "-" vs nil)
-// TODO: trailing space handling in ParseFields
-// TODO: fields of notice (last notice, serial, failure notice,..)
-// TODO: limits and cert type specific input validation
-// TODO: fix Cert mess
-// TODO: nonce is id, id is even number of hex digits (require only drawer.nonce to be uniq)
-// TODO: denom, issuer from key (key representation: armor?)
-
-import (
- "bytes"
- "crypto"
- "crypto/openpgp"
- "crypto/openpgp/armor"
- "crypto/openpgp/packet"
- "crypto/sha1"
- "encoding/hex"
- "fmt"
- "reflect"
- "strconv"
- "strings"
- "time"
-)
-
-// limits
-const (
- MaxFields = 20
- MaxLineLength = 160 // 1 sha512 + 1 key (without \n)
- MaxValueLength = 1300 // 20 sha256 space separated (without \n)
- MaxNonceLength = 20
- MaxDenominationLength = 100
-)
-
-const ClearSignedHeader = "-----BEGIN PGP SIGNED MESSAGE-----"
-
-// MIME type for epoint documents, see RFC 4288
-var ContentType = map[string]string{
- "Draft": "text/vnd.epoint.draft; charset=utf-8",
- "Notice": "text/vnd.epoint.notice; charset=utf-8",
- "DebitCert": "text/vnd.epoint.debit; charset=utf-8",
- "CreditCert": "text/vnd.epoint.credit; charset=utf-8",
- "BounceCert": "text/vnd.epoint.bounce; charset=utf-8",
-}
-
-// OpenPGP signed cleartext document representation
-type Signed struct {
- // Sign and CleanSigned sets Hash for FormatSigned
- // TODO: CreationDate
- Hash string
- // Signed text (no dash escape, no trailing space, \n new lines)
- Body []byte
- // Armored detached text signature of the Body
- Signature []byte
-}
-
-// parsed epoint document
-type Document struct {
- Type string
- Fields map[string]string
- Order []string
-}
-
-var fieldtype = map[string]string{
- "Amount": "int",
- "Authorized-By": "id",
- "Balance": "int",
- "Beneficiary": "id",
- "Date": "date",
- "Debit-Cert": "id",
- "Denomination": "text",
- "Difference": "int",
- "Draft": "id",
- "Drawer": "id",
- "Expiry-Date": "date",
- "Holder": "id",
- "Issuer": "id",
- "Last-Cert": "id",
- "Last-Credit-Serial": "int",
- "Last-Debit-Serial": "int",
- "Maturity-Date": "date",
- "Nonce": "id",
- "Notes": "text",
- "References": "ids",
- "Serial": "int",
-}
-
-var fieldname = map[string]string{
- "AuthorizedBy": "Authorized-By",
- "DebitCert": "Debit-Cert",
- "ExpiryDate": "Expiry-Date",
- "LastCert": "Last-Cert",
- "LastCreditSerial": "Last-Credit-Serial",
- "LastDebitSerial": "Last-Debit-Serial",
- "MaturityDate": "Maturity-Date",
-}
-
-type Draft struct {
- Drawer string
- Beneficiary string
- Amount int64
- Denomination string
- Issuer string
- AuthorizedBy string
- MaturityDate *int64 // optional
- ExpiryDate *int64 // optional
- Nonce string
- Notes *string // optional
-}
-
-type Notice struct {
- Date int64
- AuthorizedBy string
- Notes *string // optional
- References []string // may be empty (startup notice)
-}
-
-type Cert struct {
- Holder string
- Serial int64
- Balance int64
- Denomination string
- Issuer string
- Date int64
- AuthorizedBy string
- Notes *string // optional
- LastDebitSerial int64 // 0 if none
- LastCreditSerial int64 // 0 if none
- LastCert *string // nil if serial == 1
- References []string
- Difference int64
- Draft string
-}
-
-type DebitCert struct {
- Cert
- Beneficiary string
-}
-
-type CreditCert struct {
- Cert
- Drawer string
- DebitCert string
-}
-
-type BounceCert struct {
- Drawer string
- Draft string
- LastCert *string // optional
- Balance int64 // 0 if none
- Date int64
- AuthorizedBy string
- Notes *string // optional
- References []string
-}
-
-func ToCert(v interface{}) (cert *Cert, err error) {
- cert = new(Cert)
- switch x := v.(type) {
- case *DebitCert:
- cert = &x.Cert
- case *CreditCert:
- cert = &x.Cert
- default:
- err = fmt.Errorf("ToCert: only debit or credit document can be converted to cert")
- }
- return
-}
-
-func cleanBody(s []byte) []byte {
- nl := []byte{'\n'}
- a := bytes.Split(s, nl)
- for i := range a {
- a[i] = bytes.TrimRight(a[i], " \t")
- }
- return bytes.Join(a, nl)
-}
-
-// sha1 sum of the (cleaned) document body as uppercase hex string
-func Id(c *Signed) string {
- h := sha1.New()
- h.Write(c.Body)
- return fmt.Sprintf("%040X", h.Sum())
-}
-
-// parse an epoint document without checking the signature and format details
-func Parse(s []byte) (iv interface{}, c *Signed, err error) {
- c, err = ParseSigned(s)
- if err != nil {
- return
- }
- doc, err := ParseDocument(c.Body)
- if err != nil {
- return
- }
- iv, err = ParseStruct(doc)
- return
-}
-
-// format and sign an epoint document
-func Format(iv interface{}, key *openpgp.Entity) (s []byte, c *Signed, err error) {
- doc, err := FormatStruct(iv)
- if err != nil {
- return
- }
- body, err := FormatDocument(doc)
- if err != nil {
- return
- }
- c, err = Sign(body, key)
- if err != nil {
- return
- }
- s, err = FormatSigned(c)
- return
-}
-
-// verify an epoint document, return the cleaned version as well
-func Verify(c *Signed, key openpgp.KeyRing) (err error) {
- msg := bytes.NewBuffer(c.Body)
- sig := bytes.NewBuffer(c.Signature)
- // TODO: verify signature
- _, _ = msg, sig
- // _, err = openpgp.CheckArmoredDetachedSignature(key, msg, sig)
- return
-}
-
-// sign body with given secret key
-func Sign(body []byte, key *openpgp.Entity) (c *Signed, err error) {
- c = new(Signed)
- c.Hash = "SHA256"
- c.Body = cleanBody(body)
- w := new(bytes.Buffer)
- err = openpgp.ArmoredDetachSignText(w, key, bytes.NewBuffer(c.Body))
- if err != nil {
- return
- }
- // close armored document with a \n
- _, _ = w.Write([]byte{'\n'})
- c.Signature = w.Bytes()
- return
-}
-
-// split a clear signed document into body and armored signature
-func ParseSigned(s []byte) (c *Signed, err error) {
- // look for clear signed header
- for !bytes.HasPrefix(s, []byte(ClearSignedHeader)) {
- _, s = getLine(s)
- if len(s) == 0 {
- err = fmt.Errorf("ParseSigned: clear signed header is missing")
- return
- }
- }
- s = s[len(ClearSignedHeader):]
- // end of line after the header
- empty, s := getLine(s)
- if len(empty) != 0 {
- err = fmt.Errorf("ParseSigned: bad clear signed header")
- return
- }
- // skip all hash headers, section 7.
- for bytes.HasPrefix(s, []byte("Hash: ")) {
- _, s = getLine(s)
- }
- // skip empty line
- empty, s = getLine(s)
- if len(empty) != 0 {
- err = fmt.Errorf("ParseSigned: expected an empty line after armor headers")
- return
- }
- lines := [][]byte{}
- for !bytes.HasPrefix(s, []byte("-----BEGIN")) {
- var line []byte
- line, s = getLine(s)
- // dash unescape, section 7.1.
- if bytes.HasPrefix(line, []byte("- ")) {
- line = line[2:]
- }
- // empty values are not supported: "Key: \n"
- lines = append(lines, bytes.TrimRight(line, " \t"))
- }
- c = new(Signed)
- // last line is not closed by \n
- c.Body = bytes.Join(lines, []byte("\n"))
- // signature is just the rest of the input data
- c.Signature = s
- return
-}
-
-// clean up, check and reencode signature
-// used on drafts before calculating the signed document hash
-func CleanSigned(c *Signed) (err error) {
- b, err := armor.Decode(bytes.NewBuffer(c.Signature))
- if err != nil {
- return
- }
- if b.Type != openpgp.SignatureType {
- err = fmt.Errorf("CleanSigned: invalid armored signature type")
- return
- }
- p, err := packet.Read(b.Body)
- if err != nil {
- return
- }
- sig, ok := p.(*packet.Signature)
- if !ok {
- err = fmt.Errorf("CleanSigned: invalid signature packet")
- return
- }
- // section 5.2.3
- if sig.SigType != packet.SigTypeText {
- err = fmt.Errorf("CleanSigned: expected text signature")
- return
- }
- switch sig.Hash {
- case crypto.SHA1:
- c.Hash = "SHA1"
- case crypto.SHA256:
- c.Hash = "SHA256"
- default:
- err = fmt.Errorf("CleanSigned: expected SHA1 or SHA256 signature hash")
- return
- }
- // TODO: check CreationTime and other subpackets
- if sig.SigLifetimeSecs != nil && *sig.SigLifetimeSecs != 0 {
- err = fmt.Errorf("CleanSigned: signature must not expire")
- return
- }
- out := new(bytes.Buffer)
- w, err := armor.Encode(out, openpgp.SignatureType, nil)
- if err != nil {
- return
- }
- err = sig.Serialize(w)
- if err != nil {
- return
- }
- err = w.Close()
- if err != nil {
- return
- }
- c.Signature = out.Bytes()
- return
-}
-
-// create clear signed document
-func FormatSigned(c *Signed) (data []byte, err error) {
- s := ClearSignedHeader + "\n"
- if c.Hash != "" {
- s += "Hash: " + c.Hash + "\n"
- }
- s += "\n"
- s += string(c.Body)
- s += "\n"
- s += string(c.Signature)
- data = []byte(s)
- return
-}
-
-// parse type and fields of a document body
-func ParseDocument(body []byte) (doc *Document, err error) {
- // parse content type header first
- fields, s, err := ParseFields(body)
- if err != nil {
- return
- }
- ctype, ok := fields["Content-Type"]
- if len(fields) != 1 || !ok {
- return nil, fmt.Errorf("ParseBody: expected a single Content-Type header field")
- }
- doc = new(Document)
- for k, v := range ContentType {
- if ctype == v {
- doc.Type = k
- break
- }
- }
- if doc.Type == "" {
- return nil, fmt.Errorf("ParseBody: unknown Content-Type: %s", ctype)
- }
- // TODO: doc.Order
- doc.Fields, s, err = ParseFields(s)
- if err == nil && len(s) > 0 {
- err = fmt.Errorf("ParseBody: extra data after fields: %q", s)
- }
- return
-}
-
-// create document body
-func FormatDocument(doc *Document) (body []byte, err error) {
- ctype, ok := ContentType[doc.Type]
- if !ok {
- err = fmt.Errorf("FormatDocument: unknown document type: %s", doc.Type)
- return
- }
- s := "Content-Type: " + ctype + "\n\n"
- for _, k := range doc.Order {
- s += k + ": " + doc.Fields[k] + "\n"
- }
- return []byte(s), nil
-}
-
-// parse doc fields into a struct according to the document type
-func parseStruct(v reflect.Value, fields map[string]string, seen map[string]bool) (err error) {
- t := v.Type()
- n := v.NumField()
- for i := 0; i < n && err == nil; i++ {
- ft := t.Field(i)
- fv := v.Field(i)
- if ft.Anonymous && fv.Kind() == reflect.Struct {
- err = parseStruct(fv, fields, seen)
- continue
- }
- key := fieldname[ft.Name]
- if key == "" {
- key = ft.Name
- }
- s, ok := fields[key]
- if !ok {
- if fv.Kind() == reflect.Ptr {
- // missing optional key: leave the pointer as nil
- continue
- }
- return fmt.Errorf("ParseStruct: field %s of %s is missing\n", key, t.Name())
- }
- seen[key] = true
- if fv.Kind() == reflect.Ptr {
- if s == "" || s == "-" {
- // TODO
- // empty optional key: same as missing
- continue
- }
- fv.Set(reflect.New(fv.Type().Elem()))
- fv = fv.Elem()
- }
- switch fieldtype[key] {
- case "id":
- var val string
- val, err = parseId(s)
- fv.SetString(val)
- case "text":
- var val string
- val, err = parseString(s)
- fv.SetString(val)
- case "int":
- var val int64
- val, err = strconv.Atoi64(s)
- fv.SetInt(val)
- case "date":
- var val int64
- val, err = parseDate(s)
- fv.SetInt(val)
- case "ids":
- // TODO: empty slice?
- ids := strings.Split(s, " ")
- val := make([]string, len(ids))
- for j, id := range ids {
- val[j], err = parseId(id)
- if err != nil {
- return
- }
- }
- fv.Set(reflect.ValueOf(val))
- default:
- panic("bad field type " + key + " " + fieldtype[key])
- }
- }
- return
-}
-
-func ParseStruct(doc *Document) (iv interface{}, err error) {
- switch doc.Type {
- case "Draft":
- iv = new(Draft)
- case "Notice":
- iv = new(Notice)
- case "DebitCert":
- iv = new(DebitCert)
- case "CreditCert":
- iv = new(CreditCert)
- case "BounceCert":
- iv = new(BounceCert)
- default:
- err = fmt.Errorf("ParseStruct: unkown doc type: %s", doc.Type)
- return
- }
- seen := make(map[string]bool)
- err = parseStruct(reflect.ValueOf(iv).Elem(), doc.Fields, seen)
- if err != nil {
- return
- }
- if len(doc.Fields) != len(seen) {
- for f := range doc.Fields {
- if !seen[f] {
- err = fmt.Errorf("ParseStruct: unknown field %s in %s", f, doc.Type)
- return
- }
- }
- }
- return
-}
-
-// turn a struct into a document
-func formatStruct(v reflect.Value, doc *Document) (err error) {
- t := v.Type()
- n := v.NumField()
- for i := 0; i < n; i++ {
- ft := t.Field(i)
- fv := v.Field(i)
- if ft.Anonymous && fv.Kind() == reflect.Struct {
- err = formatStruct(fv, doc)
- if err != nil {
- return
- }
- continue
- }
- key := fieldname[ft.Name]
- if key == "" {
- key = ft.Name
- }
- val := ""
- if fv.Kind() == reflect.Ptr {
- if fv.IsNil() {
- // keep empty optional fields but mark them
- val = "-"
- goto setval
- }
- fv = fv.Elem()
- }
- switch fieldtype[key] {
- case "id":
- val = formatId(fv.String())
- case "text":
- val = formatString(fv.String())
- case "int":
- val = strconv.Itoa64(fv.Int())
- case "date":
- val = formatDate(fv.Int())
- case "ids":
- k := fv.Len()
- for j := 0; j < k; j++ {
- if j > 0 {
- val += "\n "
- }
- val += formatId(fv.Index(j).String())
- }
- default:
- panic("bad field type " + key + " " + fieldtype[key])
- }
- setval:
- doc.Fields[key] = val
- doc.Order = append(doc.Order, key)
- }
- return
-}
-
-// turn a struct into a document
-func FormatStruct(iv interface{}) (doc *Document, err error) {
- v := reflect.ValueOf(iv)
- if v.Kind() != reflect.Ptr || v.IsNil() || v.Elem().Kind() != reflect.Struct {
- panic("input is not a pointer to struct")
- }
- doc = new(Document)
- doc.Type = v.Elem().Type().Name()
- doc.Fields = make(map[string]string)
- err = formatStruct(v.Elem(), doc)
- return
-}
-
-func ParseFields(s []byte) (fields map[string]string, rest []byte, err error) {
- rest = s
- fields = make(map[string]string)
- key := ""
- // \n is optional after the last field and an extra \n is allowed as well
- for len(rest) > 0 {
- var line []byte
- line, rest = getLine(rest)
- // empty line after the last field is consumed
- if len(line) == 0 {
- break
- }
- if line[0] == ' ' && key != "" {
- // "Key: v1\n v2\n" is equivalent to "Key: v1 v2\n"
- fields[key] += string(line)
- continue
- }
- if line[0] < 'A' || line[0] > 'Z' {
- err = fmt.Errorf("ParseFields: field name must start with an upper-case ascii letter")
- return
- }
- i := bytes.IndexByte(line, ':')
- if i < 0 {
- err = fmt.Errorf("ParseFields: missing ':'")
- return
- }
- key = string(line[:i])
- if _, ok := fields[key]; ok {
- err = fmt.Errorf("ParseFields: repeated fields are not allowed")
- return
- }
- fields[key] = string(line[i+1:])
- }
- for key, v := range fields {
- // either a single space follows ':' or the value is empty
- // good: "Key:\n", "Key:\n value\n", "Key: value\n", "Key: v1\n v2\n"
- // bad: "Key:value\n", "Key: \nvalue\n"
- // bad but not checked here: "Key: \n", "Key: value \n", "Key:\n \n value\n"
- if len(v) == 0 {
- continue
- }
- if v[0] != ' ' {
- err = fmt.Errorf("ParseFields: ':' is not followed by ' '")
- return
- }
- fields[key] = v[1:]
- }
- return
-}
-
-// TODO: limit errors
-
-func parseId(s string) (string, error) {
- // check if hex decodable
- // TODO: length check
- dst := make([]byte, len(s)/2)
- _, err := hex.Decode(dst, []byte(s))
- return s, err
-}
-
-func formatId(s string) string {
- return s
-}
-
-func parseString(s string) (string, error) {
- if len(s) > MaxValueLength {
- return "", fmt.Errorf("parseString: length limit is exceeded")
- }
- return s, nil
-}
-
-func formatString(s string) string {
- return s
-}
-
-func parseDate(s string) (int64, error) {
- // TODO: fractional seconds?
- t, err := time.Parse(time.RFC3339, s)
- if err != nil {
- return 0, err
- }
- return t.Seconds(), nil
-}
-
-func formatDate(i int64) string {
- return time.SecondsToUTC(i).Format(time.RFC3339)
-}
-
-func getLine(data []byte) (line, rest []byte) {
- i := bytes.IndexByte(data, '\n')
- j := i + 1
- if i < 0 {
- i = len(data)
- j = i
- } else if i > 0 && data[i-1] == '\r' {
- i--
- }
- return data[:i], data[j:]
-}