tenant-iso-binding
Fixes discussion #220 (tenant-isolation audit of main 78fc8d40, findings on pointer binding, envelope AAD and relay deny gaps).
What changes
1. Channel pointers bind the account. ChannelPointer.Account ("account", omitempty) is signed into every new pointer. signPointer refuses to sign one with no account. verifyPointer/validatePointer take wantAcct and refuse a pointer whose account is set and differs. Every caller already had acct in scope.
- Old pointers name no account and still verify anywhere. The golden fixture is unchanged and laddercheck passes. Old binaries ignore the key, so no rollout ordering is needed.
- Downgrade: the field sits under the signature, so stripping it breaks the signature. The residual is the fixed set of account-less pointers already in buckets. They never expire, so they can be replayed across accounts by anyone who can write the victim's prefix. Such a pointer resolves only to a version shipped under the victim's own prefix. Every promote, rollback or fast-track re-signs with the account. Sunset: once no live pointer lacks an account, make the account required in
validatePointer. This could later use a verb that re-signs every channel pointer (not done here). accounts migrateused to byte-copy pointers between accounts, and the binding breaks that. - A bound pointer is now re-signed for the destination by root (release.RebindPointer). The rebind is deterministic and keepsPromotedAt, so re-runs and the verification pass compare equal. Account-less pointers still copy as bytes. - The root key is read only when the first pointer that needs re-signing turns up. Without a rootCARLOS_RELEASE_KEY, the verb stops before writing that pointer, but anything earlier in the plan has already been copied: immutable release objects and any account-less pointers. Nothing is deleted. Re-running with the key set converges, because already-copied objects are recognised as identical. - Operators moving an app that was promoted after this rollout need the root key in the environment.
2. Secret envelopes: read v2 now, write it only when switched on. The v2 envelope is "CSB2" || ephPub || nonce || ct. It uses HKDF info carlos-secrets-v2, and its GCM AAD is the length-prefixed (CSB2, account sqid, app). Open(priv, Binding, env) opens v2 only for its own binding. v1 (first byte 0x04) opens as before. CheckEnvelope accepts both shapes. Sync renders as (RouteAccount(rt), rt.App).
- Safe by construction: writing v2 is off by default.
appconfig.Sealwrites v1 untilSetBoundSealing(true). - The console turns this on fromCONSOLE_SECRETS_ENVELOPE_V2=true, and a value that is not a boolean stops the boot. That covers the console's own sealers: relay, mail and objstore. - GET config returnssecrets_accountonly while the flag is on. API-modecarlos secrets setseals v2 (SealBound) only when the console names the account, and v1 otherwise. The same rule covers older consoles, which never send the field and whoseCheckEnvelopewould reject v2. - Direct-bucketsecrets setreadsCONSOLE_SECRETS_ENVELOPE_V2from the operator's environment. - I chose the explicit setting over inferring from box status snapshots. A stale or missing snapshot must not be able to switch v2 on. - Behaviour change: API-mode
carlos secrets setnow always makes the console's GET config call, even whenCARLOS_SECRETS_PUBKEYis set in the environment, because only the console can name the account sqid. Before, that variable meant no GET at all. Callers such as CI jobs that set the variable now fail if the console cannot read the config bundle (for example a bucket read error), where they used to go straight to the write. - Downgrade: cutting off the prefix leaves a v1-shaped body, but it fails the GCM tag because the HKDF info differs (tested). The residual is every v1 envelope, both the ones already stored and any written while the flag is off. Sunset: once no stored envelope is v1, refuse v1 in
Open. This needs an inventory, for example a console count of v1 envelopes per app (not done). It also depends on the flag actually being set, which nothing enforces. That is tracked on #220.
Rollout:
- Merge and roll every box, console box included, to a build containing this change. Nothing is written as v2 yet.
- Once every box reports that build, set
CONSOLE_SECRETS_ENVELOPE_V2=trueon the console and restart it. New secrets from the console and the CLI are then bound. - Optional: re-set existing secrets to re-seal them as v2. A later change can then refuse v1.
3. Relay deny list. Adds 0.0.0.0/8, 192.0.0.0/24 and 198.18.0.0/15. NAT64 (64:ff9b::/96) and 6to4 (2002::/16) peers are checked through their embedded IPv4, so private and loopback addresses behind them are refused while public ones still work. IPv4-mapped addresses were already covered by IPNet.Contains.
Tests
- release: cross-account replay refused, fresh payload names its account, account-less golden still verifies at any account, signing requires an account, rebind (deterministic, verifies only at the destination, the legacy pointer is untouched, the wrong key is refused).
- appconfig: -
Sealwrites v1 while the switch is off and v2 when it is on, and validates the binding either way. - Bound open/refuse across account and app, and the downgrade attempt. - A v2 golden vector, plus a Sync-level test showing another account's envelope is not rendered. - console: GET has no
secrets_accountwhile the flag is off, and has it while on. CLI:sealSecretAPIbinds, or falls back to v1. apimode decode tests. - relay:
TestServerPermitextended with the new ranges, the embedded forms and nearby addresses that must stay allowed. It failed on main code before the fix. - e2e migrate tests: root key supplied, and a refusal without it is asserted.
- maintenance cross-purpose test covers both appconfig shapes.
Gate (local)
gofmt, build and vet are clean. The touched packages pass: release, appconfig, relay, console, cmd/console, mail, objstore, maintenance, host. In the root package, the e2e, secrets and migrate tests pass. Full make test fails only on known environment issues that also occur on main: unix-socket path length under the long TMPDIR (activator, edge, instanceexec), and root tests reading the operator's ~/.carlos/config (TestShipVerbAPIMode log.Fatals on main as well).
Not done
- No sunset switch for either residual.
- Teredo (
2001::/32) and local-use NAT64 (64:ff9b:1::/48) are not handled. - docs/site is not updated: the new setting is an operator console variable, not a CLI command or flag.
Reviewed: Fable approved @a794871d. This description was then corrected (migrate wording, the secrets-set GET behaviour change). The only change to the commit is its message.
Discussions 1 open
Tasks
- No tasks yet.
Activity
- claude agent, operated by paul@keymail.dev replied to Bind release pointers and secret envelopes to their account; close relay deny-list gaps
- claude agent, operated by paul@keymail.dev replied to Bind release pointers and secret envelopes to their account; close relay deny-list gaps
- claude agent, operated by paul@keymail.dev replied to Bind release pointers and secret envelopes to their account; close relay deny-list gaps
- claude agent, operated by paul@keymail.dev opened Bind release pointers and secret envelopes to their account; close relay deny-list gaps