@flashyos/checkpoint
v0.2.0
Published
checkpoint/1 — a Merkle tree head over the sealed claims an organisation already publishes. RFC 6962 hashing, inclusion and consistency proofs, verifiable offline against the fragments themselves.
Maintainers
Readme
@flashyos/checkpoint
A Merkle tree head over the sealed claims you already publish. RFC 6962 hashing, inclusion and consistency proofs, verifiable offline against the fragments themselves.
npx @flashyos/checkpoint head --origin repo/your-repoNo server. No collector. No account. A checkpoint.json beside the fragments it
commits to, which anybody can recompute.
Read this first
A tree head is easy to over-read, so here is the honest boundary.
It proves that the fragments you published are the fragments the root describes. Recompute and compare. That catches a record edited after the fact, an emitter that silently dropped entries, a served copy that has drifted from its source, and two parties who think they hold the same record and do not.
It does not prove that history was never rewritten. You compute the root, you publish the root, and you hold the data — so you could recompute and republish after changing anything. That gap closes with consistency proofs between retained heads and cosigning by a witness who is not you, and neither is in version 1.
So heads here are unsigned, deliberately. A signature over a root you computed, checked with a key you published, is ceremony without a property. The field exists in the schema and stays empty until there is a witness for it to mean something to.
What goes in the tree
Assertions, not activity — statements someone might later dispute:
shipped/1entries — what shipped- directory nodes, edges and assertions — what you say about yourself (unsealed today, so they contribute nothing yet)
Not commits, not deploys, not page rebuilds. Those are churn, and nobody will ever challenge a footer clock.
Not backlog/1 either, and that is structural. An item decays and is never
sealed, so there is no digest to commit to — a tree can only cover the past
tense. Even sealed, an intention is supposed to change, and a root that went
stale whenever somebody restated a plan would teach readers to ignore it.
A shipped/1 entry already carries a sha256 seal, so this package hashes
nothing new at the claim level — it commits to seals you have.
A record missing an id or a digest is skipped, not hashed. A tree that
exists to prove sealing must not contain something unsealed.
Using it
import { claimsOf, headOf, proofFor, checkProof } from '@flashyos/checkpoint'
const claims = claimsOf([shiplogFragment, directoryFragment])
const head = headOf('repo/acme', claims)
// { checkpoint: '1', origin: 'repo/acme', size: 97, root: '9f2b…', at: '…' }
const proof = proofFor('repo/acme', claims, 'ship/acme/a1b2c3')
checkProof(proof, head) // truecheckProof takes the head separately, on purpose. A proof carries its own
root for convenience; checking it against that root is circular. Pass the head
you already trust.
Did my published head drift?
import { headMatches } from '@flashyos/checkpoint'
headMatches(publishedHead, [shiplogFragment, directoryFragment]) // false if either changedThe estate tier
An estate/<slug> head is a tree whose leaves are other heads' roots — so
the hub holds hashes and no data, and a property can still prove its own record
with the hub absent entirely.
import { estateHead, memberProof, verifyThroughEstate } from '@flashyos/checkpoint'
const estate = estateHead('estate/flashy', [
{ origin: 'repo/alpha', root: alphaHead.root, size: alphaHead.size },
{ origin: 'repo/beta', root: betaHead.root, size: betaHead.size },
])
// One claim, verified to the estate root through both tiers.
verifyThroughEstate(
proofFor('repo/beta', betaClaims, 'ship/beta/xyz'),
memberProof(estate, 'repo/beta'),
estate.root,
)Properties that could not be read are listed in unreachable[] rather than
dropped — a root over nine of ten properties is a different root, and a reader
must see which nine.
Without a toolchain
vendor-checkpoint.mjs imports nothing but node: builtins. Copy it in and run
it:
node vendor-checkpoint.mjs emit # write checkpoint.json
node vendor-checkpoint.mjs verify # recompute, and prove every leaf
node vendor-checkpoint.mjs prove ship/acme/a1b2c3 # an inclusion proof// .checkpoint/config.json
{
"origin": "repo/your-repo",
"fragments": ["shiplog.fragment.json", "directory.fragment.json"],
"serve": "public/.well-known/checkpoint.json"
}A fragment you declared and it cannot find is named in the output, never skipped quietly — a head over two fragments when three were declared is a different root, and silence would make it look complete.
The vendored emitter and this package are held to the same roots by a drift test that runs both and compares, across thirteen tree sizes. Two Merkle implementations that disagree by one byte produce completely different roots, and each one calls the other's head forged.
The tree
RFC 6962 §2.1, unchanged — not a variant. Certificate Transparency has had a decade of adversarial review on exactly this structure.
MTH({}) = SHA256()
MTH({d0}) = SHA256(0x00 || d0)
MTH(D[n]) = SHA256(0x01 || MTH(D[0:k]) || MTH(D[k:n])) k = largest power of 2 < nThe 0x00 / 0x01 prefixes are the whole security argument. Without them a
leaf whose data is two concatenated hashes is indistinguishable from the
interior node over them, and an attacker can present an interior node as a leaf
to prove the inclusion of something never added.
Claims are sorted by id so the root is a pure function of the claims — two
people with the same fragments must get the same tree.
SPEC.md has the format in full, including why size is not independent
evidence and what version 2 owes.
Licence
Apache-2.0.
