Skip to content

Repository files navigation

kt — a Rust implementation of IETF Key Transparency

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.

Goal

  1. Correct — implement draft-ietf-keytrans-protocol (currently -05) and the architecture in draft-ietf-keytrans-architecture.
  2. 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.
  3. 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.

Upstreams

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 --init

Bump 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>"

Layout

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.

Build

cargo build --workspace
cargo test  --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --check

Interop

The 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-server is entirely //go:build ignore'd and its HTTP dependencies are missing from go.mod, so a Go server has to be stood up over katie's wire.Interface first.

Cipher suites

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.

Status

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.

A disagreement between the two Go implementations

§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.

Test coverage

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.

License

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.

About

Rust implementation of IETF Key Transparency (draft-ietf-keytrans-protocol), built to interoperate with the Go implementations

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages