initial document design
authornsz <nsz@port70.net>
Sun, 13 Nov 2011 00:11:02 +0000 (01:11 +0100)
committernsz <nsz@port70.net>
Sun, 13 Nov 2011 00:11:02 +0000 (01:11 +0100)
document/Makefile [new file with mode: 0644]
document/document.go [new file with mode: 0644]
document/document_test.go [new file with mode: 0644]

diff --git a/document/Makefile b/document/Makefile
new file mode 100644 (file)
index 0000000..7a52d21
--- /dev/null
@@ -0,0 +1,6 @@
+include $(GOROOT)/src/Make.inc
+
+TARG=epoint/document
+GOFILES=document.go
+
+include $(GOROOT)/src/Make.pkg
diff --git a/document/document.go b/document/document.go
new file mode 100644 (file)
index 0000000..46f41ab
--- /dev/null
@@ -0,0 +1,360 @@
+package document
+
+// An epoint document is a clear signed utf-8 text of key-value pairs
+// according to OpenPGP RFC 4880. The body contains a content-type
+// MIME header so it can be used in OpenPGP/MIME RFC 3156 emails with
+// the signature detached. The format of the key-value pairs are
+// similar to MIME header fields.
+//
+// TODO: allow visually aligned field values
+// TODO: handling repeated fields
+// TODO: representation of a list (references)
+//
+// Example:
+//
+// -----BEGIN PGP SIGNED MESSAGE-----
+// Hash: SHA1
+//
+// Content-Type: text/plain.epoint.cert; charset=utf-8
+//
+// Key: Value1
+// Another-Key: Value2
+// -----BEGIN PGP SIGNATURE-----
+// pgp signature
+// -----END PGP SIGNATURE-----
+
+import (
+       "bytes"
+       "strconv"
+)
+
+const ClearSignedHeader = "-----BEGIN PGP SIGNED MESSAGE-----\n"
+
+// Non-standard MIME subtype see RFC 2045 and RFC 2046
+// TODO: verify that Content-Transfer-Encoding is not needed
+// TODO: text/epoint.cert would be shorter
+var ContentType = map[string]string{
+       "cert":  "text/plain.epoint.cert; charset=utf-8",
+       "draft": "text/plain.epoint.draft; charset=utf-8",
+}
+
+// OpenPGP signed cleartext document representation
+type ClearSigned struct {
+       Hash string
+       // Signed text (no dash escape, no trailing space)
+       Body []byte
+       // Armored detached text signature of the Body
+       ArmoredSignature []byte
+}
+
+// Draft document represents an obligation transfer order
+type Draft struct {
+       Drawer      string // ID of the payer (signer of the document)
+       Beneficiary string // ID of the payee
+       Amount      int64  // amount transfered
+       // TODO: issuer keys is enough?
+       Denomination string
+       // TODO: maturity date is enough if the only constraint is <= now
+       IssueDate    int64
+       MaturityDate int64 // Draft is bounced before this date
+       // TODO: implement limits
+       Notes string // Arbitrary text notes of the drawer
+       // TODO: hack to make signed draft unique (not required for DSA)
+       Nonce string // unique number
+       // TODO: server ID might change, do we need it?
+       Server string // ID of the server (drawee?)
+       //TODO: naming: drawee vs issuer
+       Drawee string // ID of the obligation issuer
+       // TODO: reference cert ids in the draft
+       // useful if more strict date of issue information is needed
+       //References []string
+}
+
+// TODO: cert references: fpr+serial, fpr+certID
+// Certification of obligation after a transfer
+// References previous certificate (if any)
+// and the transfer related other documents
+type Cert struct {
+       Holder           string // ID of the creditor
+       Serial           uint32 // serial number, number of certs of the holder
+       Date             int64  // date of issue
+       Balance          int64  // current obligation value
+       Denomination     string
+       Issuer           string // ID of the obligation issuer (drawee?)
+       LastDebitSerial  uint32 // serial of the last draft cert or 0
+       LastCreditSerial uint32 // serial of the last credit cert or 0
+       // TODO: move to References?
+       LastCert string // ID of the previous cert if any
+       // TODO: determine cert type from diff value only?
+       // (>0: credit cert, <0: debit cert, ==0: special)
+       Difference int64 // difference from previous balance
+       // TODO: enough on the debit side
+       Draft string // draft ID related to the transfer
+       // TODO: credit side, redundant references
+       Drawer       string // ID of the drawer in the transaction
+       DrawerSerial uint32 // serial of the drawer's related debit cert
+       DrawerCert   string // ID of the drawer's related debit cert
+       // TODO: fingerprint?
+       References []string // cert IDs for timestamping the system
+}
+
+func DecodeClearSigned(s []byte) (c *ClearSigned, err error) {
+       hash, body, sig := split(s)
+       if len(sig) == 0 {
+               // TODO: split errors
+               return
+       }
+       c = &ClearSigned{string(hash), trimspace(dashunesc(body)), sig}
+       return
+}
+
+func EncodeClearSigned(c *ClearSigned) (data []byte, err error) {
+       s := ClearSignedHeader
+       if c.Hash != "" {
+               s += "Hash: " + c.Hash + "\n"
+       }
+       // TODO: check if space was trimmed from body before signature
+       s += "\n"
+       s += string(dashesc(c.Body))
+       s += "\n"
+       s += string(c.ArmoredSignature)
+       data = []byte(s)
+       return
+}
+
+func ParseFields(s []byte) (fields map[string]string, rest []byte, err error) {
+       // TODO: error
+       fields = make(map[string]string)
+       rest = s
+       for len(rest) > 0 {
+               var line []byte
+               line, rest = getLine(rest)
+               // empty line after the parsed fields (consumed)
+               if len(line) == 0 {
+                       break
+               }
+               i := bytes.Index(line, []byte(": "))
+               // TODO: long lines can be broken up in MIME
+               if i < 0 {
+                       return nil, nil, nil
+               }
+               // TODO: repeated fields
+               fields[string(line[:i])] = string(line[i+2:])
+       }
+       return
+}
+
+func ParseBody(s []byte) (t string, fields map[string]string, err error) {
+       // parse content type header first
+       mime, s, err := ParseFields(s)
+       if len(mime) != 1 {
+               return
+       }
+       for k, v := range ContentType {
+               if mime["Content-Type"] == v {
+                       t = k
+                       fields, s, err = ParseFields(s)
+                       if len(s) > 0 {
+                               fields = nil
+                               break
+                       }
+                       return
+               }
+       }
+       // TODO: error
+       return
+}
+
+/* rendering with reflect
+func render(d interface{}) (s []byte, err error) {
+       a := []string{}
+       v := reflect.ValueOf(d)
+       t := v.Type()
+       n := v.NumField()
+       for i := 0; i < n; i++ {
+               f := t.Field(i)
+               fv := v.Field(i)
+               fs := ""
+               switch fv.Type() {
+               case reflect.String:
+                       fs = fv.String() // TODO: quote, esc (\n..)
+               case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+                       fs = strconv.Itoa64(fv.Int())
+               case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
+                       fs = strconv.Uitoa64(fv.Uint())
+               default:
+                       return // TODO: error
+               }
+               a = append(a, f.Name, ": ", fs, "\n")
+       }
+       s = strings.Join(a, "")
+       return
+}
+*/
+
+func checkID(s string) (string, error) {
+       return s, nil
+}
+
+func ParseDraft(s []byte) (draft *Draft, err error) {
+       t, fields, err := ParseBody(s)
+       if err != nil {
+               return
+       }
+       if t != "draft" {
+               return
+       }
+
+       draftFields := []string{
+               "Drawer",
+               "Beneficiary",
+               "Amount",
+               "Denomination",
+               "IssueDate",
+               "MaturityDate",
+               "Notes",
+               "Nonce",
+               "Server",
+               "Drawee",
+       }
+       if len(fields) != len(draftFields) {
+               return
+       }
+       for _, f := range draftFields {
+               _, ok := fields[f]
+               if !ok {
+                       return
+               }
+       }
+
+       draft = new(Draft)
+       draft.Drawer, _ = checkID(fields["Drawer"])
+       draft.Beneficiary, _ = checkID(fields["Beneficiary"])
+       draft.Amount, _ = strconv.Atoi64(fields["Amount"])
+       draft.Denomination = fields["Denomination"]
+       draft.IssueDate, _ = strconv.Atoi64(fields["IssueDate"])
+       draft.MaturityDate, _ = strconv.Atoi64(fields["MaturityDate"])
+       draft.Notes = fields["Notes"]
+       draft.Nonce = fields["Nonce"]
+       draft.Server, _ = checkID(fields["Server"])
+       draft.Drawee, _ = checkID(fields["Drawee"])
+
+       // more checks..
+
+       return
+}
+
+func RenderDraft(draft *Draft) (data []byte, err error) {
+       s := "Content-Type: " + ContentType["draft"] + "\n"
+       s += "\n"
+       s += "Drawer: " + draft.Drawer + "\n"
+       s += "Beneficiary: " + draft.Beneficiary + "\n"
+       s += "Amount: " + strconv.Itoa64(draft.Amount) + "\n"
+       s += "Denomination: " + draft.Denomination + "\n"
+       s += "IssueDate: " + strconv.Itoa64(draft.IssueDate) + "\n"
+       s += "MaturityDate: " + strconv.Itoa64(draft.MaturityDate) + "\n"
+       s += "Notes: " + draft.Notes + "\n"
+       s += "Nonce: " + draft.Nonce + "\n"
+       s += "Server: " + draft.Server + "\n"
+       s += "Drawee: " + draft.Drawee + "\n"
+       data = []byte(s)
+       return
+}
+
+func ParseCert(s []byte) (cert Cert, err error) {
+       return
+}
+
+func RenderCert(cert Cert) (s []byte, err error) {
+       return
+}
+
+func splitline(s []byte) (line, rest []byte) {
+       i := bytes.IndexByte(s, '\n')
+       if i < 0 {
+               line = s
+       } else {
+               rest = s[i+1:]
+               if i > 0 && s[i-1] == '\r' {
+                       i--
+               }
+               line = s[:i]
+       }
+       return
+}
+
+func getLine(data []byte) (line, rest []byte) {
+       i := bytes.Index(data, []byte{'\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:]
+}
+
+func trimspace(s []byte) []byte {
+       a := bytes.Split(s, []byte("\n"))
+       for i := range a {
+               a[i] = bytes.TrimRight(a[i], " \t\r")
+       }
+       return bytes.Join(a, []byte("\n"))
+}
+
+func dashesc(s []byte) []byte {
+       r := bytes.Replace(s, []byte("\n-"), []byte("\n- -"), -1)
+       if len(r) > 0 && r[0] == '-' {
+               r = append([]byte("- "), r...)
+       }
+       return r
+}
+
+func dashunesc(s []byte) []byte {
+       r := bytes.Replace(s, []byte("\n- "), []byte("\n"), -1)
+       if len(r) >= 2 && r[0] == '-' && r[1] == ' ' {
+               r = r[2:]
+       }
+       return r
+}
+
+// RFC 4880 is unclear about multiple Hash header semantics, section 7. says
+//  "One or more "Hash" Armor Headers"
+// then
+//  "If more than one message digest is used in the signature, the "Hash"
+//   armor header contains a comma-delimited list of used message digests."
+// in section 6.2.
+//  "there is no limit to the length of Armor Headers.  Care should
+//   be taken that the Armor Headers are short enough to survive
+//   transport.  One way to do this is to repeat an Armor Header key
+//   multiple times with different values for each so that no one line is
+//   overly long."
+// we accept a single Hash header with a list of hash algorithms for now
+// but use the one specified by the signature
+
+func split(s []byte) (hash, body, sig []byte) {
+       if !bytes.HasPrefix(s, []byte(ClearSignedHeader)) {
+               return
+       }
+       s = s[len(ClearSignedHeader):]
+       // only allow a single Hash: header
+       if bytes.HasPrefix(s, []byte("Hash: ")) {
+               s = s[len("Hash: "):]
+               hash, s = getLine(s)
+       }
+       // skip empty line
+       empty, s := getLine(s)
+       if len(empty) != 0 {
+               return
+       }
+       i := bytes.Index(s, []byte("\n-----BEGIN"))
+       if i < 0 {
+               return
+       }
+       body, sig = s[:i], s[i+1:]
+       if i > 0 && body[i-1] == '\r' {
+               body = body[:i-1]
+       }
+       return
+}
diff --git a/document/document_test.go b/document/document_test.go
new file mode 100644 (file)
index 0000000..45cac1f
--- /dev/null
@@ -0,0 +1,158 @@
+package document
+
+import (
+       "testing"
+)
+
+const D1 = `-----BEGIN PGP SIGNED MESSAGE-----
+Hash: SHA1
+
+Content-Type: text/plain.epoint.cert; charset=utf-8
+
+A: foo
+B: bar
+a: baz
+-----BEGIN PGP SIGNATURE-----
+
+sig
+-----END PGP SIGNATURE-----
+`
+const D2 = `-----BEGIN PGP SIGNED MESSAGE-----
+Hash: SHA1
+
+Content-Type: text/plain.epoint.cert; charset=utf-8
+
+A: foo
+B: bar
+a: baz
+
+-----BEGIN PGP SIGNATURE-----
+
+sig
+-----END PGP SIGNATURE-----
+`
+
+var C1 = &ClearSigned{
+       Hash: "SHA1",
+       Body: []byte(`Content-Type: text/plain.epoint.cert; charset=utf-8
+
+A: foo
+B: bar
+a: baz`),
+       ArmoredSignature: []byte(`-----BEGIN PGP SIGNATURE-----
+
+sig
+-----END PGP SIGNATURE-----
+`)}
+
+var C2 = &ClearSigned{
+       Hash: "SHA1",
+       Body: []byte(`Content-Type: text/plain.epoint.cert; charset=utf-8
+
+A: foo
+B: bar
+a: baz
+`),
+       ArmoredSignature: []byte(`-----BEGIN PGP SIGNATURE-----
+
+sig
+-----END PGP SIGNATURE-----
+`)}
+
+var F = map[string]string{
+       "A": "foo",
+       "B": "bar",
+       "a": "baz",
+}
+
+var testData = []struct {
+       D []byte
+       C *ClearSigned
+       T string
+       F map[string]string
+}{
+       {[]byte(D1), C1, "cert", F},
+       {[]byte(D2), C2, "cert", F},
+}
+
+func eqClearSigned(c1, c2 *ClearSigned) bool {
+       return c1.Hash == c2.Hash &&
+               string(c1.Body) == string(c2.Body) &&
+               string(c1.ArmoredSignature) == string(c2.ArmoredSignature)
+}
+
+func eqFields(f1, f2 map[string]string) bool {
+       for k,v := range f1 {
+               if f2[k] != v {
+                       return false
+               }
+       }
+       return len(f1) == len(f2)
+}
+
+func TestClearSigned(t *testing.T) {
+       for _, x := range testData {
+               c, err := DecodeClearSigned(x.D)
+               if err != nil {
+                       t.Errorf("decoding %#v failed: %s\n", x.D, err)
+                       continue
+               }
+               if !eqClearSigned(c, x.C) {
+                       t.Errorf("expected: %#v, got %#v\n", x.C, c)
+               }
+       }
+       for _, x := range testData {
+               d, err := EncodeClearSigned(x.C)
+               if err != nil {
+                       t.Errorf("encoding %#v failed: %s\n", x.C, err)
+                       continue
+               }
+               if string(d) != string(x.D) {
+                       t.Errorf("expected: %#v, got %#v\n", x.D, d)
+               }
+       }
+}
+
+func TestParse(t *testing.T) {
+       for _, x := range testData {
+               tt, f, err := ParseBody(x.C.Body)
+               if err != nil {
+                       t.Errorf("parsing %s failed: %s\n", x.C.Body, err)
+                       continue
+               }
+               if !eqFields(f, x.F) {
+                       t.Errorf("expected fields %#v, got %#v\n", x.F, f)
+               }
+               if tt != x.T {
+                       t.Errorf("expected type %s, got %s\n", x.T, tt)
+               }
+       }
+}
+
+const draftBody = `Content-Type: text/plain.epoint.draft; charset=utf-8
+
+Drawer: 000000000000000000000000000000000000000a
+Beneficiary: 000000000000000000000000000000000000000b
+Amount: 1
+Denomination: half euro
+IssueDate: 2
+MaturityDate: 3
+Notes: some notes
+Nonce: 42
+Server: 000000000000000000000000000000000000000c
+Drawee: 000000000000000000000000000000000000000d
+`
+
+func TestDraft(t *testing.T) {
+       d, err := ParseDraft([]byte(draftBody))
+       if err != nil {
+               t.Errorf("parse %q draft failed: %s\n", draftBody, err)
+       }
+       s, err := RenderDraft(d)
+       if err != nil {
+               t.Errorf("render %v draft failed: %s\n", d, err)
+       }
+       if string(s) != draftBody {
+               t.Errorf("parsed %#v, expected %#v\n", d, draftBody)
+       }
+}