Skip to content

Consumer stream token is not bound to an operator: any aud=stream JWT opens any gateway's stream #71

Description

@CryptoFewka

Summary

A consumer stream JWT is verified by audience only, with no tenancy binding, so any aud=stream token minted by the auth service opens the stream on any operator's gateway, not just the one it was issued for.

Called out during review of #68 but not fixed there. Pre-existing: it arrived with the auth seam (#61) and the WS transport (#65), and #68 extends the same surface to a second port.

What the auth service already sends

optimum-auth mints operator_id and cluster_ids onto stream tokens specifically as the tenancy binding (src/lib/es256.ts, mintStreamToken). Its own commit message is explicit that the enforcement half is missing:

operator_id + cluster_ids are the tenancy binding: the gateway verifies locally against the JWKS, so without them any operator's token opens any operator's stream.

and:

without any tenancy binding any Optimum-minted stream token would validate against every operator's gateway, since the gateway checks only issuer and aud. Emitting them now lets the gateway enforce whenever it lands the check.

So the claim is on the wire today. Nothing consumes it.

What the gateway does

  • jwks_verifier.Claims has no operator_id field at all. ClusterIDs exists, but is documented as enforced at the handshake, not on the stream path.
  • VerifyStreamToken (pkg/service/auth_token/manager.go:284) calls verifier.Verify(rawJWT, AudStream) and returns. Deliberately no chain gate, and no tenancy gate either.
  • pkg/service/stream never reads operator_id or cluster_ids. jwksAuthenticator.Authenticate returns claims.Subject, used only as the connection-cap key.
  • Verify pins iss (the configured OPT_REMOTE_AUTH_URL) and aud. Every gateway trusting that issuer shares the same JWKS, so a token issued for operator A validates on operator B's gateway.

Impact

Any holder of a valid stream token can read the beacon-block stream of any gateway that trusts the same issuer, including mode=raw with verbatim block bytes. Since the operator hands these tokens to their own downstream consumers, one operator's consumer can read another operator's stream if they can reach the port.

The connection caps do not mitigate this: they key on sub, so a foreign token simply occupies its own slot.

ADR-0011's "No scope claim in v1" line is about topics ("there is one topic, so a valid stream token grants read of the whole stream"), not about which gateway a token is valid against, so this does not look like an intentional v1 simplification.

Possible shape of a fix

Gateway-side only, since the claim is already emitted:

  1. Add OperatorID string \json:"operator_id"`tojwks_verifier.Claims`.
  2. Gate it on the stream path, either in VerifyStreamToken or in stream.jwksAuthenticator, comparing against the gateway's own operator. auth_token.Service.OperatorID() already exposes that, sourced from the /auth/token mint response.
  3. Decide the behaviour when the gateway's own operator_id is empty. It is documented as empty when upstream auth predates the field, so failing closed would break those deployments and failing open would silently restore today's behaviour. Whichever is chosen should be explicit rather than incidental.
  4. cluster_ids could gate further if stream access should be cluster-scoped, following the handshake precedent.

Worth a test that a token minted for a different operator is rejected, on both transports.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions