Commits
TUF metadata was Ed25519-only. Meta.algorithm gains `MLDSA65
(FIPS 204) and both codecs learn it. The JSON codec reads the keytype
("ed25519"/"mldsa65", scheme "ml-dsa-65") and dispatches
verification to Crypto_ec.Ed25519 or Crypto_pq.Mldsa65; the COSE codec
pubkey_of gains the ML-DSA arm and lets Cose infer each signer's
algorithm from its key. sign_envelope keeps taking X509.Private_key.t
list -- an ML-DSA-65 key is a first-class X509.Private_key.t
(`MLDSA65 seed), so one envelope carries both an Ed25519 and an
ML-DSA-65 signature and the role threshold counts distinct verified
keys (a dual-key root at threshold 2 needs both). Round-trip interop
only; keytype/scheme names are our documented choice, as no canonical
TUF ML-DSA keytype is registered yet.
These packages are published only to the opam overlay, not the public
opam-repository. Without the repo-add line a reader who runs opam install
gets nothing and has to hunt down the overlay URL by hand.
The client parsed a role's file_meta (length + hashes) but never checked
it. Now verify_pinned_meta rejects with Metadata_mismatch when a loaded
snapshot, targets, or delegated-targets blob's byte length or sha256 digest
does not match the pin in the referencing role, blocking same-version
mix-and-match of tampered metadata. The JSON codec decodes a role pin's
file_meta only when the length/hashes keys are present, rather than
swallowing decode errors into None and dropping a real pin.
Across the packages monopam lint flagged: declare the libraries each
package actually uses (test-only ones scoped with-test, dev tools and
benches scoped with-dev-setup), drop the opam deps and dune (libraries)
entries nothing references, declare pin-depends for the wire and zarith
git pins so a standalone opam install can resolve them, and point the
five stale dune-project source URLs at the names sources.toml already
uses. The generated .opam files are regenerated to match.
Where monopam lint and merlint's project rules disagree, merlint wins:
[protocol] packages keep probe linked (E932), CSV-trace interop tests
keep csv (E825), and dev-scope deps carry the right filter (E943).
The few dead-lib reports that remain are monopam lint blind spots, not
real dead deps: usage reached only through an mdx README block (slirp),
through copy_files# (vz), or inside a library's own private modules
(builder), plus a tar gz dep kept live for a stub test still to write.
Cose.Sign1.{sign,verify} now take a signing_key/verification_key
variant; wrap the X509 keys at the call sites.
dupfind flagged the same quadratic contains/is_infix/is_substring/
index_of loop hand-rolled across 16 files (grpc, h2, fdt, uniboot,
sqlite, mal, runc, oci, parquet, requests, respond, tuf, git bench,
crypto scripts). astring is already in the dependency tree and
provides exactly these: is_infix ~affix and find_sub. Each helper
collapses to a one-line wrapper, keeping its local signature.
Remove libraries that no module in the stanza directory
references (monopam lint dead-lib).
Tree-wide mechanical migration (Digestif.* -> Hash.*, dune and opam
deps digestif -> ocaml-hash) across 43 packages, dropping digestif
from the dependency graph entirely (root.opam no longer lists it).
Blob digests, git object ids, KEX exchange hashes, JWT/COSE/X.509
signatures and every other SHA-256 user now ride the hardware path on
CPUs that have it.
Five libraries had an internal hash.ml that would shadow the Hash
library inside their own compilation units; their internal module is
renamed by what it actually is, with the public alias kept so
consumers (Irmin.Hash, Git.Hash, Tpm.Hash, ...) are unchanged:
- irmin, ocaml-git: Object_hash (content-addressed object ids)
- ocaml-scitt, ocaml-sigstore: Hash_alg (algorithm registries)
- ocaml-tpm: Hashing (digest/HMAC/KDFa over TPM algorithms)
Semantic adjustments the rename surfaced:
- x509: the OID<->hash tables lose their arms for algorithms the
hash library does not carry (SHA-3, BLAKE2, RIPEMD, WHIRLPOOL);
Hash.kind is closed over the six supported algorithms so the
catch-all arms were dead.
- sigstore: bundles declaring SHA-3 digests now fail verification
loudly (Invalid_argument) instead of mapping to an unimplemented
kind; the protobuf enum still parses.
- x509/jwt: local [let module Hash = (val module_of ...)] bindings
renamed to H -- they would shadow the library inside their scope.
- irmin re-exports the raw SHA-1/SHA-256 implementations through
Object_hash so its schema instantiations need no second name for
the library under the public Hash alias.
SSH throughput is unchanged (170/537/460 MB/s socketpair / 2-domain /
2-process, same as the digestif baseline): the negotiated transport
cipher is chacha20-poly1305, whose MAC is Poly1305, so SHA-256 only
runs in the handshake. SSH's data-path ceiling remains nox-crypto's
scalar ChaCha20/Poly1305.
All migrated package suites pass (ssh, tls, x509, paseto, jwt, kdf,
git, tpm, scitt, sigstore, oci, srp, spake2, cose, bpsec, hash).
Comment reflow and header reindent produced by the new ocamlformat;
no code changes.
Extend the alcobar property suite from codec round-trips to invariants that
capture the security-critical properties, with real Ed25519:
- canonical JSON is order-independent (member order never changes the
encoding) and idempotent (re-parsing canonical output re-encodes to the
same bytes) -- the property that makes signatures stable across serializers.
- exact signer attribution: verifying a signed envelope reports exactly the
keys that signed it, no stranger forges in and no signer is dropped.
- tamper soundness: flipping any single bit of a signed envelope either breaks
verification or leaves the authenticated content unchanged -- an altered
document never verifies.
- rollback monotonicity: across any two timestamp versions, the trusted
version never decreases.
Cover the named TUF attacks and a real CVE against the verification logic:
- CVE-2021-41131 (go-tuf): a threshold must be met by distinct keys -- a
role listing the same key id twice, and repeated signatures from one key,
must not satisfy a 2-of-2 threshold.
- indefinite freeze: a validly signed but expired snapshot is rejected.
- endless data: the launch check pins the target length.
- cross-role signature replay: a document signed by one role's key is not
accepted for another role (both via the COSE path and against a real
python-tuf snapshot offered where a timestamp is expected).
- delegation containment: a delegated role cannot authorize a path outside
its delegated paths.
Add tuf, a native OCaml implementation of The Update Framework's role
model and update workflow, with the wire format made pluggable.
The security model (roles, key thresholds, version rollback protection,
freshness/expiry, key rotation, delegation chaining) lives in tuf.core as
a Client.Make functor over a CODEC signature, independent of the encoding.
Two codecs implement it:
- tuf.cose: COSE_Sign1 over CBOR (RFC 9052/8949), Ed25519 via ocaml-cose.
The closed-world default; it signs the literal payload, so verification
never re-canonicalizes.
- tuf.json: the standard canonical-JSON TUF format for go-tuf/python-tuf
interop, verify-only. Checked against real python-tuf metadata: the
interop test pins a python-tuf root and verifies the timestamp/snapshot/
targets chain, which only passes if the canonical JSON and Ed25519 match
python-tuf byte-for-byte.
Crypto is delegated to ocaml-cose/ocaml-crypto-ec, JSON parsing to nox-json.
Tests cover codec round-trips, the verification attack vectors (rollback,
threshold, expiry, version mismatch, tampering, rotation, delegation), a
canonical-JSON suite, alcobar fuzzing, and the python-tuf interop replay.
TUF metadata was Ed25519-only. Meta.algorithm gains `MLDSA65
(FIPS 204) and both codecs learn it. The JSON codec reads the keytype
("ed25519"/"mldsa65", scheme "ml-dsa-65") and dispatches
verification to Crypto_ec.Ed25519 or Crypto_pq.Mldsa65; the COSE codec
pubkey_of gains the ML-DSA arm and lets Cose infer each signer's
algorithm from its key. sign_envelope keeps taking X509.Private_key.t
list -- an ML-DSA-65 key is a first-class X509.Private_key.t
(`MLDSA65 seed), so one envelope carries both an Ed25519 and an
ML-DSA-65 signature and the role threshold counts distinct verified
keys (a dual-key root at threshold 2 needs both). Round-trip interop
only; keytype/scheme names are our documented choice, as no canonical
TUF ML-DSA keytype is registered yet.
The client parsed a role's file_meta (length + hashes) but never checked
it. Now verify_pinned_meta rejects with Metadata_mismatch when a loaded
snapshot, targets, or delegated-targets blob's byte length or sha256 digest
does not match the pin in the referencing role, blocking same-version
mix-and-match of tampered metadata. The JSON codec decodes a role pin's
file_meta only when the length/hashes keys are present, rather than
swallowing decode errors into None and dropping a real pin.
Across the packages monopam lint flagged: declare the libraries each
package actually uses (test-only ones scoped with-test, dev tools and
benches scoped with-dev-setup), drop the opam deps and dune (libraries)
entries nothing references, declare pin-depends for the wire and zarith
git pins so a standalone opam install can resolve them, and point the
five stale dune-project source URLs at the names sources.toml already
uses. The generated .opam files are regenerated to match.
Where monopam lint and merlint's project rules disagree, merlint wins:
[protocol] packages keep probe linked (E932), CSV-trace interop tests
keep csv (E825), and dev-scope deps carry the right filter (E943).
The few dead-lib reports that remain are monopam lint blind spots, not
real dead deps: usage reached only through an mdx README block (slirp),
through copy_files# (vz), or inside a library's own private modules
(builder), plus a tar gz dep kept live for a stub test still to write.
dupfind flagged the same quadratic contains/is_infix/is_substring/
index_of loop hand-rolled across 16 files (grpc, h2, fdt, uniboot,
sqlite, mal, runc, oci, parquet, requests, respond, tuf, git bench,
crypto scripts). astring is already in the dependency tree and
provides exactly these: is_infix ~affix and find_sub. Each helper
collapses to a one-line wrapper, keeping its local signature.
Tree-wide mechanical migration (Digestif.* -> Hash.*, dune and opam
deps digestif -> ocaml-hash) across 43 packages, dropping digestif
from the dependency graph entirely (root.opam no longer lists it).
Blob digests, git object ids, KEX exchange hashes, JWT/COSE/X.509
signatures and every other SHA-256 user now ride the hardware path on
CPUs that have it.
Five libraries had an internal hash.ml that would shadow the Hash
library inside their own compilation units; their internal module is
renamed by what it actually is, with the public alias kept so
consumers (Irmin.Hash, Git.Hash, Tpm.Hash, ...) are unchanged:
- irmin, ocaml-git: Object_hash (content-addressed object ids)
- ocaml-scitt, ocaml-sigstore: Hash_alg (algorithm registries)
- ocaml-tpm: Hashing (digest/HMAC/KDFa over TPM algorithms)
Semantic adjustments the rename surfaced:
- x509: the OID<->hash tables lose their arms for algorithms the
hash library does not carry (SHA-3, BLAKE2, RIPEMD, WHIRLPOOL);
Hash.kind is closed over the six supported algorithms so the
catch-all arms were dead.
- sigstore: bundles declaring SHA-3 digests now fail verification
loudly (Invalid_argument) instead of mapping to an unimplemented
kind; the protobuf enum still parses.
- x509/jwt: local [let module Hash = (val module_of ...)] bindings
renamed to H -- they would shadow the library inside their scope.
- irmin re-exports the raw SHA-1/SHA-256 implementations through
Object_hash so its schema instantiations need no second name for
the library under the public Hash alias.
SSH throughput is unchanged (170/537/460 MB/s socketpair / 2-domain /
2-process, same as the digestif baseline): the negotiated transport
cipher is chacha20-poly1305, whose MAC is Poly1305, so SHA-256 only
runs in the handshake. SSH's data-path ceiling remains nox-crypto's
scalar ChaCha20/Poly1305.
All migrated package suites pass (ssh, tls, x509, paseto, jwt, kdf,
git, tpm, scitt, sigstore, oci, srp, spake2, cose, bpsec, hash).
Extend the alcobar property suite from codec round-trips to invariants that
capture the security-critical properties, with real Ed25519:
- canonical JSON is order-independent (member order never changes the
encoding) and idempotent (re-parsing canonical output re-encodes to the
same bytes) -- the property that makes signatures stable across serializers.
- exact signer attribution: verifying a signed envelope reports exactly the
keys that signed it, no stranger forges in and no signer is dropped.
- tamper soundness: flipping any single bit of a signed envelope either breaks
verification or leaves the authenticated content unchanged -- an altered
document never verifies.
- rollback monotonicity: across any two timestamp versions, the trusted
version never decreases.
Cover the named TUF attacks and a real CVE against the verification logic:
- CVE-2021-41131 (go-tuf): a threshold must be met by distinct keys -- a
role listing the same key id twice, and repeated signatures from one key,
must not satisfy a 2-of-2 threshold.
- indefinite freeze: a validly signed but expired snapshot is rejected.
- endless data: the launch check pins the target length.
- cross-role signature replay: a document signed by one role's key is not
accepted for another role (both via the COSE path and against a real
python-tuf snapshot offered where a timestamp is expected).
- delegation containment: a delegated role cannot authorize a path outside
its delegated paths.
Add tuf, a native OCaml implementation of The Update Framework's role
model and update workflow, with the wire format made pluggable.
The security model (roles, key thresholds, version rollback protection,
freshness/expiry, key rotation, delegation chaining) lives in tuf.core as
a Client.Make functor over a CODEC signature, independent of the encoding.
Two codecs implement it:
- tuf.cose: COSE_Sign1 over CBOR (RFC 9052/8949), Ed25519 via ocaml-cose.
The closed-world default; it signs the literal payload, so verification
never re-canonicalizes.
- tuf.json: the standard canonical-JSON TUF format for go-tuf/python-tuf
interop, verify-only. Checked against real python-tuf metadata: the
interop test pins a python-tuf root and verifies the timestamp/snapshot/
targets chain, which only passes if the canonical JSON and Ed25519 match
python-tuf byte-for-byte.
Crypto is delegated to ocaml-cose/ocaml-crypto-ec, JSON parsing to nox-json.
Tests cover codec round-trips, the verification attack vectors (rollback,
threshold, expiry, version mismatch, tampering, rotation, delegation), a
canonical-JSON suite, alcobar fuzzing, and the python-tuf interop replay.