| 6e006eaf Andy McCall ↶ |
1 |
# A transparency log for OxygenHub |
| 2 |
|
| 3 |
Design for oxygenhub#238 (an append-only, witnessed log) and #240 (trusted |
| 6ba07678 Andy McCall ↶ |
4 |
time for signatures), written 2026-10-17. **Status: accepted** (Andy, 2026-10-17, the three |
| 965b950f Andy McCall ↶ |
5 |
decisions below as recommended). Built 2026-10-18: step 1's log (oxy-core |
| 6 |
`tlog`, Oxygen spec §14.8; the hub's log, served as tiles with signed |
| 7 |
checkpoints, hub spec §13.5.3); inclusion proofs in responses and the rest |
| 8 |
follow. Hashes are BLAKE3, like the rest of Oxygen: tools for the tile and |
| 9 |
note formats read the log, but only Oxygen's code recomputes its roots. |
| 6e006eaf Andy McCall ↶ |
10 |
|
| 11 |
## Why |
| 12 |
|
| 13 |
Every change, seal, push, receipt and approval on a hub is signed, and |
| 14 |
anyone can check the signatures. What they cannot check is **completeness**: |
| 15 |
that the hub showed them everything, and showed everyone the same thing. A |
| 16 |
hub could hide an attestation (a revocation, say), show one history to one |
| 17 |
client and another to someone else, or backdate a signature to before a |
| 18 |
key was revoked. Sigstore answers this with Rekor, a transparency log. |
| 19 |
Oxygen has nothing like it yet. That is the main gap the 2026-10-16 |
| 20 |
comparison found. |
| 21 |
|
| 22 |
The aim: **a hub cannot show two different histories, or drop or reorder |
| 23 |
anything it accepted, without being caught**. As a by-product, every |
| 24 |
signature gets an **independent upper bound on when it was made** (#240). |
| 25 |
|
| 26 |
## What goes in |
| 27 |
|
| 28 |
One entry per signed object the hub accepts, in the order it accepts them: |
| 29 |
|
| 30 |
|
| 31 |
|
| 32 |
| `ref` attestation | a push moves a bookmark | |
| 33 |
| `seal` | a push brings one | |
| 34 |
| `tag` | a tag is created | |
| 35 |
| `rotate`, `revoke` | an identity rotates or revokes a key | |
| 36 |
| `receipt` | a Flow job's signed receipt is kept | |
| 37 |
| `approval` | an approval is sealed | |
| 38 |
| `policy` | a new trust policy is accepted | |
| 39 |
| identity | a new identity, or new delegates, is published | |
| 40 |
|
| 41 |
An entry is small and fixed-shape, never the payload itself: |
| 42 |
|
| 43 |
``` |
| 44 |
Entry { |
| 45 |
version [0]: 1 |
| 46 |
kind [1]: text // as above |
| 47 |
repo [2]: bytes(16) // repository id; zero for identities |
| 48 |
object [3]: ObjectRef // the attestation, policy or identity |
| 49 |
signers [4]: [KeyId] // the keys whose signatures were checked |
| 50 |
received [5]: u64 // the hub's clock, milliseconds (a claim) |
| 51 |
} |
| 52 |
``` |
| 53 |
|
| 54 |
The leaf hash is `BLAKE3("oxygen-tlog-leaf\0" || cbor(Entry))`, so the log |
| 55 |
commits to the exact object without holding it. Private repositories' |
| 56 |
entries reveal only an id and hashes, nothing else. A hub may keep a separate |
| 57 |
log for them if even that is too much (a setting; off by default). |
| 58 |
|
| 59 |
## The log |
| 60 |
|
| 61 |
- **Structure:** an RFC 6962 Merkle tree, served as **C2SP tlog-tiles**: |
| 62 |
static, cacheable tiles of hashes at `/log/tile/...` and entry bundles at |
| 63 |
`/log/tile/entries/...`. This is the format Sigstore's Rekor v2 and the Go |
| 64 |
checksum database use, so their tools (and anyone's) can read it. Tiles |
| 65 |
are immutable once full, so they sit behind Cloudflare's cache for free. |
| 66 |
- **Checkpoints:** a C2SP signed note at `/log/checkpoint`: the origin (the |
| 67 |
hub's host plus `/log`), the tree size and the root hash, signed with the |
| 68 |
hub's key. The key is Ed25519 for the note format, and a composite |
| 69 |
Ed25519 + ML-DSA-65 signature goes in an extra note line, so the log stays |
| 70 |
post-quantum like the rest of Oxygen. |
| 71 |
- **Sequencing:** the hub adds entries in the same transaction that accepts |
| 72 |
the object (a push is accepted only if its entries are logged). It |
| 73 |
publishes a new checkpoint at most every few seconds, batching entries. |
| 74 |
- **Storage:** the tiles and entries in the hub's data directory, or |
| 75 |
through Strata in a bucket. They are append-only files: backups copy |
| 76 |
them, and nothing ever rewrites them. |
| 77 |
|
| 78 |
## Witnesses |
| 79 |
|
| 80 |
A log alone only stops a hub showing two different histories if someone |
| 81 |
compares. **Witnesses** do that: |
| 82 |
|
| 83 |
- A witness (C2SP tlog-witness) fetches each new checkpoint, checks it is |
| 84 |
consistent with the last one it signed (a consistency proof), and |
| 85 |
cosigns it with **its own key and its own clock**. |
| 86 |
- The hub serves the latest checkpoint with every cosignature it has. |
| 87 |
- **Who witnesses:** |
| 88 |
- another OxygenHub (each hub can witness others: a setting, with |
| 89 |
peers); |
| 90 |
- a small `oxy witness` daemon anyone can run; |
| 91 |
- public witness networks that speak tlog-witness, such as the ones |
| 92 |
Sigstore and the Go checksum database use, once we register there. |
| 93 |
- **A client** (oxy, the VS Code extension, a verifier) trusts a checkpoint |
| 94 |
only when it carries enough cosignatures (policy: for example 2 of these |
| 95 |
N witnesses). It remembers the largest checkpoint it has seen per hub, |
| 96 |
and asks for a consistency proof whenever it sees a new one. A hub that |
| 97 |
forks its log gets caught the first time a client or witness sees both |
| 98 |
sides. |
| 99 |
|
| 100 |
## Trusted time (#240) |
| 101 |
|
| 102 |
Today a signature's time is the signer's own claim (its HLC). So "was this |
| 103 |
signed before the key was revoked?" can only take the signer's word. |
| 104 |
|
| 105 |
With the log, every object has an **integrated time**: the earliest |
| 106 |
witness cosignature over a checkpoint that includes it. That time comes |
| 107 |
from the witnesses' clocks, not the signer's or the hub's. It gives an upper |
| 108 |
bound: the signature existed by then. |
| 109 |
|
| 110 |
**Verification then uses it:** |
| 111 |
- A delegation, expiry or revocation is checked at the integrated time, |
| 112 |
not the claimed one. A key revoked at T invalidates signatures integrated |
| 113 |
after T. Ones integrated before T stay valid, whatever they claim. |
| 114 |
- A signature whose claimed time is far from its integrated time (beyond a |
| 115 |
tolerance, for example 10 minutes) is flagged, though not refused: clocks |
| 116 |
drift, but backdating shows. |
| 117 |
- **Before the log exists for a hub,** or for objects older than it, |
| 118 |
verification falls back to today's behaviour and says so. |
| 119 |
|
| 120 |
**RFC 3161 timestamps** from a timestamp authority (TSA) are the other way |
| 121 |
to get independent time. They are worth adding later for objects that never |
| 122 |
reach a hub (a local signature, say), but they need ASN.1 and CMS parsing, |
| 123 |
which none of our crates do today. The log covers everything a hub |
| 124 |
accepts, and needs no new dependencies. |
| 125 |
|
| 126 |
## What clients do |
| 127 |
|
| 128 |
- **`oxy push`:** the response carries each new entry's index and an |
| 129 |
inclusion proof against the next checkpoint (or a promise to include it, |
| 130 |
fulfilled on the next fetch). oxy keeps it beside the object. |
| 131 |
- **`oxy fetch`:** for every attestation fetched, check its inclusion proof |
| 132 |
against a witnessed checkpoint. Keep the newest checkpoint per remote and |
| 133 |
require consistency proofs between checkpoints. Fail closed on an |
| 134 |
inconsistency, and say plainly that the hub showed two histories. |
| 135 |
- **`oxy log verify HUB`:** audit a hub's whole log (or a range): recompute |
| 136 |
the tree from the tiles, check every checkpoint and its cosignatures, and |
| 137 |
list the entries. |
| 138 |
- **The hub's pages:** a change, push or receipt shows "logged at #N, |
| 139 |
witnessed by A, B at T". A `/log` page shows the latest checkpoint, its |
| 140 |
witnesses and recent entries. |
| 141 |
|
| 142 |
## Monitoring |
| 143 |
|
| 144 |
Anyone can follow a log and alert on entries that concern them: "a new key |
| 145 |
was delegated for my identity", "a bookmark of my repository moved outside |
| 146 |
CI". `oxy log watch` does that for an identity or a repository, and the |
| 147 |
hub's own notifications can do it for its users. This is how a stolen key |
| 148 |
or a compromised hub is noticed quickly. |
| 149 |
|
| 150 |
## Order of work |
| 151 |
|
| 152 |
1. **The log itself:** Entry, the tree, tiles, signed checkpoints, and |
| 153 |
entries added as objects are accepted (hub). Inclusion proofs in push |
| 154 |
responses (protocol). About a week. |
| 155 |
2. **Verification in oxy:** inclusion and consistency checks on fetch, |
| 156 |
`oxy log verify`. About a week. |
| 157 |
3. **Witnesses:** cosignature support on the hub, `oxy witness`, hubs |
| 158 |
witnessing each other. About a week. |
| 159 |
4. **Trusted time (#240):** integrated time in verification; the hub's |
| 160 |
pages show it. A few days, once witnesses exist. |
| 161 |
5. **Monitoring:** `oxy log watch` and notifications. A few days. |
| 162 |
6. **Later:** RFC 3161 for objects that never reach a hub; registering with |
| 163 |
public witness networks. |
| 164 |
|
| 6ba07678 Andy McCall ↶ |
165 |
## Decisions (taken 2026-10-17) |
| 6e006eaf Andy McCall ↶ |
166 |
|
| 6ba07678 Andy McCall ↶ |
167 |
Andy chose the recommendation each time: one log, hubs witnessing each |
| 168 |
other plus an `oxy witness` on a third machine, and warn for a release |
| 169 |
before refusing. |
| 170 |
|
| 6e006eaf Andy McCall ↶ |
171 |
1. **Public entries for private repositories?** The entries reveal only ids |
| 172 |
and hashes. One log for everything is simplest and strongest (a single |
| 173 |
history). A separate private log hides even that, but means two logs to |
| 174 |
witness. Recommendation: **one log**. |
| 175 |
2. **Witnesses at launch:** start with hubs witnessing each other (Earth |
| 176 |
and GCP already make two) and an `oxy witness` on a third machine; |
| 177 |
public witness networks later. Recommendation: **yes**. |
| 178 |
3. **Enforcement:** should oxy refuse an attestation with no inclusion proof, |
| 179 |
or only warn, while the log is new? Recommendation: **warn for a release, |
| 180 |
then refuse**, as with unverified repositories today. |