Skip to content

Commit e0a6396

Browse files
authored
mpt: shorten, generalize proofs (API and wire format change) (#79)
As suggested in rsc/tmp#21 and #75, shorten proofs to remove the mptproof prefix, which costs 8 bytes per proof for little benefit. In fact, if we are going to strip non-essential information, then the single-byte prefix at the start of the proof is also unnecessary, so this commit removes that too. While we are changing the proof format, also use a varint-encoded length prefix ahead of keys and values, so that the wire format of proofs applies to any size keys and values, even though this API still assumes [32]byte. (Followup work will revise the package API to allow variable-size keys and values.) The handling of variable-length keys is slightly subtle since we have to pad keys of different lengths to make them comparable for insertion in the tree, but we want to avoid introducing any ambiguity where two different keys pad to the same bit sequence. The answer is to pad with a sequence that always differs from itself when not exactly aligned (so not all 0s or all 1s or any other purely repeating sequence). Working within that constraint, the chosen key padding is a 0x00 byte followed by as many 0xFF bytes as needed. The 0x00 ensures that NUL-free text keys sort in the usual order in the tree, while the 0xFFs that follow ensure that keys of other lengths (even keys ending in 0x00 or 0xFF) will not have the same padding at the same positions. The previous Prove and Verify signatures were: Prove(key Key) (proof Proof, err error) Verify(snap Snapshot, key Key, proof Proof) (val Val, ok bool, err error) Now they are: Prove(key Key) (val Val, ok bool, proof Proof, err error) Verify(snap Snapshot, key Key, val Val, ok bool, proof Proof) error The invariant maintained is that Verify is passed the snapshot plus all the results of Prove. The difference is that now the val, ok are returned by Prove instead of Verify. Of course, they should not be trusted until Prove has succeeded. This commit also adds VerifyPresent and VerifyNotPresent helpers wrapping Verify, which can improve clarity at some call sites. This commit also adds test vectors for Verify in testdata/verify.txt, for easier use by other implementations.
1 parent d6a08ab commit e0a6396

7 files changed

Lines changed: 2374 additions & 306 deletions

File tree

mpt/DESIGN.md

Lines changed: 9 additions & 176 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Analogous to a [transparent log](https://research.swtch.com/tlog),
88
an MPT can cryptographically prove that a given key-value pair exists
99
(or that a key does not exist) in a given tree root.
1010
By recording the sequence of tree roots in a transparent log,
11-
a server can publish an record of the history of a key-value database,
11+
a server can publish a record of the history of a key-value database,
1212
in such a way that auditors can check that the database was correct
1313
at all times, and clients can be sure the responses they received
1414
came from the recorded database history.
@@ -67,80 +67,16 @@ After describing the fully in-memory version, we describe the hybrid version.
6767

6868
## Merkle Patricia Tree Overview {#mpt}
6969

70-
An MPT starts with the concept of a binary tree of depth 256, where the key-value
71-
pairs are stored in the leaves at depth 256, and a lookup proceeds by walking
72-
left or right according to each of the 256 key bits. The root node represents
73-
the empty key prefix, its children represent key bit prefixes 0 and 1, their
74-
children represent key bit prefixes 00, 01, 10, 11, and so on: at depth d,
75-
the nodes represent key prefixes of d bits.
76-
The original Key Transparency system at Google used exactly this data structure,
77-
a [Merkle-hashed binary radix tree](https://github.com/google/keytransparency/blob/master/docs/overview.md).
78-
Since then, the transparency community has realized that it works better
79-
to apply Merkle hashing to a Patricia tree,
80-
which adds three optimizations to the binary radix tree.
81-
82-
First, the tree is “path-compressed,” by removing inner nodes with a single child:
83-
a node that would have pointed at a single-child node is replaced by its child,
84-
recursively. Every node is therefore either a leaf or an inner node with two children.
85-
The path compression ensures that there are exactly _N_ inner nodes for a tree with _N_+1 leaf nodes.
86-
87-
Second, unlike in a normal binary tree, an inner node stores only the bit position
88-
that determines whether a lookup should proceed to the left or right child.
89-
A lookup walks inner nodes down to some leaf, checking one bit at each step.
90-
Only upon reaching the leaf does it do a full key comparison.
91-
If it takes _O_(_K_) time to compare two keys, a normal binary tree would
92-
take _O_(_K_ log _N_) time for a walk; this optimization
93-
cuts the time to _O_(_K_ + log _N_).
94-
Furthermore, inner nodes need not store associated keys,
95-
cutting the number of stored keys by a factor of two.
96-
97-
Third, nodes are “joined” by merging one inner node and one leaf node into
98-
a single stored node.
99-
(After joining _N_ inner nodes to _N_ leaf nodes, that leaves one “leaf-only”
100-
node not paired to an inner node,
101-
but the node is still stored using the joined representation.)
102-
Whether a stored node represents an inner node or leaf node depends
103-
on how it is reached
104-
while walking the tree.
105-
This trick is not essential, but it simplifies storage management
106-
to have only one type of stored node.
107-
108-
The path-compression optimization implies that an inner node for key prefix _p_ exists
109-
if and only if the tree contains at least one key with prefix _p_0 and at least one key with prefix _p_1.
110-
That is, the specific inner nodes that exist in a Patricia depend only on which
111-
keys are present in the tree, not on their insertion order.
112-
This implies that we can batch or otherwise reorder insertions of distinct keys
113-
without affecting the final tree structure.
114-
115-
For more about standard Patricia trees, see TODO REFERENCE.
116-
117-
Cominbing the Merkle and Patricia pieces, a Merkle Patricia Tree provides the following operations:
70+
See the doc comment at the top of tree.go for details about the MPT data structure and proofs.
71+
72+
A Merkle Patricia Tree provides the following operations:
11873

11974
- Set(key, val): add a new key-value pair to the map.
120-
- Snap(version): set the tree's version and return the tree root's key prefix and hash.
121-
- Prove(key): return a proof of the result of looking up a given key in the current snapshot. A separate library function `Verify` verifies a proof and returns the lookup result (whether the key was found and, if so, its associated value).
75+
- Snap(version): set the tree's version and return the tree root's hash.
76+
- Prove(key): return a lookup result (a value and whether the key was found), along with proof of the result.
77+
A separate library function `Verify` verifies the result using the proof.
12278
- Sync(): flush recent changes to disk.
123-
124-
The recursive hash of an MPT is defined as follows:
125-
126-
- The hash of a leaf node is the hash of its key and value.
127-
- The hash of an inner node is the hash of its bit position and its left and right children's hashes.
128-
129-
A proof confirming that a key-value pair exists in an MPT with a given recursive hash
130-
is the value followed by the bit position and sibling hash for every inner node along the path
131-
back to the root.
132-
The key-value pair can be hashed to obtain the hash of the leaf node,
133-
and then the running hash can be hashed with the parent's bit position
134-
and sibling hash to obtain the hash of the next inner node toward the root.
135-
(Whether the running hash is the left or right child is determined by checking
136-
the specified bit of the key.)
137-
Recomputing the root's actual hash proves the lookup.
138-
139-
A proof denying that a target key exists in an MPT is almost identical.
140-
It consists of the “other key” whose leaf would be found by looking up the key in the tree,
141-
followed by the proof that that other key is in the tree.
142-
The verification checks that the other key's proof is valid and also that
143-
the target key and other key agree at every relevant bit position.
79+
- Predict(keyvals): return the snapshot hash that would result from adding all the keyvals to the map.
14480

14581
An in-memory MPT implementation is in [mem.go](mem.go).
14682
It was useful to write and debug that version before adding the
@@ -150,112 +86,9 @@ that version first.
15086
It may also be useful read and understand that implementation
15187
before proceeding to the disk implementation.
15288

153-
## Encoding Details {#encoding}
154-
155-
Any MPT implementation must define the exact encodings it uses.
156-
The encodings used by this package are as follows.
157-
158-
### Tree Hashes
159-
160-
An empty tree is a special case that is otherwise independent
161-
of the tree hash definition. In this implementation,
162-
the hash of an empty tree is SHA256(_e_), the hash of the empty string (e3b0c442...7852b855).
163-
164-
The hash of a leaf node is the hash of the concatenation of the key and value
165-
(both fixed-size 32-byte sequences).
166-
167-
The hash of an inner node at bit position _b_ with left and right child hashes _left_ and _right_
168-
is SHA256(_left_ || _right_ || _b_) where _left_ and _right_ are 32-byte values and _b_ is a one-byte value.
169-
170-
### Proofs
171-
172-
Proofs are variable length strings beginning with the 8-byte sequence `mptproof`.
173-
174-
In Go the verifier's signature is:
175-
176-
func Verify(snap Snapshot, key Key, proof Proof) (val Val, ok bool, err error)
177-
178-
The verifier is given a tree hash (called a snapshot), a specific key, and a proof,
179-
and it returns three results: (1) the value associated with the key,
180-
if the proof proved the existence of the key in the tree,
181-
(2) whether the proved result confirms or denies the existence of the key,
182-
and (3) an error if the proof was invalid or did not match the tree hash.
183-
184-
A proof of the empty tree is `mptproof` followed by a 0x00 byte.
185-
It only applies when the tree hash is the empty tree hash,
186-
and it disproves the existence of all possible keys.
187-
188-
A proof confirming the existence of a key starts with `mptproof` followed by a 0x01 byte
189-
and then a 32-byte value _v_.
190-
The hash of the key's leaf node can be recomputed as _h_ = SHA256(_key_ || _v_).
191-
If the tree is a single node, that is the entire proof: the verifier must check that
192-
_h_ = _snap_.
193-
If the tree contains more than one node, the proof continues with
194-
one or more descriptions of sibling nodes along the path back to the tree root.
195-
Each sibling node is encoded as 33 bytes: a one-byte bit position _b_
196-
followed by a 32-byte sibling hash _sib_.
197-
The verifier must check whether the _b_'th bit of _key_ is 0 or 1
198-
and then update the running tree hash accordingly:
199-
200-
- _h_ = SHA256(_h_ || _sib_ || _b_) if bit _b_ of _key_ is 0, or
201-
- _h_ = SHA256(_sib_ || _h_ || _b_) if bit _b_ of _key_ is 1.
202-
203-
Then, as before, the recomputed tree hash _h_ can be
204-
compared against the actual tree hash.
205-
206-
A proof denying the existence of a key starts with `mptproof` followed by a 0x02 byte
207-
and then a 32-byte key _k_ and 32-byte value _v_,
208-
describing a leaf node with hash _h_ = SHA256(_k_ || _v_).
209-
If the tree is a single node, that is the entire proof: the verifier
210-
must check that _h_ = _snap_ and that _k__key_.
211-
Otherwise the proof format contains one or more siblings
212-
encoded exactly as in the the existence proofs.
213-
Verification also proceeds as in the existence proofs,
214-
checking along the way that _k_ and _key_ agree on every bit _b_.
215-
(Otherwise the proof would not describe the path taken
216-
to walk through the tree in search of _key_.)
217-
At the end, the verifier must check that _h_ = _snap_ and that _k__key_.
218-
219-
The worst case length of an existence proof is 8+1+32+33*256 = 8489 bytes,
220-
although random keys will never produce a path of length 256.
221-
222-
The worst case length of a non-existence proof is 8+1+64+33*255 = 8488 bytes.
223-
A non-existence proof can only have 255 siblings because otherwise the proof
224-
would describe a key _k_ that agrees with _key_ at all 256 bit positions,
225-
but then _k__key_ could not be true.
226-
Again, random keys will never produce a path length of 256.
227-
228-
Note: This encoding is considerably more compact than some others.
229-
For example, the Rust akd crate's [MembershipProof](https://docs.rs/akd/0.12.0/akd/struct.MembershipProof.html) is:
230-
231-
pub struct MembershipProof {
232-
pub label: NodeLabel,
233-
pub hash_val: AzksValue,
234-
pub sibling_proofs: Vec<SiblingProof>,
235-
}
236-
237-
[NodeLabel](https://docs.rs/akd/0.12.0/akd/struct.NodeLabel.html) is a key plus a bit length, 32+4 = 36 bytes. \
238-
[AzksValue](https://docs.rs/akd/0.12.0/akd/struct.AzksValue.html) is 32 bytes. \
239-
[SiblingProof](https://docs.rs/akd/0.12.0/akd/struct.SiblingProof.html) is:
240-
241-
pub struct SiblingProof {
242-
pub label: NodeLabel,
243-
pub siblings: [AzksElement; 1],
244-
pub direction: Direction,
245-
}
246-
247-
[AzksElement](https://docs.rs/akd/0.12.0/akd/struct.AzksElement.html) is a NodeLabel and AzksValue, 64 bytes. \
248-
[Direction](https://docs.rs/akd/0.12.0/akd/enum.Direction.html) is a single byte.
249-
250-
So SiblingProof is 36+64+1 = 101 bytes, and the overall worst case MembershipProof, if there are 256 siblings,
251-
is 36+32+101*256 = 25,924 bytes.
252-
The largest contributor to the difference is that the siblings include two node labels
253-
when zero node labels suffice.
254-
The result is a factor of three in the size of the proofs generated (and sent over the network).
255-
25689
## Tree Algorithms
25790

258-
There are two potentially important computations MPT hashes
91+
There are two potentially important computations on MPT hashes
25992
that can be done without creating an explicit tree representation.
26093

26194
### Whole Tree Hash

mpt/dmem.go

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44

55
package mpt
66

7-
import "fmt"
7+
import (
8+
"encoding/binary"
9+
"fmt"
10+
)
811

912
// hash returns the hash for the given tree node.
1013
// pbit is the parent bit depth, controlling whether n is viewed as a leaf.
@@ -335,41 +338,43 @@ func (t *diskTree) predict(s []node, a addr, pbit int, list []KeyVal) ([]node, [
335338
}
336339

337340
// Prove returns a proof of the presence or absence of key in t.
338-
func (t *diskTree) Prove(key Key) (Proof, error) {
341+
func (t *diskTree) Prove(key Key) (val Val, ok bool, proof Proof, err error) {
339342
t.mmu.RLock()
340343
defer t.mmu.RUnlock()
341344

342345
if t.err != nil {
343-
return nil, t.err
346+
return Val{}, false, nil, t.err
344347
}
345348
if t.hdr().dirty() {
346-
return nil, ErrModifiedTree
349+
return Val{}, false, nil, ErrModifiedTree
347350
}
348351
root, err := t.node(t.hdr().root())
349352
if err != nil {
350-
return nil, err
353+
return Val{}, false, nil, err
351354
}
352355
if root == nil {
353-
return Proof(proofEmpty), nil
356+
return Val{}, false, Proof{}, nil
354357
}
355358
return root.prove(t, -1, key)
356359
}
357360

358-
func (n *diskNode) prove(t *diskTree, pbit int, key Key) (Proof, error) {
361+
func (n *diskNode) prove(t *diskTree, pbit int, key Key) (val Val, ok bool, proof Proof, err error) {
359362
nbit := n.bit()
360363
if nbit <= pbit {
361364
// view n as leaf
362365
nkey, nval, err := n.keyVal(t)
363366
if err != nil {
364-
return nil, err
367+
return Val{}, false, nil, err
365368
}
366-
var p Proof
367369
if nkey == key {
368-
p = Proof(proofConfirm)
369-
} else {
370-
p = append(Proof(proofDeny), nkey[:]...)
370+
return nval, true, Proof{}, nil
371371
}
372-
return append(p, nval[:]...), nil
372+
var p Proof
373+
p = binary.AppendUvarint(p, uint64(len(nkey)))
374+
p = append(p, nkey[:]...)
375+
p = binary.AppendUvarint(p, uint64(len(nval)))
376+
p = append(p, nval[:]...)
377+
return Val{}, false, p, nil
373378
}
374379

375380
childAddr, sibAddr := n.left(), n.right()
@@ -378,22 +383,24 @@ func (n *diskNode) prove(t *diskTree, pbit int, key Key) (Proof, error) {
378383
}
379384
child, err := t.node(childAddr)
380385
if err != nil {
381-
return nil, err
386+
return Val{}, false, nil, err
382387
}
383388
sib, err := t.node(sibAddr)
384389
if err != nil {
385-
return nil, err
390+
return Val{}, false, nil, err
386391
}
387392
sibHash, err := sib.hash(t, nbit)
388393
if err != nil {
389-
return nil, err
394+
return Val{}, false, nil, err
390395
}
391396

392-
p, err := child.prove(t, nbit, key)
397+
val, ok, proof, err = child.prove(t, nbit, key)
393398
if err != nil {
394-
return nil, err
399+
return
395400
}
396-
return append(append(p, byte(nbit)), sibHash[:]...), nil
401+
proof = binary.AppendUvarint(proof, uint64(nbit))
402+
proof = append(proof, sibHash[:]...)
403+
return
397404
}
398405

399406
func (t *diskTree) check() {

mpt/mem.go

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package mpt
66

77
import (
8+
"encoding/binary"
89
"errors"
910
"fmt"
1011
)
@@ -244,30 +245,32 @@ func (t *memTree) predict(s []node, n *memNode, pbit int, list []KeyVal) ([]node
244245
}
245246

246247
// Prove returns a proof of the presence or absence of key in t.
247-
func (t *memTree) Prove(key Key) (Proof, error) {
248+
func (t *memTree) Prove(key Key) (val Val, ok bool, proof Proof, err error) {
248249
if t.err != nil {
249-
return nil, t.err
250+
return Val{}, false, nil, t.err
250251
}
251252
if t.dirty {
252-
return nil, ErrModifiedTree
253+
return Val{}, false, nil, ErrModifiedTree
253254
}
254255
if t.root == nil {
255-
return Proof(proofEmpty), nil
256+
return Val{}, false, Proof{}, nil
256257
}
257-
return t.root.prove(-1, key), nil
258+
return t.root.prove(-1, key)
258259
}
259260

260-
func (n *memNode) prove(pbit int, key Key) Proof {
261+
func (n *memNode) prove(pbit int, key Key) (val Val, ok bool, proof Proof, err error) {
261262
nbit := n.bit()
262263
if nbit <= pbit {
263264
// view n as leaf
264-
var p Proof
265265
if n.key == key {
266-
p = Proof(proofConfirm)
267-
} else {
268-
p = append(Proof(proofDeny), n.key[:]...)
266+
return n.val, true, Proof{}, nil
269267
}
270-
return append(p, n.val[:]...)
268+
var p Proof
269+
p = binary.AppendUvarint(p, uint64(len(n.key)))
270+
p = append(p, n.key[:]...)
271+
p = binary.AppendUvarint(p, uint64(len(n.val)))
272+
p = append(p, n.val[:]...)
273+
return Val{}, false, p, nil
271274
}
272275

273276
var sib Hash
@@ -279,7 +282,11 @@ func (n *memNode) prove(pbit int, key Key) Proof {
279282
child = n.right
280283
sib = n.left.hash(nbit)
281284
}
282-
return append(append(child.prove(nbit, key), byte(nbit)), sib[:]...)
285+
286+
val, ok, proof, _ = child.prove(nbit, key)
287+
proof = binary.AppendUvarint(proof, uint64(nbit))
288+
proof = append(proof, sib[:]...)
289+
return
283290
}
284291

285292
// check checks all the tree invariants, walking the entire tree.

0 commit comments

Comments
 (0)