Skip to content

Commit

Permalink
Include key suffix in leaf's hash.
Browse files Browse the repository at this point in the history
  This changes quite a few things and make the proof verification
  slightly more complicated, but overall remains manageable. This change
  is however necessary to prevent one from including entire sub-trees in
  one go by making look like a _value_; thus resulting in multiple trees
  with the same root hash.

  So for example, one could construct the following tree:

  ```
  ╔═══════════════════════════════════════════════════════════════════╗
  ║ #f6ee5ad5391e966a7e0f24465d98ab5df2596e5987f189b67479d271b6e938a7 ║
  ╚═══════════════════════════════════════════════════════════════════╝
   ┌─ 09ad7..[55 digits]..19d9 → apple
   └─ 177ca..[55 digits]..98a3 → <some gibberish value>
  ```

  Which is in fact, a way of disguising the following tree:

  ```
  ╔═══════════════════════════════════════════════════════════════════╗
  ║ #f6ee5ad5391e966a7e0f24465d98ab5df2596e5987f189b67479d271b6e938a7 ║
  ╚═══════════════════════════════════════════════════════════════════╝
   ┌─ 09ad7..[55 digits]..19d9 → apple
   └─ f #177cab65ae5d
      ├─ 3 #9a4b2591979d
      │  ├─ 2e49b..[53 digits]..9728 → pomegranate
      │  └─ 7d2a6..[53 digits]..d578 → kiwi
      └─ a3c8d..[54 digits]..52ff → plum
  ```

  The value is just chosen to be the pre-image of the 'f' node. However,
  this completely breaks the logic and guarantee that comes with
  insert/delete as now, one is able to add or remove entire parts of the
  tree in one go.

  The fix: includes the remaining key path in the leaf's hash, all the
  way up to the root.

  By including the remaining path (or suffix) in the final leaf, the
  trie is made completely tampered proof as the root now fully depends
  on not only the elements present in the tree, but also the structure
  of the tree itself. So, the root tree effectively depends on the
  number of branches, and their positions. So a situation like the above
  is no longer possible; the first tree would yield a different root
  hash and the proof would be invalid against the second tree.

  NOTE: The on-chain implementation, as well as the proof serialisation
  format must be adjusted.
  • Loading branch information
KtorZ committed May 22, 2024
1 parent 5dd2a9a commit 2dd496b
Show file tree
Hide file tree
Showing 2 changed files with 280 additions and 211 deletions.
160 changes: 116 additions & 44 deletions off-chain/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,47 @@ export class Tree {
return loop('', values.map(value => ({ key: intoKey(value), value })));
}


/** Set the prefix on a tree, and computes its corresponding root hash. Both steps
* are done in lock-step because the node's hash crucially includes its prefix.
*
* @param {string} prefix A sequence of nibbles.
* @param {Buffer} hash A hash digest of the value.
* @return {Tree} A reference to the underlying tree with its prefix modified.
* @private
*/
setPrefix(prefix, hash) {
// NOTE:
// We append the remaining prefix to the value. However, to make this
// step more efficient on-chain, we append it as a raw bytestring instead of
// an array of nibbles.
//
// If the prefix's length is odd however, we do still add one nibble, and
// then the rest.
const isOdd = prefix.length % 2 > 0;

const head = isOdd
? nibbles(prefix.slice(0, 1))
: Buffer.from([]);

const tail = Buffer.from(isOdd
? prefix.slice(1)
: prefix,
'hex'
);

assert(
hash.length === DIGEST_LENGTH,
`hash must be a ${DIGEST_LENGTH}-byte digest in 'setPrefix' but it is`
);

this.hash = prefix.length === 0 ? hash : digest(Buffer.concat([head, tail, hash]));
this.prefix = prefix;

return this;
}


/** Conveniently access a child in the tree at the given path. A path is
* sequence of nibbles, as an hex-encoded string.
*
Expand Down Expand Up @@ -234,7 +275,7 @@ export class Leaf extends Tree {

this.size = 1;
this.value = value;
this.setPrefix(prefix);
this.setPrefix(prefix, digest(value));
}


Expand All @@ -246,25 +287,16 @@ export class Leaf extends Tree {
* @private
*/
[inspect.custom](depth, options, _inspect) {
const hash = options.stylize(
`#${this.hash.toString('hex').slice(0, DIGEST_SUMMARY_LENGTH)}`,
'special'
);

const prefix = withEllipsis(this.prefix, PREFIX_CUTOFF, options);

const value = options.stylize(this.value, 'string');

return `${prefix}${value}`;
}


/** Set the prefix on a tree, and computes its corresponding root hash. Both steps
* are done in lock-step because the node's hash crucially includes its prefix.
*
* @param {string} prefix A sequence of nibbles.
* @return {Tree} A reference to the underlying tree with its prefix modified.
* @private
*/
setPrefix(prefix) {
this.hash = digest(this.value);
this.prefix = prefix;
return this;
return `${prefix} ${hash}${value}`;
}


Expand Down Expand Up @@ -346,24 +378,12 @@ export class BranchNode extends Tree {

this.size = children.reduce((size, child) => size + (child?.size || 0), 0);
this.children = children;
this.setPrefix(prefix);
}

/** Sets the prefix and hash of the node, ensuring that the former is used to
* compute the latter.
*
* @param {string} prefix A sequence of nibbles.
* @return {BranchNode}
* @private
*/
setPrefix(prefix) {
const children = this.children.flatMap(node => node === undefined ? [] : [node.hash || node]);

this.hash = digest(Buffer.concat([nibbles(prefix), ...children]));

this.prefix = prefix;

return this;
this.setPrefix(
prefix,
digest(Buffer.concat(this.children.flatMap(
node => node === undefined ? [] : [node.hash]
))),
);
}

/**
Expand Down Expand Up @@ -413,7 +433,7 @@ export class BranchNode extends Tree {
'│'
);
if (depth === 2 && this.prefix.length > 0) {
first = `\n ${this.prefix.toString('hex')}${first}`
first = `\n ${this.prefix}${first}`
}

// ----- In-between
Expand Down Expand Up @@ -493,10 +513,33 @@ export class Proof {
* @private
*/
rewind(target, skip, children) {
this.steps.unshift({
skip,
neighbors: children.flatMap(x => x?.hash.equals(target.hash) ? [] : [x?.hash]),
});
children = children.filter(x => !x?.hash.equals(target.hash));

if (children.filter(x => x !== undefined).length === 1) {
this.steps.unshift({
skip,
neighbors: children.map(x => {
if (x === undefined) {
return x;
}

return {
prefix: x.prefix,
value: digest(x instanceof Leaf
? x.value
: Buffer.concat(x.children.flatMap(node => {
return node === undefined ? [] : [node.hash]
}))
),
};
}),
});
} else {
this.steps.unshift({
skip,
neighbors: children.map(x => x?.hash),
});
}

return this;
}
Expand Down Expand Up @@ -531,7 +574,7 @@ export class Proof {
const suffix = path.slice(cursor);

const zero = withElement
? Leaf.prototype.setPrefix.call({ value: this.value }, suffix).hash
? Tree.prototype.setPrefix.call({}, suffix, digest(this.value)).hash
: undefined;

return this.steps.reduceRight((currentHash, step, ix) => {
Expand All @@ -548,15 +591,43 @@ export class Proof {
//
// Now, when the last branch node had only two elements (i.e. neighbors.length === 1),
// we must treat this node as a leaf instead and take its hash directly.
if (currentHash === undefined && neighbors.length === 1) {
return neighbors[0];
if (neighbors.length === 1) {
const neighbor = neighbors[0];

assert(neighbor.prefix !== undefined);

const neighborPos = step.neighbors.indexOf(neighbor);

const neighborNibble = neighborPos + (nibble <= neighborPos ? 1 : 0);

if (currentHash === undefined) {
return Tree.prototype.setPrefix.call({},
`${neighborNibble.toString('16')}${neighbor.prefix}`,
neighbor.value
).hash;
}

const neighborHash = Tree.prototype.setPrefix
.call({}, neighbor.prefix, neighbor.value)
.hash;

return Tree.prototype.setPrefix.call({},
prefix,
nibble <= neighborPos
? digest(Buffer.concat([currentHash, neighborHash]))
: digest(Buffer.concat([neighborHash, currentHash]))
).hash;
}

const children = step.neighbors.slice(0, nibble)
.concat(currentHash === undefined ? [] : currentHash)
.concat(step.neighbors.slice(nibble));
.concat(step.neighbors.slice(nibble))
.filter(x => x !== undefined);

return BranchNode.prototype.setPrefix.call({ children }, prefix).hash;
return Tree.prototype.setPrefix.call({},
prefix,
digest(Buffer.concat(children))
).hash;
}, zero);
}

Expand All @@ -568,6 +639,7 @@ export class Proof {
*/
serialise() {
// TODO: rewrite to CBOR.
throw new Error('TODO: rework proof format with new structure.');
return JSON.stringify(this.steps.map(step => {
let nextDefined = 0;
const lookup = step.neighbors.map((x, ix) => {
Expand Down
Loading

0 comments on commit 2dd496b

Please sign in to comment.