diff --git a/docs/architecture/adr-050-sign-mode-textual.md b/docs/architecture/adr-050-sign-mode-textual.md index dc400a070717..be739745f21a 100644 --- a/docs/architecture/adr-050-sign-mode-textual.md +++ b/docs/architecture/adr-050-sign-mode-textual.md @@ -8,6 +8,7 @@ - Aug 11, 2022: Require signing over tx raw bytes. - Sep 07, 2022: Add custom `Msg`-renderers. - Sep 18, 2022: Structured format instead of lines of text +- Nov 23, 2022: Specify CBOR encoding. ## Status @@ -127,8 +128,31 @@ type SignDocTextual = []Screen We do not plan to use protobuf serialization to form the sequence of bytes that will be tranmitted and signed, in order to keep the decoder simple. We will use [CBOR](https://cbor.io) ([RFC 8949](https://www.rfc-editor.org/rfc/rfc8949.html)) instead. +The encoding is defined by the following CDDL ([RFC 8610](https://www.rfc-editor.org/rfc/rfc8610)): -TODO: specify the details of the CBOR encoding. +``` +;;; CDDL (RFC 8610) Specification of SignDoc for SIGN_MODE_TEXTUAL. +;;; Must be encoded using CBOR deterministic encoding (RFC 8949, section 4.2.1). + +;; A Textual document is an array of screens. +screens = [* screen] + +;; A screen consists of a text string, an indentation, and the expert flag, +;; represented as an integer-keyed map. All entries are optional +;; and MUST be omitted from the encoding if empty, zero, or false. +;; Text defaults to the empty string, indent defaults to zero, +;; and expert defaults to false. +screen = { + ? text_key: tstr, + ? indent_key: uint, + ? expert_key: bool, +} + +;; Keys are small integers to keep the encoding small. +text_key = 1 +indent_key = 2 +expert_key = 3 +``` ## Details diff --git a/tx/textual/internal/cbor/cbor.go b/tx/textual/internal/cbor/cbor.go new file mode 100644 index 000000000000..1cf64d0b6b04 --- /dev/null +++ b/tx/textual/internal/cbor/cbor.go @@ -0,0 +1,238 @@ +// Package cbor implements just enough of the CBOR (Concise Binary Object +// Representation, RFC 8948) to deterministically encode simple data. +package cbor + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "math" + "sort" +) + +const ( + major_uint byte = 0 + major_negint byte = 1 + major_byte_string byte = 2 + major_text_string byte = 3 + major_array byte = 4 + major_map byte = 5 + major_tagged byte = 6 + major_simple byte = 7 +) + +func encode_first_byte(major byte, extra byte) byte { + return (major << 5) | extra&0x1F +} + +func encode_prefix(major byte, arg uint64, w io.Writer) error { + switch { + case arg < 24: + _, err := w.Write([]byte{encode_first_byte(major, byte(arg))}) + return err + case arg <= math.MaxUint8: + _, err := w.Write([]byte{encode_first_byte(major, 24), byte(arg)}) + return err + case arg <= math.MaxUint16: + _, err := w.Write([]byte{encode_first_byte(major, 25)}) + if err != nil { + return err + } + // #nosec G701 + // Since we're under the limit, narrowing is safe. + return binary.Write(w, binary.BigEndian, uint16(arg)) + case arg <= math.MaxUint32: + _, err := w.Write([]byte{encode_first_byte(major, 26)}) + if err != nil { + return err + } + // #nosec G701 + // Since we're under the limit, narrowing is safe. + return binary.Write(w, binary.BigEndian, uint32(arg)) + } + _, err := w.Write([]byte{encode_first_byte(major, 27)}) + if err != nil { + return err + } + return binary.Write(w, binary.BigEndian, arg) +} + +// Cbor is a CBOR (RFC8949) data item that can be encoded to a stream. +type Cbor interface { + // Encode deterministically writes the CBOR-encoded data to the stream. + Encode(w io.Writer) error +} + +// Uint is the CBOR unsigned integer type. +type Uint uint64 + +// NewUint returns a CBOR unsigned integer data item. +func NewUint(n uint64) Uint { + return Uint(n) +} + +var _ Cbor = NewUint(0) + +// Encode implements the Cbor interface. +func (n Uint) Encode(w io.Writer) error { + // #nosec G701 + // Widening is safe. + return encode_prefix(major_uint, uint64(n), w) +} + +// Text is the CBOR text string type. +type Text string + +// NewText returns a CBOR text string data item. +func NewText(s string) Text { + return Text(s) +} + +var _ Cbor = NewText("") + +// Encode implements the Cbor interface. +func (s Text) Encode(w io.Writer) error { + err := encode_prefix(major_text_string, uint64(len(s)), w) + if err != nil { + return err + } + _, err = w.Write([]byte(string(s))) + return err +} + +// Array is the CBOR array type. +type Array struct { + elts []Cbor +} + +// NewArray reutnrs a CBOR array data item, +// containing the specified elements. +func NewArray(elts ...Cbor) Array { + return Array{elts: elts} +} + +var _ Cbor = NewArray() + +// Append appends CBOR data items to an existing Array. +func (a Array) Append(c Cbor) Array { + a.elts = append(a.elts, c) + return a +} + +// Encode implements the Cbor interface. +func (a Array) Encode(w io.Writer) error { + err := encode_prefix(major_array, uint64(len(a.elts)), w) + if err != nil { + return err + } + for _, elt := range a.elts { + err = elt.Encode(w) + if err != nil { + return err + } + } + return nil +} + +// Entry is a key/value pair in a CBOR map. +type Entry struct { + key, val Cbor +} + +// NewEntry returns a CBOR key/value pair for use in a Map. +func NewEntry(key, val Cbor) Entry { + return Entry{key: key, val: val} +} + +// Map is the CBOR map type. +type Map struct { + entries []Entry +} + +// NewMap returns a CBOR map data item containing the specified entries. +// Duplicate keys in the Map will cause an error when Encode is called. +func NewMap(entries ...Entry) Map { + return Map{entries: entries} +} + +// Add adds a key/value entry to an existimg Map. +// Duplicate keys in the Map will cause an error when Encode is called. +func (m Map) Add(key, val Cbor) Map { + m.entries = append(m.entries, NewEntry(key, val)) + return m +} + +type keyIdx struct { + key []byte + idx int +} + +// Encode implements the Cbor interface. +func (m Map) Encode(w io.Writer) error { + err := encode_prefix(major_map, uint64(len(m.entries)), w) + if err != nil { + return err + } + // For deterministic encoding, map entries must be sorted by their + // encoded keys in bytewise lexicographic order (RFC 8949, section 4.2.1). + renderedKeys := make([]keyIdx, len(m.entries)) + for i, entry := range m.entries { + var buf bytes.Buffer + err := entry.key.Encode(&buf) + if err != nil { + return err + } + renderedKeys[i] = keyIdx{key: buf.Bytes(), idx: i} + } + sort.SliceStable(renderedKeys, func(i, j int) bool { + return bytes.Compare(renderedKeys[i].key, renderedKeys[j].key) < 0 + }) + var prevKey []byte + for i, rk := range renderedKeys { + if i > 0 && bytes.Equal(prevKey, rk.key) { + return fmt.Errorf("duplicate map keys at %d and %d", rk.idx, renderedKeys[i-1].idx) + } + prevKey = rk.key + _, err = w.Write(rk.key) + if err != nil { + return err + } + err = m.entries[rk.idx].val.Encode(w) + if err != nil { + return err + } + } + return nil +} + +const ( + simple_false byte = 20 + simple_true byte = 21 + simple_null byte = 22 + simple_undefined byte = 32 +) + +func encodeSimple(b byte, w io.Writer) error { + // #nosec G701 + // Widening is safe. + return encode_prefix(major_simple, uint64(b), w) +} + +// Bool is the type of CBOR booleans. +type Bool byte + +// NewBool returns a CBOR boolean data item. +func NewBool(b bool) Bool { + if b { + return Bool(simple_true) + } + return Bool(simple_false) +} + +var _ Cbor = NewBool(false) + +// Encode implements the Cbor interface. +func (b Bool) Encode(w io.Writer) error { + return encodeSimple(byte(b), w) +} diff --git a/tx/textual/internal/cbor/cbor_test.go b/tx/textual/internal/cbor/cbor_test.go new file mode 100644 index 000000000000..5f3250d4e66b --- /dev/null +++ b/tx/textual/internal/cbor/cbor_test.go @@ -0,0 +1,103 @@ +package cbor_test + +import ( + "bytes" + "encoding/hex" + "fmt" + "testing" + + "cosmossdk.io/tx/textual/internal/cbor" + "github.com/stretchr/testify/require" +) + +var ( + ui = cbor.NewUint + txt = cbor.NewText + arr = cbor.NewArray + mp = cbor.NewMap + ent = cbor.NewEntry +) + +func TestCborRFC(t *testing.T) { + for i, tc := range []struct { + cb cbor.Cbor + encoding string + expectError bool + }{ + // Examples come from RFC8949, Appendix A + {cb: ui(0), encoding: "00"}, + {cb: ui(1), encoding: "01"}, + {cb: ui(10), encoding: "0a"}, + {cb: ui(23), encoding: "17"}, + {cb: ui(24), encoding: "1818"}, + {cb: ui(25), encoding: "1819"}, + {cb: ui(100), encoding: "1864"}, + {cb: ui(1000), encoding: "1903e8"}, + {cb: ui(1000000), encoding: "1a000f4240"}, + {cb: ui(1000000000000), encoding: "1b000000e8d4a51000"}, + {cb: ui(18446744073709551615), encoding: "1bffffffffffffffff"}, + {cb: cbor.NewBool(false), encoding: "f4"}, + {cb: cbor.NewBool(true), encoding: "f5"}, + {cb: txt(""), encoding: "60"}, + {cb: txt("a"), encoding: "6161"}, + {cb: txt("IETF"), encoding: "6449455446"}, + {cb: txt("\"\\"), encoding: "62225c"}, + {cb: txt("\u00fc"), encoding: "62c3bc"}, + {cb: txt("\u6c34"), encoding: "63e6b0b4"}, + // Go doesn't like string literals with surrogate pairs, create manually + {cb: txt(string([]byte{0xf0, 0x90, 0x85, 0x91})), encoding: "64f0908591"}, + {cb: arr(), encoding: "80"}, + {cb: arr(ui(1), ui(2)).Append(ui(3)), encoding: "83010203"}, + { + cb: arr(ui(1)). + Append(arr(ui(2), ui(3))). + Append(arr().Append(ui(4)).Append(ui(5))), + encoding: "8301820203820405", + }, + { + cb: arr( + ui(1), ui(2), ui(3), ui(4), ui(5), + ui(6), ui(7), ui(8), ui(9), ui(10), + ui(11), ui(12), ui(13), ui(14), ui(15), + ui(16), ui(17), ui(18), ui(19), ui(20), + ui(21), ui(22), ui(23), ui(24), ui(25)), + encoding: "98190102030405060708090a0b0c0d0e0f101112131415161718181819", + }, + {cb: mp(), encoding: "a0"}, + {cb: mp(ent(ui(1), ui(2))).Add(ui(3), ui(4)), encoding: "a201020304"}, + {cb: mp(ent(txt("a"), ui(1)), ent(txt("b"), arr(ui(2), ui(3)))), encoding: "a26161016162820203"}, + {cb: arr(txt("a"), mp(ent(txt("b"), txt("c")))), encoding: "826161a161626163"}, + { + cb: mp( + ent(txt("a"), txt("A")), + ent(txt("b"), txt("B")), + ent(txt("c"), txt("C")), + ent(txt("d"), txt("D")), + ent(txt("e"), txt("E"))), + encoding: "a56161614161626142616361436164614461656145", + }, + // Departing from the RFC + {cb: mp(ent(ui(1), ui(2)), ent(ui(1), ui(2))), expectError: true}, + // Map has deterministic order based on key encoding + { + cb: mp( + ent(txt("aa"), ui(0)), + ent(txt("a"), ui(2)), + ent(ui(1), txt("b"))), + encoding: "a301616261610262616100", + }, + } { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + var buf bytes.Buffer + err := tc.cb.Encode(&buf) + if tc.expectError { + require.Error(t, err) + return + } + require.NoError(t, err) + want, err := hex.DecodeString(tc.encoding) + require.NoError(t, err) + require.Equal(t, want, buf.Bytes()) + }) + } +} diff --git a/tx/textual/internal/testdata/encode.json b/tx/textual/internal/testdata/encode.json new file mode 100644 index 000000000000..8c565a6deaa0 --- /dev/null +++ b/tx/textual/internal/testdata/encode.json @@ -0,0 +1,38 @@ +[ + { + "screens": [], + "encoding": "80" + }, + { + "screens": [{}], + "encoding": "81a0" + }, + { + "screens": [{"text": ""}, {"indent": 0}, {"expert": false}], + "encoding": "83a0a0a0" + }, + { + "screens": [ + {"text": "a"}, + {"indent": 1}, + {"expert": true} + ], + "encoding": "83a1016161a10201a103f5" + }, + { + "screens": [ + {"text": "", "indent": 4, "expert": true}, + {"text": "a", "indent": 0, "expert": true}, + {"text": "b", "indent": 5, "expert": false} + ], + "encoding": "83a2020403f5a201616103f5a20161620205" + }, + { + "screens": [ + {"text": "start"}, + {"text": "middle", "indent": 1}, + {"text": "end"} + ], + "encoding": "83a101657374617274a201666d6964646c650201a10163656e64" + } +] diff --git a/tx/textual/valuerenderer/encode.go b/tx/textual/valuerenderer/encode.go new file mode 100644 index 000000000000..e71bfd20255d --- /dev/null +++ b/tx/textual/valuerenderer/encode.go @@ -0,0 +1,50 @@ +package valuerenderer + +import ( + "io" + + "cosmossdk.io/tx/textual/internal/cbor" +) + +var ( + textKey = cbor.NewUint(1) + indentKey = cbor.NewUint(2) + expertKey = cbor.NewUint(3) +) + +// encode encodes an array of screens according to the CDDL: +// +// screens = [* screen] +// screen = { +// ? text_key: tstr, +// ? indent_key: uint, +// ? expert_key: bool, +// } +// text_key = 1 +// indent_key = 2 +// expert_key = 3 +// +// with empty values ("", 0, false) omitted from the screen map. +func encode(screens []Screen, w io.Writer) error { + arr := cbor.NewArray() + for _, s := range screens { + arr = arr.Append(s.Cbor()) + } + return arr.Encode(w) +} + +func (s Screen) Cbor() cbor.Cbor { + m := cbor.NewMap() + if s.Text != "" { + m = m.Add(textKey, cbor.NewText(s.Text)) + } + if s.Indent > 0 { + // #nosec G701 + // Since we've excluded negatives, int widening is safe. + m = m.Add(indentKey, cbor.NewUint(uint64(s.Indent))) + } + if s.Expert { + m = m.Add(expertKey, cbor.NewBool(s.Expert)) + } + return m +} diff --git a/tx/textual/valuerenderer/encode_test.go b/tx/textual/valuerenderer/encode_test.go new file mode 100644 index 000000000000..9cb15a18815c --- /dev/null +++ b/tx/textual/valuerenderer/encode_test.go @@ -0,0 +1,37 @@ +package valuerenderer + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +type encodingJsonTest struct { + Screens []Screen + Encoding string +} + +func TestEncodingJson(t *testing.T) { + raw, err := os.ReadFile("../internal/testdata/encode.json") + require.NoError(t, err) + + var testcases []encodingJsonTest + err = json.Unmarshal(raw, &testcases) + require.NoError(t, err) + + for i, tc := range testcases { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + var buf bytes.Buffer + err := encode(tc.Screens, &buf) + require.NoError(t, err) + want, err := hex.DecodeString(tc.Encoding) + require.NoError(t, err) + require.Equal(t, want, buf.Bytes()) + }) + } +}