Skip to content

moq-dev/moq: a client certificate's identity never reaches the authorization decision #3603

Description

@t0ms

Summary

When a relay is configured with --server-tls-root, a peer presenting any certificate that chains to
that root is admitted to whatever path it dials, with publish and subscribe both unscoped
beneath it. The authorization endpoint is told that some certificate was verified — mtls: true
and never which one. So an endpoint cannot distinguish one certificate holder from another, and
certificate-scoped entitlement is not merely unimplemented but not expressible through the
--auth-api contract.

The part that is easy to miss is that the root is not a property of the certificate. It is chosen by
the client, in the URL:

// rs/moq-relay/src/auth.rs
pub async fn verify_mtls(&self, path: &str, transport: Option<Transport>) -> Result<AuthToken, AuthError> {
    let (root, tier) = self.resolve_mtls(path, transport).await?;
    let mut token = AuthToken::unrestricted(Path::new(&root).to_owned());
    token.tier = tier;
    Ok(token)
}

and unrestricted grants everything beneath that root:

pub fn unrestricted(root: PathOwned) -> Self {
    Self {
        root,
        subscribe: PathPrefixes::from(vec![Path::new("").to_owned()]),
        publish: PathPrefixes::from(vec![Path::new("").to_owned()]),
        ...
    }
}

resolve_mtls does consult the auth API, and it fails closed on an API error, so the endpoint is not
powerless — it can alias the path, set a tier, or refuse. But the only thing it can key any of that on
is the path the client asked for. The party being scoped picks the scope.

What we measured

A relay with --server-tls-root <affiliate CA> and --auth-api, an estate of two tenants, and one
client holding a valid affiliate certificate and no token at all. Each cell is a 30 s subscription;
the figure is payload bytes delivered, taken from the subscriber's capture rather than from a relay log
line.

Dialled Result
wbd/cnn — the channel this affiliate licenses 7,260,560 bytes
wbd/cnn-intl — licensed by no affiliate 6,736,040 bytes
wbd/tnt — licensed by other affiliates 7,263,380 bytes
wbd/nobody — licensed by nobody 2,408,656 bytes
rival/cnn — the other broadcaster's tenant 7,093,804 bytes

Every cell served. The same client with a token instead of a certificate is refused on four of those
five, with zero payload bytes, exactly as the grants say it should be — so the token path is working
and it is the certificate path that bypasses it.

Why this is worth changing rather than documenting

The comment in resolve_mtls states the rationale plainly — "mTLS peers are already trusted (the cert
is the credential)" — and for the case it was written for, cluster peers dialling /, that is right.
The difficulty is that the same switch is the natural one to reach for when an operator wants mutual
authentication for subscribers, and there it silently converts a per-affiliate credential into a
master key for every tenant on the relay. Nothing warns at startup, nothing appears in the session, and
the authorization endpoint — the one component that holds the licensing matrix — cannot see that it has
been bypassed.

It also fails in a direction that is hard to detect from the outside: everything works. The affiliate
gets its channel. The misconfiguration only becomes visible when someone dials a path they should not
have, which is exactly the case nobody tests.

What we would suggest

Minimally, and without changing any default:

  1. Carry the peer's certificate identity in AuthApiRequest. A subject or a SAN, or the leaf's
    SHA-256 fingerprint, as one more optional query parameter beside kid. That alone makes
    certificate-scoped entitlement expressible: the endpoint can return a narrower public.subscribe
    for a subscriber certificate, or refuse outright, using the same reply shape it already has.
  2. Consider not granting publish unscoped to an mTLS peer by default, or making the grant
    configurable separately from subscribe. A subscriber certificate that can publish into the tenant
    it is reading is a larger grant than the deployment probably intends.
  3. Warn at startup when --server-tls-root is set alongside --auth-api. The combination is not
    wrong, but it means the API's answer is advisory for one class of peer and binding for the other,
    and that is worth one line on stderr. We are not claiming a precedent for this: an earlier
    version of this issue said the relay already warns about configurations where nothing can
    authenticate, and that was wrong. It does not. Every tracing::warn! in Auth::new on
    fd4f5d82e is a deprecation notice for a superseded flag (--auth-tls-*, --auth-key-dir with a
    URL, --auth-public-api), and the remaining checks are anyhow::ensure! hard errors on mutually
    exclusive flags. There is no existing diagnostic for a configuration that is merely hazardous, so
    this asks for a new class of warning rather than one more instance of an established one — which
    may make it a larger ask than (1) or (2), and it should be judged on the hazard rather than on
    consistency with something that isn't there.

We are happy to prepare the AuthApiRequest change if the direction is acceptable; the reply-side
contract needs no change for (1), only the request.

What we are not claiming

  • This does not contradict the documented split of mTLS for data-plane peers and JWTs for subscribers.
    It is the cost of ignoring that split, and the point is that the relay does not make the split
    enforceable.
  • We have not tested a cluster deployment. Every measurement above is one relay, and how this
    interacts with cluster-peer trust is not something we can speak to.
  • Certificate revocation was not measured. An all-or-nothing credential has no per-channel
    de-provisioning behaviour to measure, so we cannot say how quickly withdrawing a certificate takes
    effect.

Activity

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

    questBeing tracked/planned in a quest. See `quest/`

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions