Skip to content

Commit

Permalink
feat: deterministic CBOR encoding of textual rendering (#13697)
Browse files Browse the repository at this point in the history
* feat: deterministic CBOR encoding of textual rendering

* refactor: cbor package to internal, test cases as json

* chore: silence spurious gosec warnings

* docs: review feedback
  • Loading branch information
JimLarson committed Nov 28, 2022
1 parent c6189bb commit 4fe7403
Show file tree
Hide file tree
Showing 6 changed files with 491 additions and 1 deletion.
26 changes: 25 additions & 1 deletion docs/architecture/adr-050-sign-mode-textual.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
238 changes: 238 additions & 0 deletions tx/textual/internal/cbor/cbor.go
Original file line number Diff line number Diff line change
@@ -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)
}
103 changes: 103 additions & 0 deletions tx/textual/internal/cbor/cbor_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
}
}

0 comments on commit 4fe7403

Please sign in to comment.