0015 — A per-audience groups delimiter, for opkssh's colon-splitting bug¶
Status: Accepted; refines 0004 and 0011 Date: 2026-09-28
Context¶
0004 and
0011 settled how
people authenticate over SSH: opkssh verifies this issuer's ID token
directly, with no certificate authority in between, and a server's own
policy reads oidc:groups:<value> against the groups claim
0009
already lets a client pin signing_alg: RS256 or ES256 for, since
opkssh cannot verify this installation's ES384 default.
That leaves one gap 0004 and 0011 did not anticipate: opkssh's own
policy parser splits its argument on every : and compares only the LAST
segment. A server line written as
oidc:groups:devel:ssh:user
is not "match the group devel:ssh:user" to opkssh — it is "match
whatever the LAST :-separated field is", user, against a held group
of the same name. This schema's own grant shape,
<scope>:<thing>:<role> (taxonomy.md), is built
entirely out of the one character opkssh's parser treats as a field
separator, so no group this schema would ever mint for opkssh can match
by name at all — not devel:ssh:user, not any other. Quoting the value
in the server's own policy file does not help: opkssh does not strip
quotes from what it compares against, so a quoted value simply never
matches either.
This is upstream's bug, not a gap in what this issuer mints — a groups
claim that says devel:ssh:user, verbatim, exactly as
Groups → token, by deep merge
promises every other relying party. Waiting for opkssh to fix its own
parser is the right long-term answer and is not this repository's to
schedule. In the meantime, opkssh-facing SSH access is unusable through
this issuer at all, for every installation that has adopted 0004 and
0011 otherwise.
Decision¶
A client row or a resource row may pin groups_delimiter, modelled
exactly on 0009's
signing_alg: one more optional string on the same two tables, read by
the same audience-resolution rule, refused at load if it is not safe.
Set, it rewrites every : in each name under THAT AUDIENCE's groups
claim to the configured string, after
Groups in a token (scoping)
has already decided which groups survive. devel:ssh:user becomes
devel.ssh.user under groups_delimiter: "." — a single field to
opkssh's own splitting, which its policy line can then match.
The full mechanism — validation, precedence, where it hooks into minting — is reference/policy.md#groups-delimiter-per-audience-opkssh-interop. What belongs here is why a character-level restriction on the delimiter is not, by itself, the whole of what makes this safe:
No single delimiter character can be proven absent from every group
name this schema could ever declare. A Groups-table key is not
required to fit <scope>:<thing>:<role> at all — an installation may
declare any string as a group, taxonomy.md calls this
"unconventional" and warns rather than refuses it — and even a concrete
grant's own segments carry no character restriction narrower than "not
empty, no :". So this schema refuses a delimiter built from the
alphabet a real grant is conventionally written in ([A-Za-z0-9-],
literally every scope, thing and role example in this codebase) as the
character-level rule, and separately, at the SAME load, checks whether
the chosen delimiter would actually collide two of THIS policy's own
declared groups — the one check that is a genuine proof rather than a
convention, because it runs against the installation's real, finite set
of names rather than every string that could theoretically exist.
. is the documented example because it sits outside that alphabet and
outside every fixture, test and reference example this repository
declares — not because it is somehow structurally impossible in a group
name, which nothing in this schema can promise for every installation's
every future edit.
Consequences¶
opkssh is usable for the groups a groups_delimiter-pinned client
grants, without moving anything else. Every audience that has not set
it keeps minting devel:ssh:user exactly as before; the shim is scoped
to the one relying party that needs it, the same shape 0009 chose for
signing_alg.
This is an interop shim, not a naming change. The policy's own
groups table, requires, claims and every groups: override still
read and write the REAL name, devel:ssh:user — the rewrite happens once,
at the moment a token for that one audience is minted, and nowhere else.
An operator reading the policy file never sees the rewritten spelling; an
operator reading THAT AUDIENCE's tokens always does.
It is temporary. The day opkssh's own parser treats its argument as
one opaque value rather than splitting on every :, groups_delimiter
has nothing left to do for that installation, and the row can simply be
removed — nothing else in this schema depends on it existing.
An installation that points this at its own hub client locks itself
out of its own console. The service's own two groups, and scoping
them
are parsed by splitting the SAME : this option removes; the reference
documentation says so plainly, and nothing here adds a code-level guard
against it, the same way nothing stops an operator from pinning an
unusable signing_alg to that same client either.
Alternatives considered¶
Fix it only in opkssh, upstream, and wait. The right destination, and
not rejected — but it is not this repository's release to schedule, and
every installation that has already adopted 0004/0011 for opkssh is
blocked on SSH access for any group whose name contains : until it
lands, which is every group this schema's own taxonomy produces.
Rename this installation's groups to avoid : entirely, e.g. give
every group a single-segment alias. Rejected: it does not merely work
around opkssh's bug, it reverses taxonomy.md's whole
point — the name being the whole of the fact, <scope>:<thing>:<role>,
carried unchanged into every relying party's own binding — for every
consumer, to accommodate the one relying party that cannot read it, when
the fix can instead be scoped to exactly that one audience.
A single installation-wide delimiter, instead of per-audience.
Rejected for the same reason 0009 rejected a single installation-wide
signing algorithm: it would change what EVERY relying party's token says,
including every one that already reads :-separated names correctly
today, to work around a bug in exactly one of them.
Let an operator write an arbitrary rewrite (a regex, a template) instead of a single substitution character. Rejected: the substitution this issuer needs is one thing, replace the separator, and an expression language buys generality nothing here asks for at the cost of a load-time check that would have to reason about arbitrary functions instead of one substitution whose injectivity a straightforward collision check can actually decide.