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:
parent
c6189bb630
commit
4fe7403f83
@ -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
|
||||
|
||||
|
||||
238
tx/textual/internal/cbor/cbor.go
Normal file
238
tx/textual/internal/cbor/cbor.go
Normal 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)
|
||||
}
|
||||
103
tx/textual/internal/cbor/cbor_test.go
Normal file
103
tx/textual/internal/cbor/cbor_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
38
tx/textual/internal/testdata/encode.json
vendored
Normal file
38
tx/textual/internal/testdata/encode.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
50
tx/textual/valuerenderer/encode.go
Normal file
50
tx/textual/valuerenderer/encode.go
Normal 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
|
||||
}
|
||||
37
tx/textual/valuerenderer/encode_test.go
Normal file
37
tx/textual/valuerenderer/encode_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user