Oxygen wire protocol for implementers
This document describes what a second implementation needs to talk to an Oxygen server or client: the transport, the message encodings, the order of requests, and the checks each side performs. It is derived from spec.md §5.5, §14, §16 and §17; where the two disagree the specification wins, and this document says which section to read.
Everything on the wire is deterministic CBOR (§5.1): maps have unsigned integer keys in increasing order, integers use the shortest form, no floats, no indefinite lengths, no tags. Objects travel as their canonical bytes, so the receiver hashes exactly what the sender signed.
1. Identifiers and hashing
- ObjectRef (32 bytes) = BLAKE3 of
"oxy1 <type> <len>\0" || body, where<type>is one ofchunk blob change checkpoint op attest identity policy stateand<len>is the decimal body length. A ChangeHash is the ObjectRef of a change; a KeyId is the ObjectRef of an identity. - ChangeId (16 random bytes) survives rewrites of a change.
- Repository id (16 random bytes) is chosen at
initand returned bycapabilities; a client refuses to push to a repository whose id differs from its own.
Object encodings are in spec §5.5 (conventions), §6.1 (change), §11 (checkpoint), §12.1 (op), §14.2 (identity), §14.4 (attestation), §14.5 (policy) and §10.1 (state).
2. Transport
The reference server (oxy serve) exposes one repository per subdirectory
of its root at <base>/<repo>/oxy/v1/<endpoint>:
| Method and path | Body | Response |
|---|---|---|
GET …/capabilities | — | capabilities |
GET …/refs | — | refs response |
POST …/negotiate | negotiate request | {0: [hashes]} |
POST …/fetch | fetch request | Bundle |
POST …/objects | objects request | Bundle |
POST …/checkpoint | checkpoint request | Bundle |
POST …/shallow | shallow request | {0: [hashes]} |
POST …/push | push request | push response |
Rules:
- Request and response bodies are CBOR with
Content-Type: application/oxy-cbor. A body is a single CBOR item (not a sequence). - Repository names are
[A-Za-z0-9._-]+and may not start with.; anything else, and any directory without.oxy/, is404. - Reads are unauthenticated.
pushrequiresAuthorization: Bearer <token>when the server was started with tokens; a missing or wrong token is401. - Errors are plain-text bodies with a 4xx or 5xx status; the client shows the
text. A push that is refused (policy, compare-and-swap) is a
200withok = falsein the push response, not an HTTP error; one a host refuses by its content (vet_push, spec §16.4) is an HTTP error with the host's reason as its text, and nothing of it is stored. - TLS is ordinary HTTPS. The reference client trusts the system web roots
plus an optional PEM file (
remote.<name>.caorOXY_CA); servers with self-signed certificates are expected to hand that file out. - Pushes to one repository are serialised by the server; reads are not.
- A hosted repository may be unbound: created empty with no id, so an
existing repository can be pushed to it and keep its identity. Its
capabilitiesreports an all-zero id. A client refuses to clone or fetch from an unbound remote and accepts it for a push, sending its own id in theOxy-Repo-Idrequest header (32 hex characters); the server binds the repository to that id under the push lock before validating the push, and refuses a push to an unbound repository that carries no header with400. Clients send the header on every request when they have an id; servers ignore it on bound repositories.
Embedding: oxy_server::Server answers any endpoint for a repository name
a host's Backend resolves and authorises (open, authorize, and an
after_push hook, and vet_push, with which a host refuses a push by
what it adds before anything is stored: an HTTP error, 422 say, whose
text, several lines perhaps, the client shows as it is; spec §16.4), so a hub with owner/name paths and its own accounts
routes /{owner}/{name}/oxy/v1/{endpoint} to Server::handle; oxy serve
is DirBackend on that same server.
The spec (§16) also defines framing for Noise and ZCT transports (u32 big-endian length || bytes); the reference implementation speaks HTTP only.
3. Messages
Keys are CBOR unsigned integers. [x] is an array of x; bytes(32) an
ObjectRef; "signed X" is [X bytes, [Signature]].
| Message | Encoding |
|---|---|
| Signature | {0: key id bytes(32), 1: alg (1 = Ed25519 + ML-DSA-65), 2: Ed25519 bytes(64), 3: ML-DSA-65 bytes(3309)} |
| Signed attestation | [attest bytes, [Signature]] |
| Signed policy | [policy bytes, [Signature]] (also the policy proposal file format) |
| Capabilities | {0: [protocol versions], 1: repo id bytes(16), 2: [feature names]}; version 1; features currently ["seal"] |
| Refs response | {0: [ref], 1: repo id, 2: signed policy or null, 3: [tag], 4: [signed policy]} |
| ref | {0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null, 5: signed acceptance}; the attestation is present only when it describes the current state; last serial is the highest attestation serial ever recorded for the name; key 5 (optional, only when there is one) is the host's accepted attestation of that attestation (spec §14.7); key 6 (optional) is [entry number, entry bytes, [proof hash]], the attestation's inclusion proof in the host's transparency log against the response's key 6, the latest checkpoint as a signed note (spec §14.8) |
| tag | {0: name, 1: state ref, 2: [heads], 3: signed attestation or null} |
| Negotiate request | {0: [have], 1: [want]}; response {0: [hashes]} in dependency order (no change before its dependencies) |
| Fetch request | {0: [hashes], 1: max blob size or null}; response a Bundle whose blobs above the size (and their chunks) are omitted |
| Objects request | {0: [refs]}; response a Bundle carrying only objects; a blob is accompanied by its chunks |
| Checkpoint request | {0: [heads]}; response a Bundle of objects: the checkpoint of exactly those heads, its payload blob, and the blob's chunks; the server computes one when it has none for those heads |
| Shallow request | {0: [heads], 1: depth}; response {0: [hashes]}: the changes within depth dependency steps of the heads, dependencies first |
| Bundle | {0: [[change bytes, [Signature]]], 1: [signed attestation], 2: [[type name, object bytes]], 3: [signed policy]} |
| Push request | {0: Bundle, 1: [update], 2: signed policy or null, 3: [tag]} |
| update | {0: name, 1: old state ref, 2: new state ref, 3: force (bool), 4: signed attestation or null} |
| Push response | {0: [{0: name, 1: ok (bool), 2: reason (text)}]}; a result named policy reports the policy update, tags/<name> each tag |
Bundle details:
- Changes are in dependency order, each with its individual signatures (possibly none).
- Key 1 carries seals (
kind = seal) covering the changes. - Key 2 objects are only
chunk,blob,identity,state,checkpoint; a receiver rejects any other type. - Key 3 (optional) carries the policies between the receiver's serial and the one in the push request, ascending, so the receiver can verify each step (§14.5). The refs response carries the same history in its key 4.
Sizes: a seal lists at most 4096 hashes, sorted and unique. Chunks are
32–256 KiB (FastCDC, average 64 KiB, gear table from the BLAKE3 XOF of
"oxy1 fastcdc gear", spec §13.2); a receiver re-hashes every object.
4. Sequences
4.1 Clone or fetch
capabilities: check protocol version 1 and record the repository id.refs: for each wanted bookmark notestate,heads,attestation; take the policy and history.- Policy (spec §14.5): with no local policy, accept the remote's if it is for this repository and carries a valid signature by one of its own roots (trust on first use). With a local policy, refuse a lower serial or a different policy at the same serial; otherwise verify each policy in the chain against the one before it (signatures by the previous roots meeting the previous threshold, serial strictly increasing) and adopt them in order. Record the highest serial this remote has ever presented and refuse a remote that later presents none or a lower one.
negotiatewithhave= local heads of every bookmark,want= the remote heads. The response lists the missing changes.fetchthose hashes (withmax blob sizefor a partial clone). Store objects, changes and seals; verify every change's signatures and seals; under a policy, authenticate each new change (§14.4: signer is the author or a delegate of the author) and check it is authorised for the bookmark by the first matching rule (a rule key covers its rotation predecessors, and a delegate's signature counts for the author).- Verify the bookmark's attestation (
kind = ref: repo id, name, old state, new state, serial) against the policy and against the serial you last recorded for that reference (rollback protection), then updateremotes/<name>/<bookmark>. - Tags: adopt those not yet held, checking their attestations the same way.
A shallow clone replaces steps 4–5 by shallow (the hashes within the
depth) plus checkpoint for the heads, and fetches only the listed changes;
their missing dependencies form the boundary. Lazy blobs are fetched later
with objects.
4.2 Push
capabilitiesandrefsas above; refuse if the remote's policy is newer than yours (fetch first).- Compute the changes the remote lacks (
negotiatewith the roles reversed, or locally from the remote heads), bundle them in dependency order with their signatures, seal your unsigned changes with onesealattestation, and attach identities, blobs, chunks and the new state object. - Attest the reference:
kind = refwithserial= the remote'slast serial + 1, signed by your key. - Send
pushwithold state= the state the remote reported (empty state ref when the bookmark is new), the new state, and your policy with its intermediate history when the remote's serial is behind.
4.3 What a server checks on push
In this order, spec §16.4: adopt the policy chain if present (each step
verified against the previous policy); import the bundle, verifying every
object's hash and, under a policy, every change's authentication and
authorisation; verify the reference attestation (signer authorised for the
name, serial above the last recorded); check old state equals the current
state (compare-and-swap; force skips this check only); check the rule's
allow_conflicts and require (signed, sealed, any); check every
dependency of every change is present; then update the references
atomically and record the attestation. Any failure rejects the whole push
with ok = false and a reason.
4.4 Editors and tools on the same machine
A tool that drives a local repository does not speak this protocol; it runs
oxy with --json (spec §24) and reads one JSON document per command. The
VS Code extension in oxygen-vscode/docs/spec.md §4 lists the commands it
relies on.
5. Minimal implementations
A read-only client needs: CBOR decode, BLAKE3, the change and state
decoders, capabilities/refs/negotiate/fetch, and the ability to
materialise a state (spec §7–§9). Signature verification (Ed25519 and
ML-DSA-65) is required to honour a policy; without it, treat every
repository as unverified and say so.
A server needs the above plus storage, the push checks of §4.3, per-
repository write serialisation, and the policy chain (refs key 4, bundle
key 3). It never needs a private key of its own: servers store and forward
signatures, they do not make them.
Test material: the encoders and decoders in crates/oxy-core/src/protocol.rs
(wire module) and the round-trip and corruption tests beside them; the
signature vectors in crates/oxy-core/src/identity.rs; and the chunking
bounds test in crates/oxy-core/src/chunk.rs.