OXYGENHUB
# b951c58734e8 oxygenhub / docs / transparency.md
9.6 KiB · Markdown · last changed Keyless agents' release tags: log and serve a tag's acceptance and log place 16 days ago renderedsource blame history raw

A transparency log for OxygenHub

Design for oxygenhub#238 (an append-only, witnessed log) and #240 (trusted time for signatures), written 2026-10-17. Status: accepted (Andy, 2026-10-17, the three decisions below as recommended). Built 2026-10-18: step 1's log (oxy-core tlog, Oxygen spec §14.8; the hub's log, served as tiles with signed checkpoints, hub spec §13.5.3); inclusion proofs in responses and the rest follow. Hashes are BLAKE3, like the rest of Oxygen: tools for the tile and note formats read the log, but only Oxygen's code recomputes its roots.

Why

Every change, seal, push, receipt and approval on a hub is signed, and anyone can check the signatures. What they cannot check is completeness: that the hub showed them everything, and showed everyone the same thing. A hub could hide an attestation (a revocation, say), show one history to one client and another to someone else, or backdate a signature to before a key was revoked. Sigstore answers this with Rekor, a transparency log. Oxygen has nothing like it yet. That is the main gap the 2026-10-16 comparison found.

The aim: a hub cannot show two different histories, or drop or reorder anything it accepted, without being caught. As a by-product, every signature gets an independent upper bound on when it was made (#240).

What goes in

One entry per signed object the hub accepts, in the order it accepts them:

KindEntry made when
ref attestationa push moves a bookmark
seala push brings one
taga tag is created
rotate, revokean identity rotates or revokes a key
receipta Flow job's signed receipt is kept
approvalan approval is sealed
policya new trust policy is accepted
identitya new identity, or new delegates, is published

An entry is small and fixed-shape, never the payload itself:

Entry {
  version   [0]: 1
  kind      [1]: text            // as above
  repo      [2]: bytes(16)       // repository id; zero for identities
  object    [3]: ObjectRef       // the attestation, policy or identity
  signers   [4]: [KeyId]         // the keys whose signatures were checked
  received  [5]: u64             // the hub's clock, milliseconds (a claim)
}

The leaf hash is BLAKE3("oxygen-tlog-leaf\0" || cbor(Entry)), so the log commits to the exact object without holding it. Private repositories' entries reveal only an id and hashes, nothing else. A hub may keep a separate log for them if even that is too much (a setting; off by default).

The log

  • Structure: an RFC 6962 Merkle tree, served as C2SP tlog-tiles: static, cacheable tiles of hashes at /log/tile/... and entry bundles at /log/tile/entries/.... This is the format Sigstore's Rekor v2 and the Go checksum database use, so their tools (and anyone's) can read it. Tiles are immutable once full, so they sit behind Cloudflare's cache for free.
  • Checkpoints: a C2SP signed note at /log/checkpoint: the origin (the hub's host plus /log), the tree size and the root hash, signed with the hub's key. The key is Ed25519 for the note format, and a composite Ed25519 + ML-DSA-65 signature goes in an extra note line, so the log stays post-quantum like the rest of Oxygen.
  • Sequencing: the hub adds entries in the same transaction that accepts the object (a push is accepted only if its entries are logged). It publishes a new checkpoint at most every few seconds, batching entries.
  • Storage: the tiles and entries in the hub's data directory, or through Strata in a bucket. They are append-only files: backups copy them, and nothing ever rewrites them.

Witnesses

A log alone only stops a hub showing two different histories if someone compares. Witnesses do that:

  • A witness (C2SP tlog-witness) fetches each new checkpoint, checks it is consistent with the last one it signed (a consistency proof), and cosigns it with its own key and its own clock.
  • The hub serves the latest checkpoint with every cosignature it has.
  • Who witnesses:
    • another OxygenHub (each hub can witness others: a setting, with peers);
    • a small oxy witness daemon anyone can run;
    • public witness networks that speak tlog-witness, such as the ones Sigstore and the Go checksum database use, once we register there.
  • A client (oxy, the VS Code extension, a verifier) trusts a checkpoint only when it carries enough cosignatures (policy: for example 2 of these N witnesses). It remembers the largest checkpoint it has seen per hub, and asks for a consistency proof whenever it sees a new one. A hub that forks its log gets caught the first time a client or witness sees both sides.

Trusted time (#240)

Today a signature's time is the signer's own claim (its HLC). So "was this signed before the key was revoked?" can only take the signer's word.

With the log, every object has an integrated time: the earliest witness cosignature over a checkpoint that includes it. That time comes from the witnesses' clocks, not the signer's or the hub's. It gives an upper bound: the signature existed by then.

Verification then uses it:

  • A delegation, expiry or revocation is checked at the integrated time, not the claimed one. A key revoked at T invalidates signatures integrated after T. Ones integrated before T stay valid, whatever they claim.
  • A signature whose claimed time is far from its integrated time (beyond a tolerance, for example 10 minutes) is flagged, though not refused: clocks drift, but backdating shows.
  • Before the log exists for a hub, or for objects older than it, verification falls back to today's behaviour and says so.

Built (2026-10-18, oxygen fd97f4c8 and the hub after it): the hub keeps each cosigned checkpoint by its size (migration 0083, tlog_cosigned_notes), lists its witnesses at GET /log/witnesses and in every refs answer (key 7), and for each logged bookmark serves the first cosigned checkpoints that hold its entry, with inclusion proofs (ref key 7). oxy pins the witnesses on first use beside the log key, takes the earliest trusted cosignature as the integrated time, and checks a delegate's refs delegation and a short-lived key's certificate at it (Oxygen spec §14.8). Not yet: revocations carry no time today, so they stay absolute rather than "revoked at T" (a decision for Andy, with revocation times); a failure at the integrated time is flagged, not refused; one trusted witness is enough (a k-of-n policy would take the k-th earliest time); bookmark and tag attestations get a time so far (tags since andymccall/oxygen#129, so a keyless agent's release tag is checked at it; seals and receipts next).

RFC 3161 timestamps from a timestamp authority (TSA) are the other way to get independent time. They are worth adding later for objects that never reach a hub (a local signature, say), but they need ASN.1 and CMS parsing, which none of our crates do today. The log covers everything a hub accepts, and needs no new dependencies.

What clients do

  • oxy push: the response carries each new entry's index and an inclusion proof against the next checkpoint (or a promise to include it, fulfilled on the next fetch). oxy keeps it beside the object.
  • oxy fetch: for every attestation fetched, check its inclusion proof against a witnessed checkpoint. Keep the newest checkpoint per remote and require consistency proofs between checkpoints. Fail closed on an inconsistency, and say plainly that the hub showed two histories.
  • oxy log verify HUB: audit a hub's whole log (or a range): recompute the tree from the tiles, check every checkpoint and its cosignatures, and list the entries.
  • The hub's pages: a change, push or receipt shows "logged at #N, witnessed by A, B at T". A /log page shows the latest checkpoint, its witnesses and recent entries.

Monitoring

Anyone can follow a log and alert on entries that concern them: "a new key was delegated for my identity", "a bookmark of my repository moved outside CI". oxy log watch does that for an identity or a repository, and the hub's own notifications can do it for its users. This is how a stolen key or a compromised hub is noticed quickly.

Order of work

  1. The log itself: Entry, the tree, tiles, signed checkpoints, and entries added as objects are accepted (hub). Inclusion proofs in push responses (protocol). About a week.
  2. Verification in oxy: inclusion and consistency checks on fetch, oxy log verify. About a week.
  3. Witnesses: cosignature support on the hub, oxy witness, hubs witnessing each other. About a week.
  4. Trusted time (#240): integrated time in verification; the hub's pages show it. A few days, once witnesses exist.
  5. Monitoring: oxy log watch and notifications. A few days.
  6. Later: RFC 3161 for objects that never reach a hub; registering with public witness networks.

Decisions (taken 2026-10-17)

Andy chose the recommendation each time: one log, hubs witnessing each other plus an oxy witness on a third machine, and warn for a release before refusing.

  1. Public entries for private repositories? The entries reveal only ids and hashes. One log for everything is simplest and strongest (a single history). A separate private log hides even that, but means two logs to witness. Recommendation: one log.
  2. Witnesses at launch: start with hubs witnessing each other (Earth and GCP already make two) and an oxy witness on a third machine; public witness networks later. Recommendation: yes.
  3. Enforcement: should oxy refuse an attestation with no inclusion proof, or only warn, while the log is new? Recommendation: warn for a release, then refuse, as with unverified repositories today.