Skip to content
Merged
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 112 additions & 2 deletions draft-davidben-tls-merkle-tree-certs.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,9 +222,15 @@ This document additionally uses the TLS presentation language defined in {{Secti

`[start, end)`, where `start <= end`, denotes the half-open interval containing integers `x` such that `start <= x < end`.

Given a non-negative integer `n`, `LSB(n)` refers to the least-significant bit of `n`'s binary representation. Equivalently, it is the remainder when `n` is divided by 2.
Given a non-negative integer `n`,

To *right-shift* a non-negative integer `n` is to shift each bit in its binary representation to one lower position, discarding the least-significant bit. Equivalently, it is the floor of `n` divided by 2.
* `LSB(n)` refers to the least-significant bit of `n`'s binary representation. Equivalently, it is the remainder when `n` is divided by 2.

* `BIT_WIDTH(n)` refers to the smallest number of bits needed to represent `n`. `BIT_WIDTH(0)` is zero.

* `POPCOUNT(n)` refers to the number of set bits in `n`'s binary representation`.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used the C++20 names for these operations:
https://en.cppreference.com/w/cpp/header/bit.html


To *right-shift* a non-negative integer `n` is to shift each bit in its binary representation to one lower position, discarding the least-significant bit. Equivalently, it is the floor of `n` divided by 2. Given non-negative integers `a` and `b`, `a >> b` refers to `a` right-shifted `b` times.

## Terminology and Roles

Expand Down Expand Up @@ -419,6 +425,8 @@ Subtrees are Merkle Trees, so entries can be proven to be contained in the subtr

Given a subtree inclusion proof, `inclusion_proof`, for entry `index`, with hash `entry_hash`, of a subtree `[start, end)`, the subtree inclusion proof can be *evaluated* to compute the expected subtree hash:

<!-- If changing this procedure, remember to update {{inclusion-proof-bits}} -->

1. Check that `[start, end)` is a valid subtree ({{definition-of-a-subtree}}), and that `start <= index < end`. If either do not hold, fail proof evaluation.

1. Set `fn` to `index - start` and `sn` to `end - start - 1`.
Expand Down Expand Up @@ -447,6 +455,8 @@ Given a subtree inclusion proof, `inclusion_proof`, for entry `index`, with hash

This is the same as the procedure in {{Section 2.1.3.2 of !RFC9162}}, where `leaf_index` is `index - start`, `tree_size` is `end - start`, and `r` is returned instead of compared with `root_hash`.

{{inclusion-proof-bits}} explains this procedure in more detail.

### Verifying a Subtree Inclusion Proof

Given a subtree inclusion proof, `inclusion_proof`, for entry `index`, with hash `entry_hash`, of a subtree `[start, end)` with hash `subtree_hash`, the subtree inclusion proof can be *verified* to verify the described entry is contained in the subtree:
Expand Down Expand Up @@ -516,6 +526,8 @@ The following procedure can be used to verify a subtree consistency proof.

Given a Merkle Tree over `n` elements, a subtree defined by `[start, end)`, a consistency proof `proof`, a subtree hash `node_hash`, and a root hash `root_hash`:

<!-- If changing this procedure, remember to update {{consistency-proof-bits}} -->

1. Check that `[start, end)` is a valid subtree ({{definition-of-a-subtree}}), and that `end <= n`. If either do not hold, fail proof verification. These checks imply `0 <= start < end <= end`.
1. Set `fn` to `start`, `sn` to `end - 1`, and `tn` to `n - 1`.
1. If `sn` is `tn`, then:
Expand All @@ -537,6 +549,8 @@ Given a Merkle Tree over `n` elements, a subtree defined by `[start, end)`, a co
1. Right-shift `fn`, `sn`, and `tn` once more.
1. Compare `tn` to `0`, `fr` to `node_hash`, and `sr` to `root_hash`. If any are not equal, fail the proof verification. If all are equal, accept the proof.

{{consistency-proof-bits}} explains this procedure in more detail.

## Arbitrary Intervals

Not all `[start, end)` intervals of a Merkle tree are valid subtrees. This section describes how, for any `start < end`, to determine up to two subtrees that efficiently cover the interval. The subtrees are determined by the following procedure:
Expand Down Expand Up @@ -1423,6 +1437,100 @@ at-trustAnchorID ATTRIBUTE ::= {
END
~~~

# Merkle Tree Structure

This non-normative section describes how the Merkle Tree structure relates to the binary representations of indices. It is included to help implementors understand the procedures described in {{subtrees}}.

## Binary Representations
Comment thread
davidben marked this conversation as resolved.

Within a Merkle Tree whose size is a power of two, the binary representation of an leaf's index gives the path to that leaf. The leaf is a left child if the least-significant bit is unset and a right child if it is set. The next bit indicates the direction of the parent node, and so on. {{fig-merkle-tree-bits-full}} demonstrates this in a Merkle Tree of size 8:

~~~aasvg
+----------------+
| [0, 8) | level 3
+----------------+
/ \
+--------+ +--------+
| [0, 4) | | [4, 8) | level 2
+--------+ +--------+
/ \ / \
+-----+ +-----+ +-----+ +-----+
|[0,2)| |[2,4)| |[4,6)| |[6,8)| level 1
+-----+ +-----+ +-----+ +-----+
/ \ / \ / \ / \
+-+ +-+ +-+ +-+ +-+ +-+ +-+ +-+
|0| |1| |2| |3| |4| |5| |6| |7| level 0
+-+ +-+ +-+ +-+ +-+ +-+ +-+ +-+
~~~
{: #fig-merkle-tree-bits-full title="An example Merkle tree of size 8"}

The binary representation of `4` is `0b100`. It is the left (0) child of `[4, 6)`, which is the left (0) child of `[4, 8)`, which is the right (1) child of `[0, 8)`.

Each level in the tree corresponds to a bit position and can be correspondingly numbered, with 0 indicating the least-significant bit and the leaf level, and so on. In this numbering, a node's level can be determined as follows: if the node is a root of subtree `[start, end)`, the node's level is `BIT_WIDTH(end - start - 1)`.

Comparing two indices determines how the two paths diverge. For example, the bit representations of 4 and 6 are `0b100` and `0b110`, respectively. Numbering bits from least to most significant, with the least significant bit numbered zero, they share bit 2 but diverge at bit 1. The paths to leaves 4 and 6 diverges when moving from level 2 to level 1.

This can be generalized to arbitrary-sized Merkle trees. {{fig-merkle-tree-bits-partial}} depicts a Merkle Tree of size 6:

~~~aasvg
+--------------+
| [0, 6) | level 3
+--------------+
/ |
+--------+ |
| [0, 4) | * level 2
+--------+ |
/ \ |
+-----+ +-----+ +-----+
|[0,2)| |[2,4)| |[4,6)| level 1
+-----+ +-----+ +-----+
/ \ / \ / \
+-+ +-+ +-+ +-+ +-+ +-+
|0| |1| |2| |3| |4| |5| level 0
+-+ +-+ +-+ +-+ +-+ +-+
~~~
{: #fig-merkle-tree-bits-partial title="An example Merkle tree of size 6"}

When the size of a Merkle Tree is not a power of two, some levels on the rightmost edge of the tree are skipped. These can be seen in the binary representation of the last element of the tree. Here, the last element is 5, which has binary representation `0b101`. When a bit is set, the corresponding node is a right child. When it is unset, the corresponding node is skipped.

Compared to a tree of the next power of two size, here {{fig-merkle-tree-bits-full}}, the skipped nodes are where the last element *would* have been a left child, had there been enough elements to construct a right sibling.

This is additionally true for any indices before they diverge from the rightmost edge. The binary representation of 4 is `0b100`. While bit 0 and bit 1 are both unset, they manifest in the tree differently. Bit 0 indicates that 4 is a right child. However, at bit 1, `0b100` has not yet diverged from the last element, `0b101`. That instead indicates a skipped node, not a left child.

## Inclusion Proof Evaluation {#inclusion-proof-bits}

The procedure in {{evaluating-a-subtree-inclusion-proof}} builds up a subtree hash in `r` by staring from `entry_hash` and iteratively hashing elements of `inclusion_proof` on the left or right. That means this procedure, when successful, must return *some* hash that contains `entry_hash`.

Treating `[start, end)` as a Merkle Tree of size `end - start`, the procedure hashes by based on the path to `index`. Within this smaller Merkle Tree, it has index `fn = index - start`. The procedure additionally follows `sn = end - start - 1`, the path to the last element.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly add:

fn has the bit pattern that tracks the path from index to the root of the subtree, and sn has the bit pattern that that tracks the right edge of the subtree.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think that fit slightly better in the next paragraph, but added something to this effect. I added some text in the previous section to make it clear that the right edge and the path to the last element are the same. (It seems to work better to talk about paths.)


Step 4 iterates through `inclusion_proof` and bit positions in parallel. Comparing `sn` to zero ensures that the two iterators stop together, i.e. there aren't extra or missing inclusion proof entries.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly add:

Since sn tracks the path of the right edge to the root of the subtree, the last direction in this path will always be right, which is a 1 bit, hence sn is non-zero exactly when there are additional levels to traverse.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. I tend to think of every tree as having infinite levels. If zeros on the right edge mean "skip", that just means there are infinitely many "skip" directives at the front of every path. (Subtrees also potentially have infinite levels, but past some point, zeros and ones both mean "skip". Or you subtract start and that removes the ones.) But I think this is probably what equivalent interpretation makes most sense to you.

I added "When sn is zero, the procedure has reached the top of the tree." I think there's lots of ways to see why (including "In this numbering, a node's level can be determined as follows: if the node is a root of subtree [start, end), the node's level is BIT_WIDTH(end - start - 1)") that we can probably just assert that and let the reader justify it to themselves in whatever way makes most sense. This doesn't need to be a rigorous proof, just enough to get the intuition across.


Iterating from level 0 up, `fn` and `sn` will initially be different. While they are different, step 4.2 hashes on the left or right based on the binary representation, as discussed in {{binary-representations}}.

Once `fn = sn`, the procedure has reached the point where the path diverges from the right edge. At that point, the condition in step 4.2 is always tree. It only incorporates proof entries on the left, once per set bit. Unset bits are skipped.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"the condition in step 4.2 is always tree": should that be "is always true" instead of "is always tree"?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoops, yes. Fixed.


Inclusion proofs can also be evaluated by considering these two stages separately. The first stage consumes `l1 = BIT_WIDTH(fn XOR sn)` proof entries. The second stage consumes `l2 = POPCOUNT(fn >> l1)` proof entries. A valid inclusion proof must then have `l1 + l2` entries. The first `l1` entries are hashed based on `fn`'s least significant bits, and the remaining `l2` entries are hashed on the left.

## Consistency Proof Verification {#consistency-proof-bits}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something that helped me understand this better was when you told me that fn, sn, and tn stand for "first number", "second number", and "third number". Perhaps that would be useful for others and could be mentioned in this section. I think r means "running hash"?

(EDIT 1: Out of scope for this PR, but the naming of fr and sr implies some correlation between those and fn and sn. In RFC 9162 2.1.4.2, there is no tn, and there is direct correlation between the inputs first, first_hash and variables fn and fr (same for second, second_hash, sn, and sr). In this MTC draft, the variables fr and sr seem more closely correlated with sn and tn than they do with fn and sn, which makes me think those variables should be renamed. Maybe sr and tr? Maybe nh (node hash) and rh (root hash)?)

EDIT 2 (also out of scope for this PR): Both verification procedures follow the same pattern: initialize some variables, then loop over the proof array and incorporate hashes. Could the variables be renamed so that fn and r in the inclusion proof procedure have the same names as sn and sr in the consistency proof procedure? AFAICT, once we're in the loop, those are performing the exact same steps (though the initialization is different). (While we're there, maybe give the same name to p in the inclusion proof procedure as c in the consistency proof procedure? I'm assuming p means "proof" and c means "consistency proof", which isn't consistent.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some notes about first, second, and third number. Agreed the variable names are horrid. I'm on board with changing them. Maybe we can do better than first/second/third in the first place, I dunno.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The notes look good. If I think of better names, I'll write up a PR.


The procedure in {{verifying-a-subtree-consistency-proof}} iteratively builds two hashes, `fr` and `sr`, which are expected to equal `node_hash` and `root_hash`, respectively. Everything hashed into `fr` is also hashed into `sr`, so success demonstrates that `root_hash` contains `node_hash`.

A subtree consistency proof for `[start, end)` with the tree of `n` elements is a truncated inclusion proof for element `end - 1`. The proof is truncated until the highest common node between the right edge of `[start, end)` and the right edge of the tree.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think something is missing from this description, but I haven't figured it out yet. I'll take another look next week.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in part because it's wrong. :-) (It's not the right edge of the tree) I've rephrased it and elaborated.


Steps 3 and 4 skip to this common node. It may be:

* The entire subtree `[start, end)` if `[start, end)` is directly contained in the tree. This will occur if `end` is `n`, or if `[start, end)` is full.

* Otherwise, the highest full subtree along the right edge of `[start, end)`.

In the first case, `fn` will equal `sn` after truncation. Step 5 will then initialize the hashes to `node_hash`. The consistency proof does not need to include a separate copy of `node_hash`.

In the second case, `fn` is less than `sn`. Step 6 will then initialize the hashes to the first value in the consistency proof.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not clear what cases these are referring to, and they're not the two bullet points above. (fn can equal sn after performing either of those steps.) I'd clarify by changing this to something like the following:

Steps 5 and 6 initialize the hashes fr and sr.

If fn equals sn (possibly after truncation), step 5 initializes the hashes to node_hash because the subtree [start, end) is directly contained in the tree, so the consistency proof did not need to include a separate copy of node_hash.

Otherwise, fn is less than sn, and step 6 initializes the hashes to the first value in the consistency proof.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They should be the same as the two bullet points, I think. Rather, the two bullets don't quite match step 3 and step 4. I added some parentheses to make that clear. (I found these two cases to be more natural, but they didn't quite line up with code.)


From there, step 6 incorporates the consistency proof into `sr` as in inclusion proof evaluation. In parallel, step 7.2.1 incorporates a subset of the hashes into `fr`, ony of those subtrees are contained within `[start, end)`. Specifically, it incorporates only those hashes to the left of the path, and stops incorporating when `fn` and `sn` have no longer diverged.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"From there, step 6 incorporates the consistency proof ..." Step 6 was initializing fr and sr, and is described above. I think that sentence is supposed to say "step 7" instead of "step 6", or I'm misunderstanding something in the verification or this explanation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah, things got jumbled when the steps were renumbered in another PR. Fixed.


In the case when `fn` is `sn` in step 5, the condition in step 7.2.1 is always false, and `fr` is always equal to `node_hash` in step 8. In this case, steps 6 through 8 are equivalent to verifying an inclusion proof for the truncated subtree `[fn, sn + 1)` and truncated tree `tn + 1`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm getting a bit of "and then draw the rest of the owl" vibes from this. I'll take another look on Monday to see if I can offer a suggestion on more words here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly it's assumed you already read the inclusion proof one. It's really the same function, we're just doing more work along the side. Expanded on it a bit.

# Extensions to Tiled Transparency Logs (To Be Removed)

[[TODO: This section is expected to be removed. It is sketched here purely for illustrative purposes, until the features are defined somewhere else, e.g. in the upstream tlog documents.]]
Expand Down Expand Up @@ -1596,3 +1704,5 @@ In draft-04, there is no fast issuance mode. In draft-05, frequent, non-landmark
- Clarify landmark zero

- Clarify signature verification process

- Add an appendix that explains the Merkle Tree proof procedures