carlos / platform Public

console: accept an uppercase listing id, report whether revoke matched

Review round on the mint doors: C40, C41, C42 and two minors.

C40 (Important) — listingIDRe accepted lowercase hex only, so an id copied
out of PowerShell's Get-FileHash, certutil or a pasted audit extract matched
neither arm. The handle addressed nothing, the door answered with the same
bytes as a successful revoke, no store call was made and the early return
skipped the audit entirely. An owner racing a compromise walked away from a
live admin credential believing it dead. The regex now accepts [0-9a-fA-F]
and the id arm folds to lower. The NAME arm stays byte-exact: a 64-character
segment can never be a name, so folding there is safe, while folding a name
would make HUB silently revoke hub.

C41 — revoke gains a non-authoritative "matched" boolean beside the
unchanged 200 "result":"revoked". Idempotence only requires the second call
on a handle to answer like the first, and "matched": false on a second
revoke is both true and useful. The likely miss is a typo'd name, and an
opaque 200 turned that into a false all-clear at the door used in anger.
TestRevokeIsIdempotent's byte-identity assertion relaxes to status + result
(controller's call) and gains a per-case matched assertion.

C42 — an unaddressable handle is now audited under a subject that cannot be
an app name, so the branch stops contradicting the principle its neighbour
states. The handle itself is not echoed: it is unbounded caller-controlled
text, which is why it never reaches the store either.

Minor 5 — serviceView says why it carries no `revoked` field.
Minor 6 — the account-scoped audit test now exercises rotate and checks the
actor and detail, so serviceAuditKey(name, "") on the rotate path is pinned.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016UimU7fAfeAoBMm6HKCPwQ
Paul Campbell pushed by paul@keymail.dev 7866e5e99cb575525d67dd92963434dcd3dc4ece parent d276ef2
2 files changed, +220 −34
  • internal/console/apiservices.go +84 −27
  • internal/console/apiservices_test.go +136 −7
diff --git a/internal/console/apiservices.go b/internal/console/apiservices.go
index dc3e09be3..4508c215d 100644
--- a/internal/console/apiservices.go
+++ b/internal/console/apiservices.go
@@ -71,6 +71,11 @@ type serviceView struct {
Rotated string `json:"rotated"`
LastUsed string `json:"last_used"`
MintedBy string `json:"minted_by"`
+ // There is deliberately no `revoked` field, though the brief's field
+ // list names one. ServicePrincipals filters revoked_at = 0 (a revoked
+ // row is a tombstone, not history — see RevokeServicePrincipal), so
+ // every row reaching here is live and the field would be a constant
+ // false. Absent as derived, not forgotten.
}
// apiOwnerAccount is apiPrincipalAccount plus the owner check, for the doors
@@ -279,9 +284,26 @@ func serviceStamp(t time.Time) string {
return t.UTC().Format(time.RFC3339)
}
-// listingIDRe is the shape of a listing id: the lowercase hex sha256
-// hashToken writes, exactly 64 characters.
-var listingIDRe = regexp.MustCompile(`^[0-9a-f]{64}$`)
+// listingIDRe is the shape of a listing id: the hex sha256 hashToken writes,
+// exactly 64 characters. It accepts UPPERCASE hex as well as lower, and the
+// id arm below folds what it matches to lower.
+//
+// That is not politeness, it is the difference between a credential being
+// dead and an operator believing it is. hashToken writes lowercase, but the
+// person reaching for this door is racing a compromise and is as likely to
+// have the id from PowerShell's Get-FileHash, certutil, or a pasted audit
+// extract — all of which emit uppercase — as from the listing. Case-strict,
+// that paste matches neither arm, and the answer to an unaddressable handle
+// is a 200 indistinguishable from a successful revoke. The operator walks
+// away from a live admin credential believing they killed it.
+//
+// Folding is provably safe HERE and nowhere else. A 64-character segment can
+// never be a credential name (ValidTunnelName caps a name at 32), so nothing
+// this arm folds could have been meant as a name. The NAME arm stays
+// byte-exact for exactly the opposite reason: folding there would make the
+// segment HUB revoke the credential hub, which is a different credential
+// than the one the caller typed, and silently.
+var listingIDRe = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
// serviceHandle is the {name} path segment resolved to how it addresses a
// row: a NAME (create's grammar) or a listing ID (C36).
@@ -289,8 +311,10 @@ var listingIDRe = regexp.MustCompile(`^[0-9a-f]{64}$`)
// The two grammars are disjoint — a name is at most 32 characters of
// [a-z0-9-] and a listing id is exactly 64 hex characters — so a segment can
// never be read as both, and no flag or query parameter is needed to say
-// which one the caller meant. A segment that is neither addresses nothing:
-// no row this console ever wrote could match it.
+// which one the caller meant. That disjointness is also what makes the id
+// arm safe to case-fold while the name arm is not (see listingIDRe). A
+// segment that is neither addresses nothing: no row this console ever wrote
+// could match it.
//
// Refusing an unrecognisable segment here rather than passing it to the
// store also keeps an arbitrary, arbitrarily long path segment out of the
@@ -314,7 +338,9 @@ func serviceHandleFrom(r *http.Request) serviceHandle {
case registry.ValidTunnelName(seg):
return serviceHandle{name: seg}
case listingIDRe.MatchString(seg):
- return serviceHandle{id: seg}
+ // Folded to the case hashToken writes. See listingIDRe: safe on this
+ // arm alone, because a 64-character segment cannot be a name.
+ return serviceHandle{id: strings.ToLower(seg)}
}
return serviceHandle{}
}
@@ -337,6 +363,15 @@ func serviceAuditKey(name, app string) string {
return name
}
+// serviceAuditSubject is the app column an audit row is filed under when
+// there is no credential to name — an attempted revoke whose handle
+// addresses nothing (C42).
+//
+// The parentheses and the space are load-bearing: an app name is lowercase
+// letters, digits and dashes starting with a letter (handleAPICreateApp), so
+// this string cannot collide with a real app's activity feed.
+const serviceAuditSubject = "(service credentials)"
+
// findServiceCredential looks one of this account's LIVE credentials up by
// whichever handle addressed it.
//
@@ -432,19 +467,25 @@ func (s *Server) handleAPIServiceRotate(w http.ResponseWriter, r *http.Request)
// without it an owner could see such a credential in the listing and have no
// way to kill it from here. serviceHandle tells the two apart by shape.
//
-// REVOCATION IS IDEMPOTENT, and that is a deliberate refusal to be precise.
-// An unknown name, an unknown id, an already-revoked credential and a live
-// one all answer the same 200, because the person reaching for this door is
-// usually racing a compromise and must not have to work out whether their
-// first attempt landed. The one thing they need to know afterwards — that
-// the credential is dead — is true in every one of those cases.
+// REVOCATION IS IDEMPOTENT. An unknown name, an unknown id, an
+// already-revoked credential and a live one all answer 200
+// "result":"revoked", because the person reaching for this door is usually
+// racing a compromise and must not have to work out whether their first
+// attempt landed. What they need afterwards — that no credential of that
+// handle is live — is true in every one of those cases.
//
-// It costs nothing in disclosure: the caller is the account's owner, so
-// "does a credential of this name exist here" is not a fact being kept from
-// them. It is available from the listing one door away. An id belonging to
-// ANOTHER account is a different matter, and it is refused by construction:
-// both store calls below are scoped to acct.ID, so a foreign id matches
-// nothing and gets the same 200 as a fabricated one, having changed
+// AND IT SAYS WHICH IT WAS, in a non-authoritative "matched" boolean (C41).
+// Idempotence demands only that a SECOND call on the same handle answer like
+// the first, and "matched": false on a second revoke is both true and
+// useful. The likely miss here is not a stolen id but a typo — `revoke hubb`
+// — and an opaque 200 turns that into a false all-clear at the one door used
+// in anger. It costs nothing in disclosure either: the caller is the
+// account's owner, so "is there a live credential of this handle" is not a
+// fact being kept from them; it is in the listing one door away.
+//
+// An id belonging to ANOTHER account is a different matter, and it is
+// refused by construction: both store calls below are scoped to acct.ID, so
+// a foreign id matches nothing, reports "matched": false, and has changed
// nothing.
func (s *Server) handleAPIServiceRevoke(w http.ResponseWriter, r *http.Request) {
p, acct, ok := s.apiOwnerAccount(w, r)
@@ -455,12 +496,27 @@ func (s *Server) handleAPIServiceRevoke(w http.ResponseWriter, r *http.Request)
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return
}
+ actor := auditActor(p, acct)
h := serviceHandleFrom(r)
if !h.addresses() {
// A segment that is neither a name nor a listing id matches no row
- // this console ever wrote, so it gets the answer an unknown handle
- // gets — which is the same answer every other case gets.
- writeJSON(w, http.StatusOK, map[string]any{"result": "revoked"})
+ // this console ever wrote, so it reports the same "nothing matched"
+ // an unknown handle does.
+ //
+ // It is AUDITED, under a subject that is not an app (C42). An
+ // attempted revoke that could not have worked is exactly the event
+ // an operator goes looking for afterwards — "I definitely revoked
+ // that" — and the early return used to leave no trace of it at all,
+ // which contradicted the principle stated at the audit call below.
+ //
+ // The handle itself is not echoed into the row. By definition it is
+ // not something this console wrote, and it is an unbounded
+ // caller-controlled path segment; keeping it out of the trail is the
+ // same reason serviceHandle refuses to pass it to the store. Who,
+ // when, and "the handle was unusable" is what the operator needs.
+ s.audit(acct, serviceAuditSubject, actor, "service-revoke",
+ "revoke attempted with a handle that is neither a credential name nor a listing id (cli)")
+ writeJSON(w, http.StatusOK, map[string]any{"result": "revoked", "matched": false})
return
}
// Read before the write, for the audit only: a revoked row leaves the
@@ -505,15 +561,16 @@ func (s *Server) handleAPIServiceRevoke(w http.ResponseWriter, r *http.Request)
}
}
}
- actor := auditActor(p, acct)
detail := "revoked " + what + " (cli)"
if err != nil {
detail = "revoked " + what + " — nothing live matched (cli)"
}
s.audit(acct, serviceAuditKey(subject, row.App), actor, "service-revoke", detail)
- // The body carries no name and nothing else request-derived: it is the
- // same bytes for a live credential, an already-revoked one and a name
- // that never existed, which is what makes the idempotence above visible
- // from the outside rather than merely true inside.
- writeJSON(w, http.StatusOK, map[string]any{"result": "revoked"})
+ // The body carries no name and nothing else request-derived, so two
+ // revokes of the same handle are the same bytes — which is what makes
+ // the idempotence above visible from the outside rather than merely true
+ // inside. `matched` is the one thing that varies, and it varies with
+ // what HAPPENED rather than with what was asked: false on the second
+ // call for the same credential the first call killed.
+ writeJSON(w, http.StatusOK, map[string]any{"result": "revoked", "matched": err == nil})
}
diff --git a/internal/console/apiservices_test.go b/internal/console/apiservices_test.go
index d9c4a5b61..873d31fd8 100644
--- a/internal/console/apiservices_test.go
+++ b/internal/console/apiservices_test.go
@@ -52,6 +52,20 @@ func jsonField(t *testing.T, body, key string) string {
return s
}
+// jsonBool pulls one boolean field out of a raw JSON response body.
+func jsonBool(t *testing.T, body, key string) bool {
+ t.Helper()
+ var out map[string]any
+ if err := json.Unmarshal([]byte(body), &out); err != nil {
+ t.Fatalf("decode %s: %v (body %s)", key, err, body)
+ }
+ v, ok := out[key].(bool)
+ if !ok {
+ t.Fatalf("%s is not a boolean in %s", key, body)
+ }
+ return v
+}
+
// TestServiceMintIsOwnerOnly — minting is an authority grant that outlives
// its minter, so it needs the owner gate, not the ordinary member write
// door. The two refusals differ deliberately: a member who already proved
@@ -454,6 +468,9 @@ func TestRevokeIsIdempotent(t *testing.T) {
if first != http.StatusOK {
t.Fatalf("revoke = %d %s; want 200", first, firstBody)
}
+ if jsonField(t, firstBody, "result") != "revoked" || !jsonBool(t, firstBody, "matched") {
+ t.Errorf("revoking a live credential = %s; want result revoked and matched true", firstBody)
+ }
if status, out := getRaw(t, fx.ts.URL+"/api/cli/apps", tok); status != http.StatusUnauthorized {
t.Errorf("a revoked credential still works: %d %s", status, out)
}
@@ -467,9 +484,17 @@ func TestRevokeIsIdempotent(t *testing.T) {
t.Errorf("re-revoke = %d, unknown revoke = %d, unmintable revoke = %d; want 200 from all three",
second, unknown, junk)
}
- if secondBody != firstBody || unknownBody != firstBody || junkBody != firstBody {
- t.Errorf("revoke answers differ:\nfirst: %s\nsecond: %s\nunknown: %s\njunk: %s",
- firstBody, secondBody, unknownBody, junkBody)
+ // Idempotence is the STATUS and the result: every one of these leaves
+ // the caller with the same fact, that nothing of that handle is live.
+ for what, body := range map[string]string{"second": secondBody, "unknown": unknownBody, "junk": junkBody} {
+ if jsonField(t, body, "result") != "revoked" {
+ t.Errorf("%s revoke = %s; want result revoked", what, body)
+ }
+ // ...and `matched` is the non-authoritative half that says which it
+ // was, so a typo'd name is not a false all-clear (C41).
+ if jsonBool(t, body, "matched") {
+ t.Errorf("%s revoke reports matched true, but nothing was live: %s", what, body)
+ }
}
// And the row leaves the listing rather than lingering as a tombstone
// somebody has to interpret.
@@ -534,6 +559,9 @@ func TestServiceDoorsAuditAgainstTheApp(t *testing.T) {
// TestAccountScopedCredentialAuditsUnderItsName: with no app to file
// against, the subject IS the credential, so it keeps the shape fleets uses.
+// It exercises all three verbs and checks the actor and the detail, the same
+// as its app-scoped sibling — rotate especially, because
+// serviceAuditKey(name, "") on the rotate path is pinned nowhere else.
func TestAccountScopedCredentialAuditsUnderItsName(t *testing.T) {
fx := newConsoleFixture(t)
owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-audit-acct")
@@ -541,6 +569,9 @@ func TestAccountScopedCredentialAuditsUnderItsName(t *testing.T) {
if code, body := postRaw(t, base, mintBody("root", "admin", ""), owner); code != http.StatusCreated {
t.Fatalf("mint = %d %s", code, body)
}
+ if code, body := postRaw(t, base+"/root/rotate", map[string]string{}, owner); code != http.StatusOK {
+ t.Fatalf("rotate = %d %s", code, body)
+ }
if code, body := postRaw(t, base+"/root/revoke", map[string]string{}, owner); code != http.StatusOK {
t.Fatalf("revoke = %d %s", code, body)
}
@@ -548,12 +579,104 @@ func TestAccountScopedCredentialAuditsUnderItsName(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- seen := map[string]bool{}
+ want := map[string]bool{"service-create": false, "service-rotate": false, "service-revoke": false}
for _, e := range events {
- seen[e.Action] = true
+ if _, ok := want[e.Action]; !ok {
+ continue
+ }
+ want[e.Action] = true
+ if e.Address != "paul@keymail.dev" {
+ t.Errorf("%s audited as %q; want the human who pressed it", e.Action, e.Address)
+ }
+ if !strings.Contains(e.Detail, "root") {
+ t.Errorf("%s detail = %q; it must name the credential", e.Action, e.Detail)
+ }
+ }
+ for action, seen := range want {
+ if !seen {
+ t.Errorf("no %s audit row under the credential's name", action)
+ }
+ }
+}
+
+// TestRevokeAcceptsAnUppercaseListingID (C40) is the one that fires when it
+// matters most. hashToken writes lowercase, but an owner racing a compromise
+// as often has the id from PowerShell's Get-FileHash, certutil or a pasted
+// audit extract — all uppercase. Case-strict, that paste matched neither arm
+// and the door answered exactly like a successful revoke, leaving a live
+// admin credential behind a false all-clear.
+func TestRevokeAcceptsAnUppercaseListingID(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-revoke-upper")
+ tok := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Admin)
+ base := fx.ts.URL + "/api/cli/services/" + fx.sqid
+
+ upper := strings.ToUpper(hashToken(tok))
+ code, body := postRaw(t, base+"/"+upper+"/revoke", map[string]string{}, owner)
+ if code != http.StatusOK {
+ t.Fatalf("revoke by uppercase id = %d %s; want 200", code, body)
+ }
+ if !jsonBool(t, body, "matched") {
+ t.Errorf("revoke by uppercase id = %s; want matched true — the credential was live", body)
+ }
+ if status, out := getRaw(t, fx.ts.URL+"/api/cli/apps", tok); status != http.StatusUnauthorized {
+ t.Errorf("the credential still works after an uppercase-id revoke: %d %s", status, out)
+ }
+}
+
+// TestRevokeDoesNotFoldACredentialName is the other half of C40, and the
+// reason the folding lives in the id arm alone: a 64-character segment
+// cannot be a name, so folding there can misroute nothing — but folding a
+// NAME would make the segment HUB revoke the credential hub, which is a
+// different credential from the one the caller typed, killed silently.
+func TestRevokeDoesNotFoldACredentialName(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-revoke-case")
+ tok := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
+ base := fx.ts.URL + "/api/cli/services/" + fx.sqid
+
+ code, body := postRaw(t, base+"/HUB/revoke", map[string]string{}, owner)
+ if code != http.StatusOK {
+ t.Fatalf("revoke HUB = %d %s; want 200", code, body)
+ }
+ if jsonBool(t, body, "matched") {
+ t.Errorf("revoke HUB reports matched true — it must not reach hub: %s", body)
}
- if !seen["service-create"] || !seen["service-revoke"] {
- t.Errorf("account-scoped credential's rows not filed under its name: %+v", events)
+ if status, out := getRaw(t, fx.ts.URL+"/api/cli/apps", tok); status != http.StatusOK {
+ t.Errorf("hub was revoked by a request naming HUB: %d %s", status, out)
+ }
+}
+
+// TestUnusableRevokeHandleIsAudited (C42): an attempted revoke that could
+// not have worked is exactly the event an operator goes looking for
+// afterwards. The early return used to leave no row at all, which
+// contradicted the principle stated at the other audit call in the same
+// handler. Filed under a subject that cannot be an app.
+func TestUnusableRevokeHandleIsAudited(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-revoke-audit")
+ base := fx.ts.URL + "/api/cli/services/" + fx.sqid
+ if code, body := postRaw(t, base+"/Not%20A%20Handle/revoke", map[string]string{}, owner); code != http.StatusOK {
+ t.Fatalf("revoke with an unusable handle = %d %s; want 200", code, body)
+ }
+ events, err := fx.st.Events(OpsAccountID, serviceAuditSubject, 50)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(events) != 1 {
+ t.Fatalf("audit rows under %q = %d, want 1: %+v", serviceAuditSubject, len(events), events)
+ }
+ e := events[0]
+ if e.Action != "service-revoke" || e.Address != "paul@keymail.dev" {
+ t.Errorf("row = %+v; want a service-revoke by the human who pressed it", e)
+ }
+ if !strings.Contains(e.Detail, "neither a credential name nor a listing id") {
+ t.Errorf("detail = %q; it must say the handle was unusable", e.Detail)
+ }
+ // The handle itself is unbounded caller-controlled text and must not be
+ // echoed into the trail — the same reason it never reaches the store.
+ if strings.Contains(e.Detail, "Not A Handle") {
+ t.Errorf("detail echoes the caller's handle: %q", e.Detail)
}
}
@@ -647,6 +770,12 @@ func TestRevokeIgnoresAnIdFromAnotherAccount(t *testing.T) {
t.Errorf("a foreign id is distinguishable from an unknown name:\nunknown: %s\nforeign: %s",
mineBody, foreignBody)
}
+ // And it says so: a foreign id must never report a match, or the
+ // non-authoritative signal would be telling the caller they killed
+ // something in an account they do not hold.
+ if jsonBool(t, foreignBody, "matched") {
+ t.Errorf("a foreign id reports matched true: %s", foreignBody)
+ }
if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps", theirs); status != http.StatusOK {
t.Errorf("the other account's credential was revoked through this door: %d %s", status, body)
}