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>
84 lines
2.8 KiB
Go
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")
|
|
}
|
|
}
|