--- /dev/null
+// 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:]
+}