feat: deterministic CBOR encoding of textual rendering (#13697)

* feat: deterministic CBOR encoding of textual rendering

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

* chore: silence spurious gosec warnings

* docs: review feedback
This commit is contained in:
Jim Larson 2022-11-28 09:34:46 -08:00 committed by GitHub
parent c6189bb630
commit 4fe7403f83
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 491 additions and 1 deletions

View File

@ -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

View File

@ -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)
}

View File

@ -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())
})
}
}

View File

@ -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"
}
]

View File

@ -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
}

View File

@ -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())
})
}
}