Files
cloud/auth_kid_test.go
Hanzo Dev 538801a946
Hanzo CI/CD / cicd (push) Successful in 18s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 2m3s
cloud: the last reader of one contract
cloud held the third independent reading of what an IAM token means: its own claim
struct, its own algorithm allowlist, its own JWKS cache, its own key selection, its
own issuer comparison. The file said so at the top, and explained why — the gateway
is heavyweight and already imports cloud, so importing its validator back would
braid a module cycle. Both halves were true. The conclusion, write our own, is what
produced the copy.

The premise is gone: hanzoai/authz is 148 packages with one non-stdlib dependency
and imports nothing from cloud or the gateway. So idClaims now EMBEDS authz.Claims
rather than restating it, and validate() is the shared edge.Verifier.

The copy was not free, and both costs were real:

  it declared a `type` claim IAM emits NOWHERE and read it as the machine
  discriminator, so every machine principal arrived as a human (fixed last change);

  its key selection FELL BACK — after the kid-matched key failed it tried every RSA
  signing key in the JWKS and accepted the first that verified. A token naming
  cert-hanzo was accepted on a signature from cert-lux, and a token naming NO key
  was accepted on any of them. Not exploitable by a tenant, because IAM publishes
  only certs owned by a reserved platform org, but the INVARIANT was gone: any
  future widening of what reaches the JWKS becomes an impersonation path silently.

TestTokenIsVerifiedByTheKeyItNamed pins it through cloud's own boundary, with TWO
platform keys published — the real shape, since rotation is additive and each brand
has its own cert.

A FALSIFICATION THAT DIDN'T BITE, which is the finding worth recording. Reverting
isHuman to the pre-fix reading left every SuperAdmin probe GREEN: that arm is
separately blocked because a machine resolves no home org at all. What actually
depended on isHuman was the ORG-admin bit, and nothing tested it. So the predicate
was load-bearing in exactly one place and pinned in none.
TestMachineIsNeverMintedTheOrgAdminBit now covers it, and reverting isHuman turns
it red. `captured` gained the org-admin bit, because no test was reading it.

WHAT STAYED, deliberately: the trusted-issuer SET (the brands this binary fronts —
authz gained an issuer allowlist for exactly this), the API-key resolver and the
subjectOrg it yields, the memo that keeps a replayed ZAP credential from
re-verifying per frame, and username()'s legacy `name` fallback, which now EXTENDS
authz.Username rather than restating it. VerifiedIdentity.Orgs stays []model.OrgRef
— it is cloud's published contract, copied verbatim into a session by clients/team —
so there is ONE conversion, at that surface.

auth_identity.go: 717 → 520 lines. Full suite 174 packages, CGO_ENABLED=0
-tags sqlite_fts5 with the dev KMS key.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 10:15:57 -07:00

84 lines
2.8 KiB
Go

package cloud
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
// A token is verified by the KEY IT NAMED, or not at all — asserted through cloud's
// own boundary, because this is the boundary that runs in front of production.
//
// cloud used to hold its own key selection, and it fell back: after the kid-matched
// key failed it looped over every RSA signing key in the JWKS and accepted the first
// that verified. So a token naming cert-hanzo was accepted on a signature from
// cert-lux, and a token naming NO key was accepted on any of them.
//
// It was not exploitable by a tenant: IAM publishes only certs owned by a reserved
// platform org (isSigningCert → store.IsSigningCertOwner), so a customer cannot get a
// key into the set to sign with. What the fallback cost is the INVARIANT, and with
// the invariant gone any future widening of what reaches the JWKS becomes an
// impersonation path silently.
func TestTokenIsVerifiedByTheKeyItNamed(t *testing.T) {
named, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
other, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
// Two PLATFORM keys, both legitimately published — which is the real shape: key
// rotation is additive and each brand has its own cert.
jwk := func(kid string, pub *rsa.PublicKey) map[string]any {
return map[string]any{
"kty": "RSA", "kid": kid, "use": "sig", "alg": "RS256",
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
}
}
body, _ := json.Marshal(map[string]any{"keys": []map[string]any{
jwk("cert-hanzo", &named.PublicKey),
jwk("cert-lux", &other.PublicKey),
}})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(body)
}))
defer srv.Close()
v := newIdentityValidator(testIssuer, srv.URL, 0)
sign := func(key *rsa.PrivateKey, kid string) string {
t.Helper()
c := tokenClaims("hanzo-console", "acme", "", false, time.Now().Add(time.Hour))
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, c.Claims)
if kid != "" {
tok.Header["kid"] = kid
}
raw, err := tok.SignedString(key)
if err != nil {
t.Fatal(err)
}
return raw
}
if _, err := v.validate(sign(named, "cert-hanzo")); err != nil {
t.Fatalf("a token signed by the key it named was refused: %v", err)
}
if _, err := v.validate(sign(other, "cert-hanzo")); err == nil {
t.Error("SECURITY: a token naming cert-hanzo verified against a DIFFERENT published key")
}
if _, err := v.validate(sign(named, "")); err == nil {
t.Error("SECURITY: a token naming NO key verified against the set")
}
}