Skip to content

Signed-grant caveats are verified but never enforced: netd checks hosts against env config, not against the grant it just validated #24

Open

Description

@bdelanghe

Sub-issue of #21.

The gap

A SignedGrant carries caveats, and they are covered by the signature — guest-room/mod.ts:280-288:

export function grantSigningBytes(grant: DoorGrant, binding: GrantBinding): string {
  return JSON.stringify({
    name: grant.name,
    guest: grant.guest,
    caveats: [...(grant.caveats ?? [])].sort(),
    binding,
  });
}

netd validates that signature on the tcp/vsock path. verifyConnectGrant (netd/netd.ts:93-110) checks:

  • the bearer parses as a SignedGrant
  • grant.name === "net" (right door)
  • audience matches ROOM_ID, and exp has not passed (guest-room/mod.ts:326)
  • the signature verifies against the concierge's published keys

It then returns { ok: true } and discards the grant. grant.caveats is never read.

The host decision that follows (netd/netd.ts:205 → allowed() at :168-174) consults ALLOW and CAVEATS, both derived from environment variables at process start (netd/netd.ts:159-165).

Consequence

There are two unrelated things called "caveats" in this system and they never meet:

source scope enforced?
NETD_CAVEATS env, at daemon start whole daemon, all callers yes — allowed()
grant.caveats signed, per-caller that caller's authority no

A grant attenuated to host=github.com and one with no caveats at all produce identical egress through netd. The narrowing is signed, transmitted, and verified — and then has no effect on what the holder can reach.

This is a direct contradiction of the attenuation model the org's engine implements and proves. guest-room goes to real lengths here: attenuate is append-only, attenuatesDoors proves narrowing is monotone, and interpose.ts exists specifically because "a delegated child still holds the same upstream reference… the caveats were a string it was trusted to honor, not a boundary" (guest-room/interpose.ts:8-17). At netd, the caveats are exactly that string again — except here nobody is even trusted to honor them, because nothing reads them.

guest-room/mod.ts:305-307 says it outright: "Pair with checkCaveats for full enforcement (signature THEN caveats)." netd does the signature half.

Proposed fix

  1. Thread the verified grant out of verifyConnectGrant instead of discarding it — return the validated SignedGrant alongside the verdict, so onHead has it in scope at the point of the host decision.

  2. Apply the grant's caveats to the host check, intersected with (never widening) the daemon's ALLOW. The engine already has the primitive: checkCaveats(grant, ctx, verifiers) with a host verifier, where ctx is derived from the CONNECT target. This is the same shape interpose.ts:94-118 uses — check caveats, and only then forward.

  3. Decide the relationship between NETD_CAVEATS and grant.caveats explicitly and document it. The safe rule is intersection: a host must satisfy ALLOW ∧ NETD_CAVEATS ∧ grant.caveats, so neither channel can widen the other.

  4. Tests to add:

    • a grant with host=X reaching X → allowed
    • the same grant attempting Y, where Y is in the daemon's ALLOW but not in the grant's caveats → 403
    • a grant whose caveats name a host outside ALLOW → still 403 (caveats cannot widen)

    That middle case is the one that currently fails, and it is the whole point of the issue.

Note on the unix path

On unix the mounted socket is the authority and no grant is presented (netd/netd.ts:41-46), so caveat enforcement there needs a different carrier — the per-launch NETD_CAVEATS, or a scoped netd instance per room, both of which the launcher already does. This issue is specifically about the tcp/vsock path, where a signed grant is present and is being partially ignored.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions