A Rust implementation of the IETF KEYTRANS protocol, built to interoperate byte-for-byte with the existing Go implementations.
The specification is normative; the Go implementations are the interop peers. Where this repository and the drafts disagree, the drafts win — and the disagreement is a bug report, either against this code or against the draft.
- Correct — implement
draft-ietf-keytrans-protocol(currently -05) and the architecture indraft-ietf-keytrans-architecture. - Interoperable — produce and consume the same bytes as
Bren2010/katie: same tree hashes, same VRF outputs, same commitments, same TLS-presentation-language encodings, same proofs. A Rust client must verify proofs from a Go server, and a Go client must verify proofs from a Rust server. - Honest about its evidence — every interop claim is a committed vector that CI regenerates, and a gap that is not pinned by one is recorded as a gap.
Pinned as git submodules under upstream/ — read as specification and used as
interop peers, never copied into crates/ (see docs/licensing.md).
| Path | Upstream | What it is | License |
|---|---|---|---|
upstream/draft-protocol |
ietf-wg-keytrans/draft-protocol | draft-ietf-keytrans-protocol — the normative wire protocol |
IETF Trust (BSD for code) |
upstream/draft-arch |
ietf-wg-keytrans/draft-arch | draft-ietf-keytrans-architecture — deployment models, threat model |
IETF Trust (BSD for code) |
upstream/katie |
Bren2010/katie | Go transparency log — trees, crypto, client, auditor; the primary interop peer. Library only at this pin; its server is build-ignored | AGPL-3.0 |
upstream/keytrans-verification |
felixlinker/keytrans-verification | A second Go client, read for its reading of ambiguous passages. Not an oracle: it uses katie for the VRF, and its Gobra proofs cover memory safety rather than conformance | unlicensed (all rights reserved) |
git clone --recurse-submodules https://github.com/OR13/kt.git
# or, in an existing clone:
git submodule update --initBump an upstream deliberately, never incidentally:
git -C upstream/katie fetch && git -C upstream/katie checkout <sha>
git add upstream/katie && git commit -m "upstream: bump katie to <sha>"crates/
kt-wire/ TLS presentation language codec + protocol structs (draft §2.1, §11-§13)
kt-crypto/ cipher suites, VRF, commitments, tree-head signature (draft §11)
kt-tree/ log tree, prefix tree, combined tree, binary ladder (draft §3-§5, §11.8-§11.9, §12)
kt-client/ search / monitor / update / distinguished heads (draft §4.2, §6-§10, §13)
interop/ cross-implementation test-vector harness (Go generates, Rust verifies, and back)
upstream/ pinned submodules — spec + interop peers, read-only
docs/ interop plan, licensing boundary
Crates are layered bottom-up: kt-wire depends on nothing in-tree, kt-crypto
on kt-wire, kt-tree on both, kt-client on all three.
cargo build --workspace
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --checkThe interop strategy — differential test vectors first, live wire second — is in
docs/interop.md. Short version:
- Tier 1, vectors. Go emits JSON vectors for each primitive (commitment, VRF, log/prefix tree roots, proofs, encoded structs); Rust asserts equality. Then the reverse direction, so neither side is only ever the oracle.
- Tier 2, live. Drive a Go server with the Rust client over HTTP, then serve
from Rust and drive it with katie's Go client. Note the blocker: at the current
pin, katie's
cmd/katie-serveris entirely//go:build ignore'd and its HTTP dependencies are missing fromgo.mod, so a Go server has to be stood up over katie'swire.Interfacefirst.
Both registered suites use SHA-256, Nc = 16, and
Kc = d821f8790d97709796b4d7903357c3f5:
| Value | Name | Signature | VRF |
|---|---|---|---|
0x0001 |
KT_128_SHA256_P256 |
ECDSA / P-256 | ECVRF-P256-SHA256-TAI |
0x0002 |
KT_128_SHA256_Ed25519 |
Ed25519 | ECVRF-EDWARDS25519-SHA512-TAI |
KT_128_SHA256_Ed25519 is the first target: katie implements both, and Ed25519
has fewer encoding traps.
or13.github.io/kt publishes the interop results:
every case, both values, the peer's commit, rebuilt on every push to main.
report.json is the same data for
machines. The page is generated by the same code the test suite runs, and its
generator exits non-zero if anything disagrees — so it cannot show a green result
that CI does not enforce.
Implemented and verified against the Go peer, byte for byte:
| Implemented | Draft | Verified by |
|---|---|---|
Presentation-language codec (kt-wire::codec) |
§2.1 | every vector file below |
UpdateValue, CommitmentValue (kt-wire::structs) |
§11.5, §11.6 | commitment.json |
Commitment, HMAC(Kc, CommitmentValue) (kt-crypto::commitment) |
§11.6 | commitment.json — 6 positive, 1 negative |
Implicit binary search tree (kt-tree::ibst) |
§4.1, App. A | ibst.json — 38 log sizes |
Binary ladders (kt-tree::ladder) |
§5, App. B | binary-ladder.json — 76 cases |
VRF, ECVRF-EDWARDS25519-SHA512-TAI (kt-crypto::vrf) |
§11.7 | RFC 9381 App. B vectors and vrf.json — 10 positive, 1 negative |
Log tree: root, batch inclusion + consistency proofs (kt-tree::log) |
§3.2, §11.8, §12.1 | log-tree.json — 19 sizes, 297 proofs; log-math.json — 1679 decompositions |
Prefix tree: root, membership + non-membership proofs (kt-tree::prefix) |
§3.3, §11.9, §12.2 | prefix-tree.json — 11 trees |
Configuration, tree head + auditor head + full head signatures (kt-crypto::signature) |
§11.2, §11.3, §11.4 | tree-head.json — all three deployment modes |
FullTreeHead wire encoding, both shapes (kt-wire::heads) |
§11.4 | tree-head.json — the mode decides how many bytes follow |
Updating a view of the tree (kt-tree::ibst) |
§4.2 | update-view.json — 190 size/advertised pairs |
Search ladder interpretation (kt-tree::ladder) |
§6.2 | ladder-interpretation.json — 211 target/greatest pairs |
§13 request structures + response building blocks (kt-wire::requests) |
§11.5, §13.1–§13.5 | requests.json — 22 structures |
Prefix tree root before and after an audited update (kt-tree::prefix) |
§15.2, §3.3 | prefix-mutation.json — 8 update shapes |
AuditorUpdate + every auditor check on one entry, steps 1–7 (kt-wire::audit, kt-tree::audit) |
§15.2 | auditor-update.json — 14 cases through katie's own auditor |
Growing the log tree from retained subtree heads (kt-tree::log) |
§3.2, §11.8 | log-append.json — 64 sizes, heads and root at each |
Distinguished log entries (kt-tree::distinguished) |
§6.1 | distinguished.json — 42 size/window/timestamp shapes |
CombinedTreeProof + SearchResponse (kt-wire::proofs, kt-wire::responses) |
§12.3, §13.1 | search.json — 10 responses from a running log |
Greatest-version and fixed-version search (kt-tree::combined) |
§6.3, §7.1, §7.2, §12.3 | search.json — all 12 replayed, each consuming its proof exactly |
Monitoring response structures (kt-wire::responses) |
§13.2–§13.4 | monitor.json — contact, owner init, owner monitor |
Contact monitoring (kt-tree::combined) |
§8.2, §12.3.4 | monitor.json — 4 replayed, each consuming its proof exactly |
Owner initialization (kt-tree::combined) |
§8.3, §12.3.5 | monitor.json — replayed, consuming its proof exactly |
Owner monitoring (kt-tree::combined) |
§8.3, §12.3.6 | monitor.json — replayed to exhaustion, §8.2 and §8.3 composed |
Refusing what the peer refuses.
tampered.json holds 18 proofs and openings that
katie built, corrupted, and confirmed its own verifier rejects — so accepting one is
a bug rather than a difference of opinion. Every other vector file asks whether we
compute the same values, which a verifier that accepted everything would also pass.
Both directions. The vectors above are generated from the pinned katie by
interop/go/cmd/gen and checked by cargo test. The reverse also runs:
kt-interop-emit builds proofs from this implementation — half of them corrupted
on purpose, and including CombinedTreeProofs for katie's own §6.3 to consume —
and interop/go/cmd/verify puts each through katie's own verifiers,
which must accept every honest one and reject every corrupted one. That direction is
what catches over-acceptance, and recomputing the peer's values cannot see it.
CI regenerates everything and fails on any diff, so upstream drift is loud.
The VRF is worth a note: RFC 9381 ships its own test vectors, so the ECVRF core is
pinned against the specification rather than against another implementation —
the strongest oracle available. vrf.json then pins what RFC 9381 says nothing
about, which is the KT wrapping: that alpha_string is the presentation-language
encoding of a VrfInput (§11.7), and that the 64-byte output is truncated to 32
(§17.1). Two conforming ECVRF implementations can still fail to interoperate on
exactly those two decisions.
§11.2 writes Configuration's mode-dependent part with grouped cases:
select (Configuration.mode) {
case contactMonitoring:
case thirdPartyManagement:
opaque leaf_public_key<0..2^16-1>;
Read the C way the presentation language inherits, leaf_public_key belongs to
both modes. katie emits it only under thirdPartyManagement;
keytrans-verification annotates the field "Only for Contact monitoring or
ThirdParty". Since a TreeHeadTBS begins with the whole Configuration, the two
readings differ by 34 bytes in contact-monitoring mode — so no tree head signature
verifies across them.
The draft's prose settles it: leaf_public_key verifies "the Service Operator's
signature on modifications", and §11.5 gives UpdateSuffix a signature only under
thirdPartyManagement, so under contact monitoring the key would have nothing to
verify. This implementation follows the prose and katie, and
tampered.json carries a signature valid under the
other reading that must be rejected — so if the working group resolves it the other
way, a test starts failing rather than a document going stale.
Not implemented: the combined tree (§3.4, §12.3) and the client algorithms
(§6–§10, §13). §12.3's CombinedTreeProof is defined in terms of the order those
algorithms request data in, so it cannot be implemented before them.
Deliberately out of scope: the KT_128_SHA256_P256 suite. KT_128_SHA256_Ed25519
is supported by both Go peers, and one suite implemented properly is worth more here
than two implemented thinly. Asking for the P-256 VRF returns an error rather than
evaluating the wrong curve. The coverage table on the evidence page distinguishes
"out of scope" from "not implemented", so this does not read as a gap.
cargo llvm-cov reports 99% line coverage across the three protocol crates
(kt-wire, kt-crypto, kt-tree), and CI fails below 97%. The number is a floor
against regression, not a target: what it mostly bought was covering every error
type's Display — the text a verifier emits when it rejects something — which turned
up one real inconsistency, a wrapped error that did not chain to its cause.
Interop work has turned up three bugs in the Go peer and ten gaps or ambiguities in
the draft, one of them filed upstream because it cannot be settled from either source. Each is written up and given a stable identifier in
docs/interop.md, whose findings register is the tracker — nothing is
filed upstream, by decision, and every entry is pinned by a committed vector so a filing
could point at reproducible bytes rather than at prose.
The most substantive is DRAFT-04: §15.2 asks an auditor to compute a prefix tree root
that the proof it is given cannot determine, so some removals are unauditable as specified
and both implementations reach the root by guessing. This one reports the guess
(Accepted::root_determined) so an auditor can decline to sign.
Next: the combined tree (§3.4, §12.3), which is what the search, monitor, and update algorithms of §6–§10 all build their proofs out of.
Dual-licensed under Apache-2.0 or MIT, at your
option — the Rust ecosystem default. This choice is only defensible if the
AGPL boundary in docs/licensing.md is respected.