Skip to content

Node identity without a coordinator: contain a host compromise to one node #489

Closed

Description

@CMGS

Status: current scope is a cell-level blast radius. The earlier per-node design is kept at the bottom for history only; do not implement it.

Goal

A host compromise on one node is bounded to its cell. Ops can cut a node or a cell off without that node's cooperation. No new protocol and no new central component; cell size is the operator's knob.

Done

Sandbox code

None required. Rotation without downtime is not planned: secrets rotate with a whole-cell restart, and cutoff relies on the network, not on rotation.

Deployment contract

Per cell:

  • a PostgreSQL database and role that cannot reach other cells' databases (sandboxd_tenants has no cell column, so cells cannot share a database);
  • a store credential limited by the backend's policy to the cell's bucket or prefix;
  • a cloud identity with no access to other cells' secrets or to IAM, network or firewall controls;
  • api_token, mesh.cluster_key, preview_secret and an egress root;
  • a tun exit with its own netns, FDB, neighbor table and an underlay allowlist of the cell's nodes.

Per node (recommended): its own PG role and store key with the cell's grants, so one node can be cut off without restarting the rest.

Ingress: control, preview and mesh ports, PostgreSQL and the store accept only the cell's node addresses, plus the control plane where it calls in. Backends refuse the cell's credentials from anywhere else.

Anti-spoofing outside the host: VPC rules on cloud; port security or routed ports on bare metal.

Tenant tokens are not reused across cells; this is the control plane's duty.

Runbook

  • Cutoff. On every host the node reaches, drop its address ahead of the established-connection rule and flush its conntrack entries; terminate its PostgreSQL sessions and disable its role and key. Done when the drop counters move, no established flow to it remains, and it is gone from /v1/peers on every remaining node within the measured bound.
  • Rotation. api_token, mesh, preview_secret, meta_store, checkpoint_store and egress_ca change only at a restart (config/reload.go), and nodes on different values cannot talk to each other, so restart the whole cell at once.
  • Recovery. Treat the whole cell as suspect. Rebuild hosts from clean images, re-promote operator templates from a source outside the cell, and do not trust the cell's checkpoints automatically.

Acceptance checks

  • Cell A's credentials cannot reach cell B's control API, previews, database, store or cloud resources.
  • Cutoff ends established and new access within the documented bound, without the node's help.
  • Copied credentials do not work from outside the cell.
  • A cell A node cannot inject into or draw traffic from cell B's overlay.
  • Cell B guests do not trust a cell A egress intermediate.
  • A rotation drill follows the real restart requirements.

Tasks

Earlier per-node design (superseded)

Goal

A guest that escapes to host root on one node loses that node's claims and
nothing else: no other node's claims, no cluster-wide credential, no way to
steer clients or peers to itself. The cluster keeps its gossip shape. No new
central service is added; every node verifies every other node locally. The
design runs on any platform: a cell of bare-metal hosts with no cloud provider
is a first-class case, and cloud attestation is an optional convenience.

Out of scope here:

What a compromised node holds today

Credential Scope What it grants
api_token every node root on every node: list claims, read any claim token (GET /v1/sandboxes/{id}), exec, delete, move, register volumes
mesh.cluster_key every node join gossip and replace any node's NodeState; merge checks only the epoch, so a member can rewrite another node's addr and draw redirects, moves and the tokens they carry
tenant tokens sent by clients the SDK and API replay a tenant token to whatever node a redirect names
claim tokens sent by clients and the adopter Adopter.Adopt sends the claim token to every peer
checkpoint store and meta store credentials every node read, write and delete every object and row
promoted templates the store checkTemplateOwner runs on the writing node, so any node can replace any template, including operator templates every tenant clones

The first four are lateral paths that need no shared storage at all. This
design removes them and makes the store tamper-evident.

Design

1. Node identity

Each node proves who it is once, at admission, with a credential that a trust
root the cell already holds can verify. Every peer checks it locally. No issuer
runs.

The key. At start the node generates an ed25519 key pair in memory. The
private key never touches disk and dies with the process; a restart is a new
key and a new admission.

The proof. The node binds that key to its identity with one of these
providers:

Provider Proof Trust root in config Online dependency
x509 the node signs its public key with the key of a per-node certificate the operator CA certificate none
gce an instance identity token from the metadata server (instance/service-accounts/default/identity?format=full) with audience = "sandboxd:" + base64url(sha256(pubkey)); Google signs it and names the project, zone, instance id and instance name Google's JWKS, cached only to admit a node whose token uses a key id not yet cached

x509 works on any platform and is the provider for bare metal, other clouds
and private datacenters. The certificate comes from the operator's own
provisioning (the system that installs the host), signed by an offline or
HSM-held CA; sandboxd only reads the files and reloads them when they change.
gce saves GCE hosts that step. A cell may mix both. Other cloud attestations
can be added as providers without changing the rest of the design.

The bundle. The public key plus the proof. A node serves its bundle at
GET /v1/identity.

Admission. A verifier admits a bundle when the proof verifies against a
trust root and the subject matches the cell's admission rule, for example
x509: CA C, subject matches sandbox-metal-* or
gce: project P, instance name matches sandbox-node-*. The node id is the
certificate subject or the instance name; it is never self-declared.

Continuity. The proof is checked once, at admission. After that the
verifier trusts the key for as long as the node gossips under it, whatever the
proof's own expiry. A GCE token's one-hour life or a certificate's expiry
therefore never drops a running peer; it matters only when a node restarts and
must be admitted again. Google's JWKS is cached without limit and refetched,
rate-limited, only for an unknown key id. When the trust root is unreachable,
existing members keep working for any length of time; only nodes that start
during the outage wait to be admitted.

Revocation. identity.revoked lists {node, since}. A verifier drops a
listed node's current key, refuses its admission, and rejects anything it
signed after since (§6). Expiry is not revocation: a node to be removed is
listed, not waited out.

The admission rule, trust roots and revocation list are cluster-invariant
config, covered by the existing digest. Adding a node needs no config change
when its name fits the rule.

New config:

"identity": {
  "provider": "x509",
  "x509": {"cert": "/path/node.pem", "key": "/path/node.key"},
  "admit": [
    {"kind": "x509", "ca": "/path/ca.pem", "subject": "sandbox-metal-*"},
    {"kind": "gce", "project": "P", "name": "sandbox-node-*"}
  ],
  "revoked": [{"node": "sandbox-metal-7", "since": "2026-01-01T00:00:00Z"}],
  "template_signers": ["/path/operator-publish.pub"]
}

A cell without a gce admission rule never contacts Google.

2. Signed gossip

NodeState gains PubKey and Sig. The origin signs each state it
publishes; relays pass it unchanged.

  • merge accepts a state for node X only when PubKey belongs to an admitted
    key whose node id is X and Sig verifies. A state that fails is dropped,
    whoever relayed it.
  • A node fetches an unseen key's bundle once from that node's
    GET /v1/identity. Push/pull carries the state, key and signature only
    (about 100 bytes more per node), never the bundle.
  • Routing reads only verified states: Candidates, PeerAddrs,
    TemplateOwners and the rest skip a member whose state never verified, so
    a SWIM member without an identity receives no traffic.
  • mesh.cluster_key stays optional. It hides gossip from the network but is
    no longer a trust anchor; leaking it lets a host join SWIM and nothing more.

3. Peer and API authentication

With identity set, the control port serves TLS. Each node serves a
self-signed certificate over its identity key, so a client that verified the
node's bundle knows it reached that node. Callers authenticate by role:

Role Credential Verified against
node client certificate over an admitted identity key the admission rule
operator an OIDC ID token with aud = "sandboxd", or an X.509 client certificate operators: [{kind: "oidc", issuer, subjects}, {kind: "x509", ca, subjects}]
tenant tenant token sandboxd_tenants (unchanged)
sandbox per-sandbox token the claim (unchanged)

OIDC suits a control plane that already has a workload identity (any issuer:
a cloud, Kubernetes, Dex, Keycloak). An operator X.509 certificate serves a
deployment with no OIDC issuer at all.

api_token remains for single-host deployments and is refused over the
network when identity is set. A new admin_socket (a unix socket, root-only
mode 0600) accepts operator calls without a credential, so on-host scripts keep
working: whoever can open it is already host root.

What each role may call:

Endpoint group operator node tenant sandbox
claim, release, hibernate, wake, env, renew yes, for any tenant (X-Sandbox-Tenant) no own own claim
exec, relay, ports, files yes no no own claim
GET /v1/sandboxes/{id} with token yes no no no
cordon, drain, pools, volumes yes no no no
move rounds (/v1/moves/*) no yes, see §5 no no
checkpoint blob, probe no yes no no
GET /v1/sandboxes/{id}/owner yes yes, without a token own own claim

A node cannot exec, read tokens, or change claims on a peer.

4. Acting for a tenant

A control plane that serves many tenants calls nodes as operator and names
the tenant in X-Sandbox-Tenant. It never sends a tenant token to a node, so a
redirect to a compromised node harvests nothing. Tenant tokens stay for SDK
users who talk to nodes directly; those clients follow redirects only to nodes
whose signed state names the redirect address (§7).

5. Ownership changes

A node decides its own moves and adoptions, so its own checks cannot stop a
compromised node. Integrity of what an adopter or a move target boots comes
from §6, not from liveness evidence.

  • Moves. The target admits a move only from the node that its verified
    view and the move record both name as the source. A malicious source can push
    only claims it already held; it cannot pull one, because a move starts at
    the source.
  • Adoption. A two-phase offer replaces the token broadcast. The adopter
    offers the claim to peers with the token's SHA-256, which the row already
    stores; a peer answers whether it would take it. Only the one peer that
    accepts receives the plaintext token, so refusing peers learn nothing and
    claim auth keeps comparing plaintext tokens as today. The accepting peer
    verifies the record chain (§6) with that token before it boots anything.

6. Checkpoint store integrity

The store and the archive rows stay shared within a cell; integrity no longer
depends on who can write them. Chunks are already addressed by SHA-256.

Checkpoints, archives and replicas are bound to the claim token. Every
record carries mac = HMAC(claim_token, record_digest), where the record names
the claim id, the manifest digest, a sequence number and the previous record's
digest. A reader verifies the MAC chain with the token it holds, from the
client's call or from the handing-over owner, before it boots anything. The
archive row in PostgreSQL becomes an untrusted pointer: a node that never held
the claim's token cannot produce a record that verifies, so it can break
availability but cannot make another claim boot its disk. The rule needs no
owner-of-record lookup and no liveness evidence, and it covers adoption after
the owner is gone. The writer also signs the manifest with its identity key,
which attributes objects for sweeps and revocation; the MAC is what readers
trust.

Templates split by who can clone them:

Kind Where it lives Signed by Rule
pool golden each node builds its own, on local disk — unchanged; never shared
operator template (empty tenant, every tenant may clone it) shared store the promoting node and a key in identity.template_signers a promote stages it; it is invisible to other nodes until an operator signs the key and content digest (POST /v1/templates/{name}/publish)
tenant template shared store the promoting node a replacement must be signed by the node that signed the current version, or by a template signer; every node also remembers the signer it last saw per template name and requires a template signer to change it, so a delete followed by a new version does not reset the rule

Operator promotes are rare operator actions, so the second signature costs a
step, not a process. The publish key is an ed25519 key the operator holds
(file, KMS or HSM); OIDC tokens authenticate but cannot sign content, so they
do not replace it. A control plane that drives tenant promotes may also
countersign them as a template signer.

Revocation. A reader rejects manifests and metas signed by a revoked node
after its since. Content signed before stays valid: a node's death does not
make what it signed suspect; its compromise does, from the moment listed. A
template signer re-signs what should outlive a revocation.

Keys and sweeps. Object keys gain the cell: {prefix}{cell}/.... The TTL
sweep and the departed-owner purge delete only objects this node signed, or
whose signer's lease expired.

A compromised node can delete objects (availability) but cannot plant an
operator template or make another claim boot a disk it wrote. Tenant templates
keep two narrow gaps, listed under Residual risk.

7. Clients

The SDK and any control plane:

  • verify the node bundle and TLS binding of every node they call;
  • follow a redirect or owner_addr only to an address that a verified signed
    state publishes.

8. Archive rows

putRow stops writing guest: false env values into the row (Env.Redacted
exists). The control plane re-injects them after a wake or adoption through
PATCH /v1/sandboxes/{id}/env.

What leaves the node

Today After
api_token gone on clusters; operators use OIDC or client certificates
mesh.cluster_key optional, not trusted
tenant tokens on the wire to every redirect target not sent by an operator; SDK sends only to verified owners
claim tokens broadcast by the adopter hash in the offer; the token goes only to the peer that accepts
claim env in archive rows redacted

Deployments without GCE

Nothing in the design depends on a cloud. A cell of bare-metal hosts, another
cloud, or a private datacenter needs:

Piece Provides it
node identity a per-node X.509 certificate from the operator CA, installed at provisioning
operator identity an OIDC issuer the operator already runs, or operator client certificates from the same or another CA
template publish key an ed25519 key held by the operator
checkpoint store any S3-compatible store (unchanged)
meta store any PostgreSQL (unchanged)
time NTP for certificate and token expiry checks (unchanged)

The one recurring duty is certificate renewal, done by the operator's
provisioning. Because of continuity (§1), an expired certificate stops a node
only at its next restart, and the node logs a warning ahead of expiry.

Residual risk

Within one cell a compromised node can still:

  • read every checkpoint and every row of the cell (confidentiality);
  • delete objects and rows, or expire other nodes' leases in the table, which
    makes owners drop claims (availability);
  • create a tenant template under a name that tenant has not used yet, which
    the tenant clones only if it later asks for that exact name; on a node that
    never saw the name's signer (a new node, a wiped cache), a delete followed
    by a new version also passes;
  • sign truthful-looking but inflated warm counts, templates and volumes, and
    draw a disproportionate share of new claims; an SDK that talks to nodes
    directly then sends that node its tenant token, which stays a cluster-wide
    bearer (a tenant key credential that never travels as a bearer would close
    this; not in this design);
  • on a tun pool, send frames whose inner source is another node's claim: the
    exit learns MACs (learning, arp_accept=1 in
    advanced network mode) and trusts any VTEP for any
    claim address. Closing it needs a static FDB driven by signed node states
    and, on an L2 underlay, underlay anti-spoofing;
  • use whatever its own claims' credentials allow until they are rotated.

It cannot act as another node, read or use another node's claim tokens,
redirect clients to an address it does not publish, plant an operator
template, make another claim boot a disk it wrote, or reach another cell
(advanced network mode aside, above). Per-node confidentiality of the store needs
per-claim data keys, which this design leaves for later.

On x509, the certificate key sits on the node's disk, so a compromised node
can be admitted again as itself until it is revoked. That is the same node, not
a new one; revocation (§1) ends it.

The egress root stays cluster-wide. A node's intermediate signs only traffic
that passes through that node's own proxy, so another node's intermediate gives
no path to a guest it does not host.

Scale

  • Verification is local: one proof check per node start, one signature check
    per state change.
  • Gossip grows by about 100 bytes per node.
  • A cell is the gossip, store-prefix and database-role unit. Large fleets run
    many cells; nothing crosses cells.
  • Adding or replacing a node requires no per-node secret, account, role or
    config entry beyond the certificate its provisioning already installs.

Hot path

The claim path is unchanged apart from TLS on the control port, which
keep-alive amortizes. Signature checks run on gossip merges, ownership changes
and store reads of a manifest, never per request or per frame.

Compatibility

Everything is opt-in behind identity; a node without it behaves as today.

Deployment or client Without identity With identity
single host, api_token unchanged not a target; a single host has no peers to protect
cluster, cluster_key + api_token unchanged migrate per Rollout
SDK with a tenant token unchanged unchanged; needs an SDK that speaks TLS and verifies bundles, or a transition plaintext listener
SDK or script with api_token unchanged refused over the network; use OIDC, an operator certificate, or admin_socket on the host
control plane serving many tenants unchanged operator identity plus X-Sandbox-Tenant; tenant tokens still accepted
guests, silkd, egress, previews unchanged unchanged

Mixed versions during a rollout:

  • Gossip. A node accepts unsigned states until identity.require_signed
    is set; routing prefers verified states while any are present.
  • Peer calls. A node in transition accepts both the root bearer and a node
    certificate on move rounds, blob pulls and probes. The adopter uses the
    two-phase offer with peers whose state advertises the new protocol and the
    current broadcast with the rest.
  • Control port. identity.plain_listen keeps a plaintext listener for
    tenant and sandbox tokens while SDKs upgrade; the operator role is never
    served on it.
  • Store. Objects written before the upgrade carry no MAC or signature and
    sit under the old prefix. At start each owner adds the MAC chain and its
    signature to the records of its own claims, since it holds their tokens, and
    a template signer signs the operator templates. Readers accept legacy objects
    only until identity.require_signed. An archived claim whose owner never
    returns stays legacy and is adopted only before that switch, or after a
    control plane that holds its token re-records it.
  • Archive rows. Rows written before the upgrade keep their env and wake as
    today; new rows are redacted, so a control plane must re-inject env on wake
    or adoption before redaction is enabled.

Breaking changes appear only when an operator sets require_signed or removes
plain_listen, and each is a config switch, not a release.

Rollout

  1. Independent of identity, ships now: §8 env redaction, the two-phase
    adoption offer, and the §3 table (tenant tokens stay off exec, relay,
    ports and files).
  2. Token-bound records (§6): closes store integrity without any PKI.
  3. Node identity, signed gossip and TLS roles, with both modes accepted:
    unsigned states and api_token still work, signed states are verified when
    present. Switch control planes to operator identity with
    X-Sandbox-Tenant, then turn identity on node by node.
  4. Sign existing operator templates; require signed states and objects
    cell-wide; rotate api_token and mesh.cluster_key, since every node held
    them.
  5. Later, or stays under Residual risk: exit hardening for tun pools and
    tenant key credentials.

Work breakdown

Package Size
identity: key, X.509 and GCE providers, bundle endpoint, admission, continuity, revocation, JWKS cache M
signed NodeState, verified-only routing M
TLS control port, role auth, endpoint table M
operator OIDC and X.509, X-Sandbox-Tenant S
move source check, two-phase adoption offer S
token-bound record MAC chain, verified before boot M
signed manifests, cell key prefix, scoped sweep M
template tiers: staged operator templates, publish endpoint, same-signer replacement M
archive row env redaction S
admin_socket, plain_listen, require_signed, mixed-version peer calls M
legacy store signing pass M
SDK: bundle and redirect verification M
config validation and probe MAC off cluster_key (config.go:553, :563, :838; probe.go:150) S

Tasks

  • identity: key, X.509 and GCE providers, bundle endpoint, admission, continuity, revocation, JWKS cache
  • signed NodeState, verified-only routing
  • TLS control port, role auth, endpoint table
  • operator OIDC and X.509, X-Sandbox-Tenant
  • move source check, two-phase adoption offer
  • token-bound record MAC chain, verified before boot
  • config validation and probe MAC that no longer need mesh.cluster_key
  • signed manifests, cell key prefix, scoped sweep
  • template tiers: staged operator templates, publish endpoint, same-signer replacement
  • archive row env redaction
  • admin_socket, plain_listen, require_signed, mixed-version peer calls
  • legacy store signing pass
  • SDK: bundle and redirect verification
  • later or residual: tun exit static FDB from signed states; tenant key credentials
  • docs: security.md deployment assumption 3 and the token model follow the new identity

Activity

  1. CMGS commented on Nov 7, 2026

    @CMGS
    ContributorAuthor

    Review against main ef58ef76. The "What a compromised node holds today" table matches the code: merge compares only the epoch (sandboxd/mesh/mesh.go:285), the adopter sends the claim token to every peer it tries (sandboxd/store/peer/adopt.go:24) and the adopter stores it (sandboxd/pool/failover.go:80), checkTemplateOwner runs on the writer (sandboxd/pool/template.go:295), and putRow writes Env unredacted (sandboxd/pool/failover.go:138). The egress paragraph also holds: the root key stays offline and nodes hold only intermediates.

    The design leaves six gaps. Gaps 1 and 2 conflict with the stated goal.

    1. Ownership still rests on rows a compromised node can write (§5, §6)

    §6 accepts a manifest when its signer is the owner of record, and the owner of record is the archive row in PostgreSQL, which every node of the cell can write. Node X can rewrite any archived claim's row to owner = X and checkpoint = <a disk X signed>, and keep token_sha256. When the victim wakes the claim, an honest node adopts it with the victim's own token, the hash matches, the signer equals the owner of record, and the claim boots X's disk. That replaces another node's checkpoint, which the design says X cannot do.

    Proposal: bind each record to the claim token, not to the row. Every archive and checkpoint record carries mac = HMAC(claim_token, record_digest), where the record names the claim id, the checkpoint digest, a sequence number and the previous record's digest. A reader verifies the MAC with the token it holds, from the client or from the handing-over owner, before it boots anything. PostgreSQL becomes an untrusted pointer. A node that never held the token can only break availability, so the rule needs no owner-of-record lookup and no liveness evidence, and it also covers adoption after the owner is gone.

    2. The tun exit trusts any node for any claim address

    The exit's vxlan0 runs with learning and arp_accept=1 (docs/advanced-network.md, single-host exit). X can send frames whose inner source is another node's claim. The exit then learns that MAC behind X's VTEP and sends the claim's return traffic to X. On a bare-metal L2 underlay, X can also spoof the exit's VTEP address toward another node's tun.listen and inject inbound frames. Either way X reaches guests it does not host.

    Proposal: run the exit with nolearning and static FDB entries that an exit-side agent programs from signed node states. Each node lists its claims' tun addresses and MACs in its signed NodeState, and the agent drops frames whose inner source a VTEP did not sign for. Bare-metal spoofing needs underlay anti-spoofing (switch port security) or an authenticated underlay between nodes and exits. If that is out of scope, the advanced network mode belongs under Residual risk.

    3. Signed state proves origin, not truth; tenant tokens remain cluster-wide bearer secrets

    X signs its own NodeState truthfully as far as signatures go, but it can inflate its warm counts, templates and volumes to draw new claims. An SDK that talks to nodes directly then hands X its tenant token, and that token is valid on every node. For example, POST /v1/checkpoints/{id}/claim lets the holder read every checkpoint of that tenant across the cell. §4 removes this only for control planes, and Residual risk does not list it.

    Proposal: give tenants a credential that never reaches a node as a bearer, for example a tenant ed25519 key whose public half sits in the tenant record, used through a client certificate or a request signature bound to the node id and a timestamp. In any case, state under Residual risk that a compromised node can attract a disproportionate share of new claims.

    4. The §3 table widens the tenant token

    The table grants tenant: own for exec, relay, ports and files. Today those accept only the sandbox token or the operator token (sandboxd/server/exec.go:47, sandboxd/pool/operator.go:91). Granting them to the tenant role makes gap 3 worse. These rows should stay no for tenant.

    5. The §5 liveness rule is absence-based

    The rule says "accept a new owner when no verified fresh state exists", and absence of evidence is what an attacker controls. X can withhold newer states or replay older ones. X can also declare Y dead at the SWIM layer, and forget() then deletes Y's state from the view (sandboxd/mesh/mesh.go:265) until Y refutes it. If the client asks X itself, the check fails open. With proposal 1 this rule is no longer needed for integrity. If it stays, the evidence should come from a direct, nonce-bound challenge to the previous owner (GET /v1/identity?nonce= returning a signed At and the nonce), never from the claimant.

    6. Delete-then-create replaces a tenant template

    The same-signer rule compares a new version against the version currently in the store. X can delete objects (accepted as availability), and after a delete no current version exists, so X's version is accepted as the first one. Either pin the signer per template name outside the store (for example, each node remembers the signer and requires a template signer to change it), or list this under Residual risk next to the "name not used yet" item.

    Not in the work breakdown

    • Adoption without broadcasting the token. The plan replaces the token with its SHA-256 on /owner. The adopter then never holds the plaintext token, while claim auth compares plaintext (sandboxd/pool/operator.go:91), so every token check would have to move to hashes. That also gives X a new move vector: copy the hash from PostgreSQL into a move's claim record. A simpler fix is a two-phase offer. Offer with the hash, and send the token only to the one peer that accepts. Refusing peers learn nothing, auth stays as it is, and the accepting node can verify the proposal-1 MACs.
    • Config validation. checkpoint_peer_heal, move_concurrency and archive_failover require mesh.cluster_key (sandboxd/config/config.go:553, :563, :838). The design makes that key optional.
    • Probe MAC. The probe MAC derives from cluster_key (sandboxd/store/peer/probe.go:150), so it has to move to node identity.

    Suggested order

    1. Independent of identity, small, can ship now: §8 env redaction, the two-phase adoption offer, and the §3 table correction.
    2. Token-bound records (proposal 1): this closes the store-integrity gap without any PKI.
    3. Node identity, signed gossip and TLS roles as designed: with §5 reduced to the challenge form or dropped.
    4. Exit hardening (proposal 2) and tenant key credentials (proposal 3): or an explicit Residual risk entry for each.
  2. CMGS commented on Nov 7, 2026

    @CMGS
    ContributorAuthor

    All points verified against ef58ef76 and folded into the description.

    # Finding Verdict Change
    1 Owner of record comes from rows any cell node can write correct §6 now binds every checkpoint, archive and replica record to the claim token: HMAC(claim_token, record_digest) over claim id, manifest digest, sequence and previous digest, verified before boot. PostgreSQL is an untrusted pointer. The node signature stays for attribution, sweeps and revocation only.
    2 tun exit learns MACs and trusts any VTEP (advanced-network.md:237, :242) correct Under Residual risk; static FDB from signed node states listed as later work.
    3 Signed state proves origin, not truth; tenant tokens stay cluster-wide bearers correct Under Residual risk (claim attraction and tenant token exposure to directly connected SDKs); tenant key credentials listed as later work.
    4 §3 table widened the tenant token correct, my error (exec.go:47, operator.go:91) exec, relay, ports and files are no for tenant.
    5 Absence-based liveness rule correct (mesh.go:265) Removed. Integrity no longer depends on liveness; the signed heartbeat is gone from §2.
    6 Delete-then-create resets the same-signer rule correct Each node remembers the last signer per template name and needs a template signer to change it; the remaining case (a node that never saw the name) is under Residual risk.
    — Hash-only adoption breaks plaintext claim auth correct §5 uses the two-phase offer: hash to every peer, token only to the one that accepts.
    — cluster_key-dependent config and probe MAC correct (config.go:553, :563, :838; probe.go:150) Added to the work breakdown and tasks.

    Rollout follows the suggested order: env redaction, two-phase offer and the table fix first; token-bound records second; identity, signed gossip and TLS roles third; exit hardening and tenant keys later or as residual risk.

  3. CMGS commented on Nov 7, 2026

    @CMGS
    ContributorAuthor

    Follow-up review of the revised description against main@ef58ef76d5daf02dd0a409069609d7deabad2a41.

    The architecture is feasible, but the current specification does not yet contain a host compromise to one node. The findings below use this issue's threat model: an attacker already controls host root on one admitted node. The existing residual risks for shared-store confidentiality, availability, tenant bearer exposure, and tun remain separate from these findings.

    The first five items should be treated as P1 blockers for the proposed isolation guarantee. Items 1, 2, 3, and 5 are reachable paths in the current source that the design must close; item 4 is a flaw in the proposed operator authentication protocol.

    1. S3 manifest paths can write outside the receiving node's staging directory.

      Store.Fetch → populate → materialize consumes a manifest from the shared store. materialize joins file.Path to its local root and calls MkdirAll, Create, and Truncate without rejecting paths that escape that root. A compromised node with the shared store credentials can supply a traversal path and make another node create, truncate, or overwrite files outside its download directory when that node fetches the record. A later boot failure does not undo those writes.

      Evidence: Fetch, manifest decoding, materialization. The template path also calls Fetch before checking the returned template's visibility: resolveGolden.

      Required change: confine every materialized path to the staging root, reject invalid manifests, and bound file counts and sizes. Verify the authenticated record and manifest before materialization. “Verified before boot” is too late if verification follows Fetch. Path confinement remains mandatory even for signed records: a malicious admitted node can sign malformed data for a claim whose token it legitimately holds.

    2. SDK owner discovery still broadcasts the claim token.

      Go Client.Lookup probes the entry node and then scatters requests to peers. Each ownerAt request sends the plaintext claim token. Automatic rebinding after a move invokes that same lookup. Python lookup likewise probes the entry and peers with the token.

      Evidence: Go lookup and ownerAt, automatic rebind, Python lookup.

      The two-phase offer in §5 changes the server-side adopter, but does not cover these SDK paths. TLS and a signed address prove that the recipient is an admitted node; an admitted but compromised peer can still collect a token belonging to a claim on another node.

      Required change: make owner discovery secret-free and require evidence of authority over the specific claim before sending its bearer. A node's signed assertion that it is the owner is insufficient. An existing owner can prove possession of a claim-specific secret through a nonce-bound exchange; a new adopter needs an explicit authorized transfer. The hash already readable from PostgreSQL cannot serve as the secret for that proof.

    3. The cluster-wide preview_secret remains a lateral credential.

      A compromised node holds the shared preview HMAC key. For a known claim ID on another node, it can forge a preview token containing {id, port, owner, exp}. The target validates the shared HMAC, then PreviewDial accesses the guest port using Cred{Operator: true}, without a claim token.

      Evidence: preview signing and verification, preview request handling, PreviewDial. The deployment documentation explicitly defines preview_secret as cluster-shared.

      Required change: replace the shared signing authority with a capability scoped to the claim, including verification after migration. Merely accepting signatures from any admitted node preserves the same authorization flaw. The compatibility statement that previews remain unchanged conflicts with the isolation goal.

    4. An operator OIDC bearer with a common audience can be replayed across nodes.

      Under §3, every node accepts the operator ID token with aud = "sandboxd". Once the control plane sends it to a compromised node, that node can replay it to another node until expiry and exercise operator privileges, including retrieving claim tokens. The statement in §4 that a redirect to a compromised node “harvests nothing” therefore does not hold for this credential.

      Required change: use operator mTLS for the initial implementation, or define a sender-constrained token protocol or a strict recipient-specific audience scheme that prevents cross-node replay. A short expiry only limits the attack window. This follows the replay threat and mitigations described in RFC 9700 §4.10.

    5. The shared tenant table is still a writable authentication and authorization authority.

      The tenant store uses the same configured DSN as the other shared backends, and tenant authentication directly resolves sandboxd_tenants.token_sha256. A compromised node with that database role can replace a tenant's hash with one for an attacker-controlled token. An honest node then resolves that token as the victim tenant. This permits tenant-authorized operations on the victim's claims, such as modifying claim env; it does not require the SDK to send the victim's original tenant token to the compromised node.

      Evidence: backend wiring, tenant store wiring, token resolution, tenant record writes, env authorization.

      Required change: protect tenant identity, quota, and policy records with operator authorization that a node cannot forge. Options include externally signed records verified by every reader, or a separate write authority whose credentials are absent from sandbox nodes. If node database access becomes read-only for these tables, the administrative write path must change accordingly. Adding MACs to checkpoint records does not protect the tenant table.

    The record and identity protocols also need the following decisions before implementation:

    Area Gap and required decision
    MAC key availability for ordinary checkpoint cloning Checkpoint.New sends the tenant/operator credential, not the source claim token. A checkpoint handle does not retain that token, and the source claim may already have been released. See checkpoint claim and handle construction. Define a checkpoint-specific capability or another explicit means to authorize and verify this operation. The archive/adoption token flow does not establish compatibility for ordinary checkpoint cloning.
    Rollback protection A sequence number and previous-record digest authenticate a chain, but a valid old prefix still verifies. A new adopter with no trusted latest sequence/digest cannot distinguish that prefix from the current state. Retain a trusted latest-record anchor at the client or control plane, or explicitly list rollback as residual risk.
    Authenticated record coverage and verification order Define the authenticated envelope to bind the cell, record type, claim ID, tenant, manifest, env, policy, deadlines, and security-relevant lifecycle state. The current adoption path deserializes the shared row's claim and installs it locally: adoptArchived. Verification and consistency checks must precede those state changes, not just VM boot. The four fields currently named in §6 do not specify protection for the full claim state.
    Revocation by since A compromised signing key can sign new content with an old timestamp. A signer-supplied date cannot establish that a record predates the compromise. Without an independently trusted time/history proof, revoke the key and preserve approved historical content through exact-digest exceptions or operator countersignatures.
    Historical keys and admission continuity A restarted node's current /identity endpoint does not establish the provenance of every old ephemeral key. A new verifier also has no previous admission state for a running peer whose original proof has expired. Retain historical public keys and their proofs independently of the writer's lifetime, define historical verification separately from live admission, and refresh the proof for a running key when needed by new verifiers. Continuity for existing verifiers alone does not solve either case.
    Node identity namespace A bare GCE instance name is not a sufficient identity across zones, projects, and instance replacement. Use a provider-scoped identity incorporating project, zone, and instance ID; likewise namespace X.509 identities by their trust domain and enforce subject uniqueness. Google documents instance_id, project_id, and zone as the globally unique tuple to verify.

    Suggested implementation order:

    1. Fix S3 materialization confinement, SDK claim-token disclosure, and preview signing scope.
    2. Establish X.509 node identity, operator mTLS, signed gossip, strict endpoint roles, and protection for shared authentication/authorization records. Add OIDC once its replay protection is specified.
    3. Implement checkpoint capabilities, complete record authentication, the chosen rollback guarantee, historical verification materials, and revocation.
    4. Roll out the compatibility changes. Enable env redaction together with a defined secret re-injection/readiness flow for recovery. A cell must not claim the new isolation guarantee while legacy root bearers, unsigned records, plaintext credential paths, or token-broadcast fallbacks remain enabled.

    This can retain gossip and support bare metal without adding a central service. Local identity verification is feasible; ownership authority, record freshness, and revocation still require explicit trust rules.

    Validation scope: source and protocol review only. No code changes, runtime tests, or exploit reproduction were performed.

  4. CMGS commented on Nov 7, 2026

    @CMGS
    ContributorAuthor

    Decision: the cell is the blast radius

    After both reviews, the design is narrowed to keep it simple. A host compromise on one node is bounded to its cell, ops can cut a node or a cell off without that node's help, and no new protocol or central component is added. Cell size is the operator's knob.

    Kept

    • Fix the S3 materialize path confinement now (review 2, item 1), as its own PR.
    • Scope every shared credential and backend to one cell: control-port token, mesh key, preview secret, checkpoint store location and credential, cloud identity.
    • One PostgreSQL database (or schema) per cell. sandboxd_tenants has no cell column, and the other tables share one role across cells, so a shared database would not bound a cell.
    • Cutoff: backends and node-to-node ports accept only the cell's node addresses, so blocking an address cuts a node off at once; a reloadable deny_nodes list drops a node from routing and peer calls; then rotate the cell's credentials.
    • Docs: deploy.md and security.md describe these values as shared within a cell, plus a cutoff and rotation runbook.

    Dropped
    Node attestation and ephemeral keys, signed gossip, record MAC chains, template tiers, revocation timestamps and OIDC operator tokens. The findings they addressed stay inside the cell and are accepted there; per-node roles, per-node store prefixes and mTLS remain options if a cell cannot be made small enough.

    The description above is the earlier, wider design and is kept for reference; this comment is the current scope.

  5. CMGS commented on Nov 7, 2026

    @CMGS
    ContributorAuthor

    Re-review against the cell-boundary decision, with source references at main@ef58ef76.

    The revised scope is reasonable and implementable. Treating the whole cell as potentially compromised removes the need to solve the earlier per-node identity, ownership, and record-authentication problems in this issue. The remaining work is to enforce the boundary between cells and make the cutoff and recovery procedures accurate.

    Three requirements still need to be explicit:

    1. Blocking an address does not, by itself, guarantee immediate cutoff.

      GCE and AWS stateful firewalls can retain established connections after rules change. A change that blocks new connections may leave PostgreSQL sessions, HTTP keep-alive connections, relays, and transfers already in progress usable. See GCE firewall connection tracking and AWS security group connection tracking.

      The runbook needs a containment mechanism enforced outside the compromised host, with explicit handling of established connections and database sessions. Define a measurable cutoff condition rather than assuming that a rule update has ended access.

      The same condition must cover credentials copied off the host. If an object store has a public endpoint that still accepts the stolen credentials, blocking the original node's network access does not cut off the attacker. Restrict access at the service boundary and revoke or rotate credentials as required. A private route alone does not disable public endpoint access; S3 documents this distinction.

      deny_nodes is useful for stopping placement and outbound peer calls. It is not an authorization-revocation mechanism. With signed node identity removed, a compromised member can change its self-declared node ID. Network containment must also prevent source-address spoofing where the underlay permits it.

    2. Separate database/schema and store locations must have separate permissions.

      A schema name, search_path, or S3 prefix selects a namespace; it does not restrict what a stolen credential can access. PostgreSQL explicitly notes that access across schemas depends on privileges: schema documentation.

      Make the following deployment requirements explicit:

      • Each cell has a distinct PostgreSQL role, restricted to its own database or schema and objects, with no privilege to assume another cell's role.
      • Each cell's object-store credentials are restricted to its own objects by the backend's authorization policy.
      • A node's cloud identity cannot read another cell's secrets or change the IAM, network, or firewall controls that enforce the cell boundary.
      • “Every shared credential” includes tenant bearer tokens. Reusing the same tenant bearer across cells reconnects those authorization domains once one cell obtains it.

      These are requirements for the new deployment contract, not a request to restore the dropped record-signing design.

    3. The tun exit must also respect the cell boundary.

      If multiple cells share one VXLAN learning domain, a compromised node in cell A can claim cell B's inner address/MAC and attract B's return traffic. The current exit example enables VXLAN learning and arp_accept=1. The earlier accepted network risk is confined to a cell only if the exit topology confines it there.

      The simplest deployment rule is a separate exit network domain, FDB, and neighbor table per cell, with trusted ingress controls binding node sources to the permitted overlay. Different VNIs alone are insufficient: host root can construct a packet with another VNI. Bare-metal deployments also need underlay anti-spoofing outside the compromised host. The existing documentation already requires that other hosts cannot spoof exit VTEP addresses.

    The earlier findings now have these dispositions:

    Earlier finding Disposition under the cell scope
    S3 manifest path traversal Still requires the independent fix already retained in the decision. The reviewed source still joins an untrusted path and creates/truncates the destination without confinement: materialize.
    SDK claim-token broadcast, shared preview signing key, writable tenant authentication table Accepted within a cell. Cross-cell credential and backend isolation must make their consequences stop at that boundary.
    Operator OIDC replay The proposed OIDC mode was dropped, so this no longer blocks the revised scope.
    MAC-chain freshness, historical signing keys, timestamp-based signature revocation The associated protocols were dropped, so these no longer block the revised scope.

    Rotation and recovery need an implementation-aware runbook. At the reviewed commit, changing api_token, mesh.cluster_key, or preview_secret requires a restart; an ordinary config reload rejects those changes. See ReloadDiff and the reloadable field set. Document the restart sequence and resulting service interruption, or implement the rotation behavior explicitly.

    Because the accepted blast radius is the entire cell, rotating keys alone does not establish that its remaining hosts are trustworthy. Recovery must account for possible compromise of other nodes and tampering with that cell's templates and checkpoints. Do not automatically restore trust in those artifacts merely because the old node has been blocked.

    Suggested acceptance checks for this narrower scope:

    • Possession of all cell A credentials still does not permit access to cell B's control API, preview authority, database objects, object store, or cloud resources.
    • Cutoff ends already-established access as well as new access, within the documented bound, without cooperation from the compromised node.
    • Credentials copied to an external host cannot bypass the containment policy.
    • A cell A node cannot inject into or attract traffic from cell B's overlay through a shared exit.
    • The rotation/recovery exercise uses the actual restart requirements and a defined source of trusted recovery artifacts.

    I recommend keeping the simplified direction and incorporating these requirements into the current decision and implementation checklist. Replace the issue's active goal and task list with the cell-scoped version, or move the older design into a clearly separate historical section, so the implementation cannot accidentally follow the superseded per-node requirements.

    Validation scope: source and design review only. No code changes, runtime tests, or inspection of deployed IAM, database grants, or firewall policies were performed.

  6. CMGS commented on Nov 7, 2026

    @CMGS
    ContributorAuthor

    Review 3 is folded into the description, which now leads with the cell scope; the per-node design sits in a collapsed history section.

    • Cutoff: host-level drop ahead of the established-connection rule, conntrack flush, and termination of PostgreSQL sessions, with a measurable done condition. Backends refuse the cell's credentials from outside the cell. Per-node PG roles and store keys are recommended so one node can be cut off without restarting the rest. deny_nodes is dropped: node ids are self-declared, and once the address is dropped SWIM removes the node from routing anyway.
    • Separate permissions: per-cell database and role, store credential scoped by backend policy, and cloud identity without access to other cells or to boundary controls; tenant tokens are not reused across cells. Also added: an egress root per cell, since guests trust the root from node config.
    • tun exit: one exit per cell with its own netns, FDB, neighbor table and underlay allowlist.
    • Rotation: confirmed restart-only in config/reload.go; the runbook restarts the whole cell at once. Rotation without downtime is listed as an undecided option.
    • Recovery and acceptance checks: added as suggested, plus a cell-isolation check for the egress intermediate.

    No further sandbox code is required for this scope; the remaining tasks are docs and the runbook.

  7. CMGS commented on Nov 7, 2026

    @CMGS
    ContributorAuthor

    Host-only claim env at rest, split into issues (reviewed against a2a0f366): values persist only in claims.json and the archive/replica rows; never in the checkpoint store, the guest, the journals or any API read.

    Control-plane counterparts: loomhand/cocoonbox#223 (re-push on env_missing, must ship before #493/#494), #224, #225, #226.

  8. CMGS commented on Nov 7, 2026

    @CMGS
    ContributorAuthor

    Done for the cell-scoped plan:

    Rollout order for #499: release with the flag off, deploy the control-plane re-push (loomhand/cocoonbox#228), then turn the flag on per node with a restart. The superseded per-node design stays in the collapsed section for history only.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions