Skip to content

Security review remediation: proof of possession, identity bans, type-bound dispatch, gossip validation, control-plane transport trust, secrets hygiene, mobile tokens - #415

Merged
aojea merged 14 commits into
google:mainfrom
aojea:audit-fixes
Sep 17, 2026

Conversation

@aojea

@aojea aojea commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Remediation of the 2026-09-16 security review, stacked as one commit per area so each can be read on its own. Everything below is alpha-breaking where noted.

What changes

Enrollment proves key possession (fe9a110). POST /register, POST /enroll and POST /routers/lease now carry a timestamp and a signature over an endpoint-specific challenge made with the key being enrolled. Previously anyone could register an arbitrary peer id, and any enrolled node could rewrite a router's addresses with the router's own (widely shared) biscuit. Router leases are verified against the router's stored key and require the router role.

Pre-authentication work is bounded (0f2ceba). Inbound biscuits with appended blocks are refused before any Datalog runs; the authorizer has fact and iteration caps; auth-handshake streams get a per-peer rate limit and a deadline on both node and router.

Control plane: identity bans and token spending (cbd1e49). A banned OIDC identity can no longer mint bootstrap tokens or re-enroll a new device via /user/*. Bootstrap-token usage is consumed atomically (UPDATE … WHERE usages < max), the node record is written before an enrollment request is resolved, approve re-checks the token, and there is an unban path. IdP roles claims become idp_role facts. User tokens are clamped to 7 days / 10 uses; /user/status no longer returns the mesh policy to every user; the CLI ban path canonicalises peer ids and errors on zero rows.

Node dispatch is bound to the service type (3bccddc). A stream authorized for mcp://foo cannot reach inference://foo; the registry refuses one name under two types; ingress and egress reject .. segments. Command-backed MCP services get a per-request id space (two callers with the same JSON-RPC id no longer receive each other's replies) and the SSE broadcast side is removed. The sidecar strips its own token from Authorization before forwarding (only when it equals the sidecar token; upstream credentials pass through). The reverse proxy addresses the backend by its configured host and drops inbound X-Forwarded-*. Remote get_mesh_info returns only peer id and DHT size.

Gossip, relay and callee verification (b0c5c3c). A GossipSub validator on the control-plane events topic rejects unsigned or forged events at the first hop, so a junk flood via the router can no longer exhaust the rate budget that real ban and rotation events share; the limit is keyed on the author. The relay ACL requires the source to be authenticated too. Every provider a node talks to must present a control-plane-signed biscuit bound to its peer id and carrying role(node) — on MCP streams, the OpenAI facade and the raw egress proxy — not only when labels or an egress floor apply. The discovery table caps entries per signer. The mesh_pubsub_broadcast / poll_messages / subscribe_topic MCP tools are removed; A2A is the agent-to-agent channel.

Control-plane transport trust (4d92e21). Whoever answers the control-plane URL is the trust root, so a plaintext http:// URL to a non-loopback host is refused by sam-node and sam-router unless --insecure-control-plane is passed (charts, k8s templates, e2e and docs opt in for the in-cluster Service URL). GET /keys is signed by every valid key and accepted only when a key the receiver already trusts signed it. KEY_ROTATION events are signed by the retiring key — they were signed by the new key, which no node trusted yet, so rotation announcements were being dropped. A refreshed biscuit is verified (trusted signer, own peer id, role) before it replaces the identity, and a 403 on /refresh no longer os.Exits the process.

Secrets out of the tree and out of env (0244e42). The docs-site demo recording carried a real daemon token (redacted; hack/verify-secrets.sh now fails CI on bearer tokens and PEM keys in tracked files). The mock OIDC issuer's RSA private key was committed three times; all three generate it at start-up. The sam-node chart's apiToken: devtoken env var becomes a generated Secret mounted as a file; the router bootstrap token is one use per replica instead of 999999; the sam-node Deployment and the internal postgres get security contexts and automountServiceAccountToken: false. The agent skill no longer tells the model to read MCP client config files to recover the node token. The dead TLS-MITM gateway in internal/sambox is deleted.

Mobile (3b6dc32). The app's sidecar token was the fixed string secret-token, and Android loopback is shared by every installed app; it is now generated per device and shown on the Config tab. The phone-sensors MCP backend was unauthenticated on 127.0.0.1:9090; it now binds a random port with a per-launch bearer token. Location is coarse-only (network provider, ~1 km) to match what the switch promises; ACCESS_FINE_LOCATION is dropped; allowBackup=false. Supporting this, a service may name a credential file in target_auth_path; a credential written into target_url in sam-node.yaml is refused.

Hygiene (024de4c). Admin token masked in the deploy workflow and minted over a port-forward; SQLite files 0600; secrets read from files unset their env var so subprocesses don't inherit them; response bodies from the control plane are size-capped; log lines are bounded; .dockerignore; release workflow limited to v* tags without packages: write; dependabot covers the other ecosystems.

Breaking

  • Clients must send the challenge fields on /register, /enroll, /routers/lease.
  • /keys responses are signed; unsigned ones are refused. Nodes/routers older than this cannot follow a rotation.
  • Plaintext http:// to a non-loopback control plane needs --insecure-control-plane.
  • Providers must carry role(node).
  • The three custom pubsub MCP tools are gone.
  • sam-node chart: apiToken defaults to a generated Secret; --api-token-path replaces the env var.

Deferred (reasons in the commit messages)

Gossip/DHT peer admission filters (would break discovery for peers that have not handshaken), the /debug/connect-peer dial oracle, a per-route control-plane rate limiter, cosign signing of releases (needs a new Action), a release-signing keystore for the APK, and the hub canaries' SAM_API_TOKEN env vars and floating images.

Operator action

Rotate the daemon token on the machine that recorded site/static/demo.cast (rm ~/.config/sam-mesh/api-token && sam-node run --daemonize).

Verification

make build; golangci-lint (root and cmd/nano-init) 0 issues; deadcode; hack/verify-generated.sh; hack/verify-secrets.sh; helm unittest 46/46; flutter analyze; full go test -race ./... including tests/integration. Every security branch has a negative test; the gossip-flood and provider-role tests were checked to fail with the fix reverted. Four independent review passes over the final diff; their findings (provider role, explicit StrictSign, loopback edge cases) are folded in.

Low-risk, decision-free fixes from the 2026-09-16 security audit
(audit.md): secrets that leaked sideways and inputs that were unbounded.

Secrets:
- H4: mask the hub admin token in Actions logs and mint the router
  bootstrap token over a port-forward from the runner instead of a
  kubectl-run pod whose spec (and `describe` on failure) carried it.
  The bootstrap token is masked too.
- M4: create the SQLite file 0600 before the driver opens it, so the
  WAL/SHM side files inherit that mode, and tighten pre-existing files.
  The keyring table holds the mesh signing private keys.
- M5: secrets.FromPathOrEnv unsets the env var once read, and command
  backends get an environment with SAM_API_TOKEN/SAM_CLIENT_SECRET
  stripped (backendEnv), so subprocesses cannot inherit the node's own
  credentials.
- M15: OPENROUTER_API_KEY moves from a plain Deployment env value to a
  Secret with secretKeyRef.
- L25: the sam-mesh chart mounts the router bootstrap token as a file
  and uses --bootstrap-token-path; nothing secret in argv or env.
- I12: Cache-Control: no-store on every route that returns biscuits,
  bootstrap tokens or enrolled-node records.
- I14: POST /admin/bootstrap-tokens requires an explicit role; the
  silent default was router, the most privileged one.
- L16: verifiers call GetAllValidPublicKeys; the private halves no
  longer flow through five handlers that only verify.

Bounds:
- M19: StdioBridge reads lines up to the 1 MiB request cap (was
  bufio.Scanner's 64 KiB, after which the reader stopped forever and
  every later caller hung). When the reader does stop, the bridge kills
  the child and answers 503 instead of hanging.
- M20: remote-controlled strings (request path, catalog text, target)
  are truncated before logging, the ingress path is no longer logged
  before authorization, and the debug log ring caps each retained line
  at 4 KiB so its memory is bounded.
- I16: control-plane and IdP response bodies are read through a 1 MiB
  LimitReader everywhere (enroll, enroll/status, refresh, token).
- I17: Options.Validate rejects a control-plane public key that is not
  32 bytes, before it can reach ed25519 verification.
- L17: the sam-one --policy-file seed runs the same validation as
  POST /policies (ValidatePolicyConfig).

CI / supply chain:
- M14/I19: the OpenClaw gateway token is checked for emptiness (base64
  -d on empty input exits 0) and masked; every envsubst names its
  placeholders so runner env and container-runtime $VARs are never
  inlined into cluster objects.
- L28: chart-test.yml checkout no longer persists GITHUB_TOKEN;
  helm-unittest is installed at a pinned version.
- L21: release.yml fires on v* tags only and drops the unused
  packages: write scope.
- L20: .dockerignore keeps .git, local DBs, keys, tokens and build
  output out of every image build context; dependabot now covers the
  nano-init and sam-a2a-bridge Go modules, npm, pip, pub and gradle.
- L2: vendored js-yaml 4.1.0 -> 4.1.1 (CVE-2025-64718, merge-key
  prototype pollution), taken from the npm tarball verified against the
  registry shasum.

Tests: SQLite file mode incl. WAL/SHM and DSN parsing; env var removed
after FromPathOrEnv; backendEnv strips only exact secret names; bridge
delivers a >64 KiB reply and refuses with 503 after backend exit; ring
buffer line cap and truncateForLog; Options key-size check; admin token
role required + no-store header; chart unit test for the mounted token.
…dit H1, H6)

Two control-plane routes trusted a peer_id from the request body. libp2p
proves key possession on every peer-to-peer stream, but the control plane
is plain HTTP and holds no libp2p host, so on this surface a peer_id is a
claim and a biscuit is a bearer token. /enroll, /enroll/status and
/refresh already carry a signed sam:<endpoint>:<peer_id>:<unix-ms>
challenge for exactly this reason; these two were the ones that never got
it.

H1 - POST /register. Any OIDC identity with a node binding could submit a
victim's peer_id with any public key. The upsert overwrote the victim's
record, NULLed owner_id and reset autonomous_recovery, and the victim's
next /refresh failed its key challenge. EnrollRequest gains
timestamp/challenge_signature over api.RegisterChallenge; the handler
requires peer_id to be derived from public_key and the signature to
verify with it, before any ban or policy check. Since peer_id is now
always the key's own, a record can only ever be overwritten by its owner.

H6 - POST /routers/lease. The handler verified the router's biscuit and
its role() fact, nothing else. Routers send that biscuit to every peer
they authenticate (AuthResponse.Biscuit), so any enrolled node held a
copy and could rewrite the router's lease for every joining peer: empty
or attacker addresses, false connected_peers/dht_size. RouterLeaseRequest
gains timestamp/challenge_signature over api.RouterLeaseChallenge,
verified against the key stored at enrollment (routerRecord.PublicKey),
so nothing in the request is something to sign with. The handler also
requires routerRecord.Role == router, the control plane's own record,
next to the biscuit's fact.

Clients: node enrollHTTP takes the private key and signs; router enroll()
and renewLease() sign with r.privKey. Both fields are hard-required with
no grace period (agreed: alpha, and /refresh keeps existing nodes
working; only pre-upgrade binaries hitting /register or renewing leases
are refused, with a clear 401).

Tests, per .gemini/styleguide.md §3.6 (negative inputs) and §3.3 (fail on
parent): TestRegisterRequiresProofOfPossession drives the H1 hijack -
victim peer_id with attacker key (400), with victim key and no challenge
(401), with a challenge signed by the attacker (401), a challenge for
another endpoint (401), a stale one (401) - and checks the victim's
public key and autonomous_recovery survive and the attacker left no
record. TestNodeAndRouterRegistrationFlow gains the H6 poisoning cases -
captured router biscuit with no challenge, with the node's signature,
with a stale router signature - and checks /info still advertises the
router's real addresses. With either guard removed the tests reproduce
the audit's outcome (200s, addresses wiped to [], recovery flag reset).
Existing tests and the two integration helpers sign the challenge; the
alias test signs over the canonical id, as clients do, so the
canonicalization it pins still holds.

Docs: control-plane-configuration.md lists the five challenges and why
they exist next to libp2p's transport-level proof.
Two findings from the 2026-09-16 audit (audit.md) about what an
unauthenticated or merely-enrolled peer can make a verifier do.

M23 - Datalog CPU pinning through appended blocks. Appending a block to a
biscuit needs no root key, so it is the one place a token holder can put
Datalog of their own. biscuit-go runs each rule to completion before it
checks its deadline, checks the fact cap only after a whole iteration, and
on timeout returns while the worker goroutine keeps computing and then
blocks forever on an unbuffered channel. A block with ~200 facts and a
3-way self-join therefore pinned a core for the full budget (1 s on nodes
and routers, 10 s on the control plane) and leaked two goroutines on every
verify path, with the token still verifying. Reproduced here before the
fix: took=budget, goroutines 3->5->7->... per call.

SAM never mints appended blocks and reads nothing from them (their facts
are invisible to the authorizer; RequireAuthorityBinding already ignores
them), so the fix is to not evaluate any: identity.UnmarshalInbound
rejects a token with BlockCount() > 0 (ErrAppendedBlocks) before an
authorizer is built. Every inbound path goes through it: verifyBiscuit
(VerifyBiscuit / AndGetKey / AndGetExpiry), extractPeerID (refresh,
policies, catalog), VerifyBiscuitRole, and the node's Authorize. The
world limits WithMaxFacts/WithMaxIterations are now set explicitly and
the comment that claimed they bound the work is corrected.
TestPeerBindingRejectsAppendedBlock previously asserted that an
attenuated token stays usable for its real owner; it now asserts
ErrAppendedBlocks, with RequireAuthorityBinding kept as defence in depth.

M6 - Unauthenticated handshakes had no deadline or rate limit. Any
internet peer could open /sam/auth streams on a node or router and hold
each one (and its goroutine) open indefinitely, or loop handshakes to
make the verifier evaluate every frame. Both handlers now refuse a peer
over a per-peer budget (5/s, burst 10, LRU-tracked) and set a stream
deadline (10 s) before the frame read; the /sam/mcp auth frame gets the
same read deadline, lifted once the peer is authorized since that
session is long-lived. The limiter moves from internal/node to
internal/ratelimit so the router can use it without importing the node
(the two components stay independent). Also deleted: cmd/sam-node
re-registered HandleAuthHandshake after Start, replacing the
panic-recovering wrapper Start had installed, so a panic on untrusted
handshake bytes would have taken the process down.

Tests: TestInboundVerifyRejectsAppendedBlocksWithoutEvaluatingThem
builds the join bomb and checks all six verify paths reject it in well
under the budget with no goroutine growth (fails as described above with
the guard removed). TestHandleAuthHandshakeBoundsUnauthenticatedPeers in
node and router: an idle stream is closed on the server's schedule, and
30 back-to-back valid handshakes are not all answered.
The control plane's identity lifecycle had gaps between what a ban, a
bootstrap token and an issuer claim were meant to say and what the code
enforced. Findings M17, M3+L35, M1, M2, M9, L36, L12, L9, L10, L11, L15,
I13 from audit.md.

M17 - Identity ban not enforced on /user/*. /admin/revoke bans the node
key and the OIDC identity behind it, but authenticateUser never checked
the identity ban, so a revoked user's still-valid ID token minted a
bootstrap token and re-enrolled a fresh device (auto-approve: back on the
mesh immediately, with the banned identity as OwnerID). Now:
authenticateUser refuses a banned issuer|subject (403 via requireUser on
every /user/* and admin route); /enroll and approve refuse a token whose
owner is banned. Users gain an Issuer column (migration 11) so the owner
of a token can be matched against the issuer|subject ban key; rows from
before the migration adopt the issuer on next login. The inverse exists
too: POST /admin/nodes/{peer}/unban lifts node and identity together
(L36), and the CLI ban/unban canonicalizes --peer, fails on an unknown
node instead of printing "Successfully banned" for zero rows, and covers
the identity half (L12), all through one controlplane.SetNodeBan.

M3 + L35 - Token usage was read-then-increment across two round trips;
approve never re-checked the token. ConsumeBootstrapTokenUsage replaces
IncrementBootstrapTokenUsage: one UPDATE whose WHERE clause carries the
usage cap, revocation and expiry, spent before anything is minted or
written, on auto-approve, approve and re-mint. Approve re-validates the
token (410 if revoked/expired/exhausted since queued, 403 if the owner is
banned), writes the node record before the request flips to APPROVED,
and resolves through ResolveEnrollmentRequest, which only touches a
PENDING row: a second approve or a reject after approve is 409, not a
second biscuit or an approved request with no node behind it.

M1 - The issuer's "roles" claim was minted as role(), the same predicate
mesh policy bindings grant and RequireRole/relay rights key on: an IdP
emitting roles: ["sam:role:router"] made a router. The claim now maps to
idp_role(); bindings and allowed_targets use idp_role:<name>, and role:
is no longer a valid member prefix (BindingMemberPrefixes drives the
control plane's validation and the node's rule compiler, which also
closes L15: agent: and role: members no longer compile into role rules).

M2 - A mesh with no policy roles minted every non-router an unrestricted
token (granted_service_all_types + target_unrestricted). Removed: no
policy, no grants, as the docs already claimed for enrollment.

M9 - An email the issuer marks email_verified: false no longer resolves
bindings, is not minted and is not stored; absent email_verified is kept
(several issuers never emit it). A subject already registered under
another issuer is refused rather than sharing the account.

Smaller: non-admin bootstrap tokens capped at 7 days / 10 usages (L9);
/user/status no longer returns live biscuits, public keys or claims, and
shows policy and routers to admins only (L11); /readyz and user-auth
failures no longer echo internal errors (L10); GET /policies tries the
node biscuit before OIDC so a biscuit no longer produces a verification
error log or a users row (I13); SetNodeBanned reports ErrNotFound.

Tests (.gemini/styleguide.md §3.3, §3.6): identity_lifecycle_test.go
drives each finding from the attacker's side - banned identity on
/user/status, /user/bootstrap-tokens, /user/revoke (403), its pre-ban
token at /enroll (REJECTED) and a queued approval (403), then unban
restores both halves; 12 concurrent enrollments on a 1-use token approve
exactly 1; approve after revoke (410), after the cap (410), second
approve and reject-after-approve (409) with a node record behind the
approved request; user token ceilings; /user/status shape for user and
admin; verified vs unverified email resolving an email: binding;
cross-issuer subject collision (401); empty policy mints role only.
Storage: atomic consume under 20 goroutines, expired/revoked/unknown,
pending-only resolve. resolveRoles pins that a role: member never
resolves from the claim; TestPolicyPermutations gains an unbound-claim
case and drops the roles: [sam:role:node] hack that relied on M1. With
the identity-ban check removed, the M17 test reproduces the audit (201
with a fresh token for the banned identity).

Stacked on audit-pr1-proof-of-possession: the /register tests need its
challenge helpers.
…(audit PR 4)

Findings H2, M18, L30, L32, L33, I15 from audit.md, all on the node's
ingress and egress data path.

H2 - Dispatch ignored the type the policy was evaluated on. The registry
was keyed on name alone and both dispatch paths looked a target up by
name, so a peer granted a2a://reports reached mcp://reports when two
services shared a name (which Register silently allowed), and the stream
path had a fallback that looked the raw target string up as a name.
Now: Register refuses a same-named service of another type and validates
the type://name URI (so a name carrying its own scheme, "plugin://x", is
refused rather than dispatched under a type nobody evaluated); dispatch
uses GetTyped(type, name) on both /libp2p-http and /sam/mcp, and the
stream path additionally requires the granted type to be MCP, the only
thing it can carry. The name-only fallback is gone.

M18 - Command-backed MCP services on HTTP ingress share one stdio
process, and the bridge in front of it routed replies by the caller's
JSON-RPC id and broadcast every stdout line to every SSE (GET) reader:
peer B holding GET received peer A's tool output; A and B posting the
same id got each other's replies. The bridge now owns the id space: each
request's id is replaced with a bridge-assigned one before it reaches the
backend and restored on the way out, a reply is delivered only to the
request it answers, unowned lines (notifications) are delivered to nobody,
and GET is 405. Residual, documented on the type: the backend process is
still one per service, so backend-side session state is shared across
authorized callers; the mesh-stream path already gives each session its
own process.

L32 - "..": both proxies pass the path after /{type}/{name} to the
backend verbatim, so a caller granted service a could send
/mcp/a/../b/tools to a backend that resolves dot segments. Ingress and
egress refuse any "." or ".." segment.

L33 - Host / X-Forwarded-For: the URL-backend reverse proxy set the
upstream Host to whatever the remote peer sent; it is now the backend's
configured host (the original stays in X-Forwarded-Host). The inference
Director proxy does not manage X-Forwarded-*, and RemoteAddr there is a
peer id, so an inbound X-Forwarded-For reached the backend as-is; the
transport drops the X-Forwarded-* trio.

L30 - An OpenAI SDK configured with api_key=<sidecar token> plus
X-Sam-Authentication as a default header sends the token twice; the gate
stripped only the header it consumed and forwarded the other copy to the
remote inference provider. withAuth now also drops an Authorization header
whose bearer value is the sidecar token, on the TCP and socket paths.

I15 - get_mesh_info over the remote catalog stream disclosed the local
Unix socket path, the connected-peer list and the router id; the remote
variant returns peer id and DHT size. The sidecar's local tool is unchanged.

Tests: registry refuses a second type under a name and GetTyped refuses
the other type; bridge tests answer with the bridge id, GET is 405, and
two callers sharing id 1 each get their own reply while a notification
reaches neither; hasDotSegment table; withAuth strips a duplicated token
but keeps a distinct provider credential; reverse proxy asserts the
backend Host; gate tests target mcp://name as clients do; the mesh-wide
fan-out test uses valid names and pins that scheme-carrying names are
refused. Integration: the stdio datapath compares the echo as JSON.

Deferred from this group: L13 (gating /debug/ to socket or mTLS) - twelve
integration tests reach /debug/connect-peer over TCP as plumbing and need
to move to sockets first.
…t PR 5)

M22 gossip flood: register a GossipSub topic validator for the control-plane
events topic on both node and router. Unsigned, forged or undecodable events
are rejected (dropped and not re-forwarded) at the first hop; stale events are
ignored. The node's per-peer rate limit moves after validation and is keyed
on the author (msg.GetFrom) instead of the forwarding peer, so a junk flood
through the router can no longer exhaust the router's budget and drop real
BANNED / KEY_ROTATION events.

M7 relay ACL: AllowConnect on both the router and the node relay now requires
the *source* to be authenticated as well as the destination. Previously any
host that could reach the router port could open circuits to every admitted
peer.

H3-a callee verification: a provider must always present a control-plane
signed biscuit bound to its peer ID and carrying role(node), not only when
the caller requires labels or the operator set an egress floor. A router's
or admin's biscuit is a valid identity but not a service provider. Applied on the three consumer paths:
ConnectMCPSession, the OpenAI facade (nil verifier now fails closed with 503)
and the raw /sam/<peer>/... egress proxy. Discovery names candidates; only
this says the peer is enrolled.

L34 discovery table: cap entries per signer (16) so one peer announcing many
service names cannot evict everyone else's entries; the signer's own oldest
entry is displaced first.

Remove the mesh_pubsub_broadcast / poll_messages / subscribe_topic MCP tools
(M8, L31). A2A is the agent-to-agent channel; the raw gossip tools had an
unbounded per-topic buffer, no topic cap, and could not be held to an egress
floor. Their integration test and the playground snippet that only exercised
them go with them.

Tests: TestValidateMeshEvent (node, router), TestGossipJunkFloodIsRejected-
NotForwarded (verified failing with the validator neutralised: 50 junk
messages forwarded and the signed ban dropped), TestRelayACLAllowConnect-
RequiresAuthenticatedSource, TestNodeRelayACL_AllowConnect updated,
TestProviderTableCapsEntriesPerSigner, TestVerifyPeerLabelsDoesNotShortCircuit,
"router biscuit is not a provider",
facade "no requirement still verifies the provider"; TestFailoverUpdatesRelay
now authenticates its client to the router before dialling the circuit and
asserts an anonymous host is refused. Test fixtures give provider nodes real
identities via enrollUnderRoot.

GossipSub is created with StrictSign pinned explicitly (the library
default): the validator and the per-author rate limit key on msg.GetFrom(),
which only the envelope signature makes trustworthy.

Deferred with reasons: H3-b/c (pubsub WithPeerFilter / DHT peer filters) —
non-handshaked DHT peers legitimately gossip discovery announcements, so a
filter there breaks discovery; revisit with a handshake-before-gossip design.
Whoever answers the control-plane URL is the trust root: /keys, enrollment
and router addresses all come from it. Four changes close the paths by which
an on-path party could become that root or kill the process.

Plaintext transport: a plain http:// control-plane URL to a non-loopback host
is refused by both sam-node and sam-router unless --insecure-control-plane is
passed. The check runs in the HTTP transport used for every control-plane
request (redirects included), so a stored or FFI-supplied URL is held to the
same policy as a flag; sam-node also fails fast on a flag-supplied URL.
Loopback stays allowed (sam-one, local development). Charts, the k8s
templates, the e2e harness and the docs opt in explicitly for the in-cluster
Service URL; the sam-node chart does so only when controlPlaneUrl is http://.

Signed /keys: KeysResponse gains timestamp and one signature per listed key,
by that key, over the deterministic encoding (api.SignKeysResponse /
VerifyKeysResponse). Node and router accept the set only when a listed key
they already trust signed it and the timestamp is within five minutes, so the
first key always comes from enrollment and a rotation is learned through the
retiring key. A node with no stored keys skips the sync.

KEY_ROTATION signed by the retiring key: the control plane signed the event
with the new key, which no node trusted yet, so every node dropped it as a
spoofing attempt. It is now signed by the key just retired into its grace
period, the one receivers can verify.

Refresh hardening: the refreshed biscuit is verified like the enrolled one
(trusted signer, bound to this peer, configured role) before it replaces the
identity, on node and router. A 403 from /refresh is an error, not os.Exit:
only a verified MeshEvent_BANNED is the control plane's word, and the node or
router keeps serving on its current biscuit until it expires.

Tests: api TestValidateControlPlaneTransport and TestKeysResponseSignatureChain
(retiring-key chain, stranger set, listed-but-not-signing trusted key,
tampering, replay, unsigned legacy); node TestSyncMeshConfigRefusesUntrustedKeySet,
TestControlPlaneClientRefusesPlaintextToNonLoopback, TestRefreshEnrollment
ForbiddenDoesNotExit, TestRefreshEnrollmentRejectsUntrustworthyToken; router
TestOptionsValidateControlPlaneTransport, TestControlPlaneClientRefusesPlaintextHop,
TestSyncKeysRequiresTrustedSignature, TestRouterRefreshEnrollmentHardening;
controlplane TestKeyRotationEventIsSignedByTheRetiringKey and /keys
self-verification; chart tests for the opt-in flag.
L37 demo.cast: the asciinema recording on the docs site carried a real
daemon API token (46 of 64 hex chars) in some sixty frames. Redacted in place
with a same-width placeholder so the frames stay aligned. hack/verify-secrets.sh
(run by `make verify` in CI) now fails on `Bearer [0-9a-f]{32,}` and on any
PEM private-key block in tracked files; both rules fire against the previous
HEAD. Operator action still needed: rotate the token on the machine that
recorded the cast (delete ~/.config/sam-mesh/api-token and re-run
`sam-node run --daemonize`).

L38 mock OIDC signing key: the RSA private key of the mock issuer was
committed three times (docs manifest, e2e image, inline in policy.bats) and
the docs recommended that issuer for local testing. All three now generate
the key at start-up and derive the JWKS from it; the guide warns that a
control plane trusting the mock admits anyone who can reach it.

L19 chart secrets: the sam-node chart put `apiToken: devtoken` in the pod's
env; it is now a generated Secret (<release>-api-token, stable across
upgrades, pin with --set) mounted as a file and read via --api-token-path,
matching the sam-mesh chart's admin-token pattern. The router bootstrap
token minted by the sam-mesh bootstrap job had max_usages 999999; it is now
one use per router replica.

L22 / I22 security contexts: the sam-node Deployment and the internal
postgres StatefulSet had none. Both get runAsNonRoot with the numeric uid
(65532 distroless, 70 postgres with fsGroup for the PVC), seccomp
RuntimeDefault, allowPrivilegeEscalation false, all capabilities dropped,
and automountServiceAccountToken false (the node uses the projected,
audienced token; postgres needs none).

I25 skill: the sam-mesh skill and the node's MCP instructions told the agent
to read ~/.gemini/config/mcp_config.json or ~/.claude.json to recover the
node token, which puts every other MCP server's headers into the model
context; L37 is what that produces. They now point at the daemon token file
with a curl form that reads it without putting the value in argv, and say to
ask the user otherwise.

I5: delete internal/sambox/gateway.go, the dead TLS-MITM forward proxy (no
non-test caller).

Chart tests cover the mounted Secret, the security contexts, and the router
token cap; the verify-secrets rules were checked against HEAD before the fix.
…se location (audit PR 8)

H5: the mobile app's sidecar token was the fixed string "secret-token", and
Android loopback is shared by every installed app, so any of them could drive
the phone's mesh identity. The token is now generated on first launch
(32 random bytes, base64url) and kept in the app-private data dir next to
the node key and biscuit it protects; the Config tab shows it (hidden by
default) with copy and regenerate. There is no default value anywhere.

M10: the phone-sensors MCP backend listened on 127.0.0.1:9090 with no
authentication. It now binds a random loopback port, mints a token per start
and refuses any request without it (constant-time compare). The node reaches
it through a general backend-credential mechanism added here. A service may
name a file in target_auth_path ("TOKEN" is sent as "Authorization: Bearer
TOKEN", "user:pass" as Basic); the node reads it once at start and composes
the credential into the in-memory target as URL userinfo. A credential
written into target_url inside sam-node.yaml is refused at load: that file
is copied, committed and rendered into ConfigMaps. Userinfo is still honoured
on an in-memory config, which is how the mobile app hands over its per-launch
token without writing it. On every backend path (reverse proxy, MCP client
transport, inference proxy and engine, A2A card probe) the node presents the
credential, overrides any caller-supplied Authorization there, strips it from
the URL it dials, and never echoes a URL in an error (url.Error would print
the userinfo).

L39: the app requested ACCESS_FINE_LOCATION and returned metre-level GPS
coordinates under a switch labelled "coarse". It now requests only coarse
permission, reads the network provider, rounds to two decimals (about a
kilometre) and says so in the tool description, the switch and the README.
ACCESS_FINE_LOCATION is dropped from the manifest.

M12 (data half): android:allowBackup="false", so the node key, biscuit and
API token are excluded from cloud and adb backups and a restore cannot clone
the identity onto another device. The release-signing half still needs a
keystore in CI secrets and is left for the operator.

Tests: TestParseBackendTarget, TestReverseProxySendsBackendCredential,
TestBackendCredentialComesFromAFileNotTheConfig,
TestBackendCredentialRefusedInConfigFileOnly (node);
Dart: random loopback port with token as userinfo, request without or with a
wrong token refused (401), request with the launch token served, new token
per start, constant-time comparison. flutter analyze clean.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces extensive security hardening across the Sovereign Agent Mesh (SAM) codebase. Key changes include implementing proof-of-possession challenges for registration and router leasing, enforcing secure control plane transport validation, restricting database file permissions, and preventing directory traversal and credential leakage in proxies. Additionally, GossipSub mesh events are now validated at the first hop to prevent DoS attacks, and the StdioBridge has been refactored to prevent cross-wire reply leakage under concurrency. Feedback on these changes suggests further optimizing StdioBridge by separating the state and write mutexes to avoid blocking concurrent requests, and refactoring the global allowInsecureControlPlane variable into an encapsulated client struct to improve testability.

Comment thread internal/node/stdio_bridge.go
Comment thread internal/node/stdio_bridge.go Outdated
Comment on lines +180 to +187
b.mu.Lock()
if b.closed {
b.mu.Unlock()
http.Error(w, "Backend process is no longer running", http.StatusServiceUnavailable)
return
}
_, err = b.stdin.Write(append(toBackend, '\n'))
b.mu.Unlock()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

5. Review output (Performance)

Writing to b.stdin is a blocking I/O operation. Holding the main state mutex b.mu during b.stdin.Write can cause head-of-line blocking for all concurrent requests and prevent deliver from routing completed responses if the backend is slow or hung.

Recommendation:
Release b.mu before writing to b.stdin. Use the newly added b.writeMu to serialize writes to b.stdin safely.

b.mu.Lock()
isClosed := b.closed
b.mu.Unlock()
if isClosed {
    http.Error(w, "Backend process is no longer running", http.StatusServiceUnavailable)
    return
}

b.writeMu.Lock()
_, err = b.stdin.Write(append(toBackend, '\n'))
b.writeMu.Unlock()

Comment thread internal/node/controlplane_client.go
The sam-mesh internal postgres was moved to runAsUser 70 with the PVC
mounted at /var/lib/postgresql/data. initdb chmods its data directory and
the PVC root belongs to root, so it failed with "could not change
permissions of directory", the db crash-looped, the control plane never
answered /info and the bootstrap post-install hook hit its deadline — the
kind-mesh and bats e2e jobs both failed on `helm install`.

PGDATA now points at the pgdata/ subdirectory, which postgres creates and
owns (fsGroup 70 gives it group access to the PVC root). Same shape as the
.github/k8s control-plane template. Reproduced with docker and on a kind
cluster: without PGDATA the pod crash-loops, with it the database is ready
in ~10s as uid 70 with pgdata/ at 0700.

Upgrade note in values.yaml: a release whose postgres ran as root with data
at the PVC root must move it into pgdata/ first.
Review finding on google#415. The bridge held its state lock across the write to
the backend's stdin. A subprocess reads stdin and writes stdout on one
thread: while it is blocked writing a large reply it is not reading, so a
caller's write blocks once the pipe is full — holding the lock that
deliver() needs to drain stdout. Neither side could proceed.

Writes now serialize on writeMu; mu is never held across I/O. The closed
check moves out from under the write; if the backend exits in between, the
reader's shutdown has already closed the caller's reply channel and the
request answers 503 as before.

TestStdioBridge_BlockedStdinWriteDoesNotStallDelivery: caller B blocked in
a stdin write nobody reads, caller A's reply must still be delivered.
Fails in 2s on the previous code (and without the signalling writer it
would hang, which is the bug). Test fixture's stdin buffer is now
mutex-guarded since writes no longer happen under b.mu.
@aojea

aojea commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-ups pushed:

  • CI (kind-mesh e2e, bats e2e)843a4b8: the internal postgres moved to runAsUser: 70 in this PR, but with the PVC mounted at /var/lib/postgresql/data initdb could not chmod the root-owned mount root and crash-looped, so the control plane never answered /info and the bootstrap hook timed out. PGDATA now points at the pgdata/ subdirectory postgres creates and owns (same shape as .github/k8s/sam-control-plane-template.yaml). Reproduced in docker and on kind: pod crash-loops without it, Ready in ~10s as uid 70 with it. Upgrade note added to values.yaml.
  • StdioBridge lock split (review comment) — b191901: legitimate and worse than a stall — with a subprocess it is a deadlock (backend blocked writing stdout → not reading stdin → caller blocked in the stdin write holding the lock deliver needs to drain stdout). Writes now serialize on their own mutex; mu is never held across I/O. New TestStdioBridge_BlockedStdinWriteDoesNotStallDelivery fails on the previous code.
  • Package-level allowInsecureControlPlane (review comment) — acknowledged, not changed here. The policy is deliberately process-wide: the control-plane URL enters from a flag, the stored config, and the mobile FFI, and every request has to be held to the same rule regardless of entry point; internal/node tests don't use t.Parallel. Encapsulating the ~8 package-level control-plane calls into a client struct is a reasonable refactor for a follow-up.

run-local-node.sh derives the control-plane URL at runtime from the Gateway's
LoadBalancer address, so the sweep that added --insecure-control-plane to
every hardcoded in-cluster http:// launch site missed it, and the kind-mesh
e2e job failed at "plaintext http:// control plane URL to a non-loopback
host". This is the flag's intended case: the docker bridge on the developer's
machine is the trust boundary, and the script already fetched the admin
token over the same hop.

TLS was considered and left for its own change: the chart's Gateway listener
is HTTP-only, the control plane has no TLS serving mode, and sam-node has no
CA-pinning flag for a self-signed dev certificate.
@aojea

aojea commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

kind-mesh e2e: development/kind/run-local-node.sh builds its control-plane URL from the Gateway's LoadBalancer IP at runtime, so it was the one plaintext launch site the flag sweep missed. Opted in with --insecure-control-plane (the docker bridge on the dev machine is the trust boundary; the script already fetches the admin token over that hop). TLS for the kind flow would need an HTTPS Gateway listener, a TLS mode on the control plane and a CA-pinning flag on sam-node — a separate change, not dev-only plumbing to bolt on here.

Verifying the callee's biscuit on every egress call (H3-a) made the
label gate the first thing that dials a peer, ahead of the egress proxy
that used to do the address resolution and relay reservation for it.
The gate dialled with a plain context, so libp2p demanded a direct
connection and peers behind a relay or not yet in the peerstore failed
with "no addresses" -> egress 403. The relay and sam-one flows carry no
labels or floor, so the gate had never been exercised on them before.

Run preparePeerAddrs first and open the auth stream with
WithAllowLimitedConn, mirroring what the guarded call does. Unit test
pins the limited-connection opt-in.
auth_flows: the two `sam-node join` invocations take the control plane
URL positionally and were missed when the other http:// call sites
opted into --insecure-control-plane; join now refuses plaintext to a
non-loopback host by default.

datapath: the stdio assertions encoded the old broadcast behaviour
(POST reply delivered on a shared SSE stream to whoever was listening).
The bridge now correlates replies to the requesting POST, so the test
issues two concurrent POSTs with the same id and checks each caller gets
its own reply, and asserts GET returns 405 as the MCP Streamable HTTP
transport permits for servers that do not offer a server-push stream.
@aojea

aojea commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Bats e2e triage (run 35217706060, 5 failures) — each failure was classified as breaking change, test relying on an assumption this PR removes, or regression, before touching anything:

# Test Verdict Fix
4, 6 Auth Flow 2 (device) / Flow 4 (bootstrap token) Breaking change (intended). sam-node join now refuses http:// to a non-loopback control plane. These two call sites pass the URL positionally and were missed when the other plaintext call sites opted in. 77379f3: add --insecure-control-plane to the two join invocations.
12 Datapath: stdio service reachable Wrong assumption. The test expected the POST reply to be broadcast on a shared SSE stream (the M18 finding this PR fixes). MCP Streamable HTTP lets a server answer GET with 405 when it offers no server-push stream. 77379f3: two concurrent POSTs with the same JSON-RPC id must each get their own reply; GET must return 405.
24, 37 Relay discovery / sam-one dataplane Regression from H3-a. Unconditional callee verification made the label gate the first dial to a peer, with a plain context: libp2p then demands a direct connection, and relay-only or not-yet-resolved peers fail with no addresses → egress 403. Never surfaced before because those flows have no labels/floor. d6619e1: gate runs preparePeerAddrs and dials with WithAllowLimitedConn, same as the guarded call; unit test pins the opt-in.

Verified locally with E2E_REUSE_CLUSTER=1 bats tests/e2e/{datapath,relay,auth_flows}.bats → 7/7, plus standalone.bats (sam-one) → ok. Lint clean, go test -race ./internal/node/ green.

@aojea
aojea merged commit 63d46fa into google:main Sep 17, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant