From bcab164584653fd264f4ceee5dd65f03a1f6cc35 Mon Sep 17 00:00:00 2001 From: nsz Date: Sun, 13 Nov 2011 01:11:02 +0100 Subject: [PATCH] initial document design --- document/Makefile | 6 + document/document.go | 360 ++++++++++++++++++++++++++++++++++++++ document/document_test.go | 158 +++++++++++++++++ 3 files changed, 524 insertions(+) create mode 100644 document/Makefile create mode 100644 document/document.go create mode 100644 document/document_test.go diff --git a/document/Makefile b/document/Makefile new file mode 100644 index 0000000..7a52d21 --- /dev/null +++ b/document/Makefile @@ -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 index 0000000..46f41ab --- /dev/null +++ b/document/document.go @@ -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 index 0000000..45cac1f --- /dev/null +++ b/document/document_test.go @@ -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) + } +} -- 2.20.1