$CHANNEL_REGISTRY_SSH, $CHANNEL_ROOT/. Why now: the finding below was
made yesterday while proving a subscriber’s pull scope, and the next thing that
happens on this channel is a subscriber holding one of these credentials.
What changed on the host: one file. $CHANNEL_ROOT/Caddyfile. env,
htpasswd, compose.yaml and registry-config.yml are byte-identical before
and after — no account was minted, no password rotated, no hash rewritten. The
registry container was never stopped and nothing was pruned.
What was true before
The Caddyfile’s terminalhandle carried no basic_auth of its own, so every
read fell through to the registry, whose htpasswd is all-or-nothing. Writes
had been confined per station since 2026-08-25; reads were not confined at all.
Measured with a subscriber credential, not inferred:
RUNBOOK § 5.2 promised only that enumeration stays behind credentials. It
never promised isolation between subscribers, and there was none.
The rule
A credential reads the channel(s) it consumes and its own station path. Nothing else on the host.
The station half needs no per-account configuration: as on the submit path,
the account name is the path segment, so the expression compares the
authenticated user against the captured segment. The channel half cannot be
derived from a name — a channel has many subscribers — so it is an explicit
list at the edge, and admitting an account to a channel is one line, exactly
like granting station-write.
The gate is additive. Caddy authenticates to decide scope and passes the
caller’s own
Authorization upstream; the registry still authenticates every
request that gets through.
route rather than bare directives because the order of the steps is the
policy — authenticate, refuse, then proxy — and route keeps them in the order
they are written rather than Caddy’s directive order. The block sits below both
write gates and above the terminal handle, which now receives only the verbs
neither gate claims.
One account carries two channels, and it is ours: the ephemeral staging
station consumes the internal estate and installs a subscriber’s own entry on
a build host before the subscriber ever sees it. That second line is in the map
because the ledger says that station consumed that channel — its ledger name
and its account name differ, so no name rule could have derived it. It was read
out of the ledger, not assumed.
The table, before → after
Every cell measured against the live edge, the same probe set before and after.
The anonymous column is unchanged in every row — including the signature
manifest and its blob, both still 200 with no credential. (An anonymous
signature GET without an
Accept header still answers 404 by content
negotiation, exactly as § 5.2 has said since August. It looks like a policy
failure and is not.)
The controls
- Production, and it is the one that would have forced a rollback.
vexa-prodandvexa-staging-bbbstill fetchvexa-internal:currentatsha256:42fcfa3a…and still read their own station bundles. Checked first, within seconds of the recreate, before anything else was measured. - Write policy byte-for-byte unchanged. The same five write probes before and after: a subscriber 401 on a publisher path, 403 on another station’s path, 401 on its own channel’s push path; the publisher opens an upload session (202) and cancels it (204). Diff of the two runs: empty.
- Real clients, not just status codes. With the subscriber credential:
oras pullof its entry returns the four expected files at the entry’s own digest, andhelm pullof the chart returnsvexa-0.12.36.tgz. The same clients againstvexa-internalfail with a clean403 Forbiddenrather than a hang or a mangled manifest. - The channel page inherits the scope for free. It reads
/v2/back through this same edge with the caller’s credential, and its refusal branch already treated 403 like 401 — so a subscriber asking for another channel’s page now gets the one-line “This is a Vexa Delivery channel. Sign in…” instead of that channel’s release, entry sequence and digest. No change was needed in the page service. edge-signature-readernarrowed to what its name says. It has no bcrypt inenv— only the base64 the edge presents upstream — so it is not in the read gate and is refused 401 everywhere except the anonymous signature route it exists to serve. Anonymous verification is unaffected: that route is above the gate and untouched.
The claim path, end to end
Re-run on the live edge, because a credential that cannot be claimed is not a credential:add <throwaway> --park --channel <channel> --ttl 900 --ledger <scratch>— minted, parked, six digits printed once. The park arrived owned by the spool’s uid, not root: the ownership defect from the 2026-09-06 deploy is fixed on the branch this mint ran from, and did not have to be chowned by hand.kit/claim.sh --code … --station <throwaway> --print-oncefrom the build host — exit 0, credential returned,attempts.ndjsonattributing the claim to that host’s real public address.- The claimed credential reproduces the scoped table exactly: its channel’s
entry, chart, kit, verifier, revocations and
tags/list200 — with a realorasfetch at the entry’s own digest — and 403 on the internal channel, on every station path including the one belonging to the subscriber whose channel it shares, on_catalogand on scratch. revoke <throwaway>— the credential answers 401, and the account is gone fromhtpasswd, which is byte-identical to its value before the rehearsal.
403 {"error":"refused"} and recorded
outcome: wrong-code. That is the design working, and it is also the reason
the operator reading digits aloud cannot distinguish a mistyped code from an
expired one without looking at the spool.
Rollback, tested dry
The pre-change file was copied beside the live one before anything was applied and is byte-identical to the snapshot taken at the start (sha256:8242b52f…). It was adapted and provisioned in a throwaway container
with the live env_file, so compose’s $$ interpolation was exercised the way
the real stack exercises it: Valid configuration, exit 0, without touching
the running listener. The rollback is two commands — restore the copy, then
docker compose up -d --force-recreate from $CHANNEL_ROOT (a plain
docker restart does not re-read the file).
What this changes for the next subscriber
The edge half of a mint now comes first. An account the edge has never heard of is refused 401 on every read — including theGET /v2/ that
vexa_subscriber.py add uses to prove a fresh credential before printing it,
so the mint aborts and reports “the rotation is broken” when the truth is
“this account has no read scope yet”. Admit the account at the edge first
(one SUB_<NAME>_BCRYPT line, one basic_auth line, one channel line, then a
recreate), then mint. Rotating an existing account is unchanged — the same env
key the read gate reads is the one add rewrites, so a rotation on a call
still works with no edge edit at all, which was verified here.
Making the tool write both halves is the obvious follow-up and is not in this
change.