brand: prove the bearer scheme is compared, and name what the pin does not settle

A negative row presented the token as "Basic <jwt>", six characters where Bearer
is seven, so a verifier that never compared the scheme would still have refused it
— on a token it had read one character into. Digest is exactly as long, so the row
now fails for the reason it claims to test. Killing that mutant is what showed the
test was passing on the wrong evidence.

The issuer pin stops another brand's token; it does not make org names unique
across issuers, and the tenant here IS the org name. Stated where the decision
lives: one record plane serving several brands has to scope by brand as well as by
org, one plane per brand does not.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-29 21:33:33 -07:00
co-authored by Hanzo Dev
parent e1103e15cb
commit b73c14a51a
3 changed files with 48 additions and 4 deletions
+32 -4
View File
@@ -14,7 +14,8 @@ Real-time AML/CFT transaction monitoring engine. Pure Go, single binary, embedde
- **Scoring**: Weight-of-evidence (pure Go math)
- **Sanctions**: Jaro-Winkler + token-based fuzzy name matching
- **HTTP**: Hanzo Base router (net/http)
- **Auth**: X-Org-Id header (set by gateway from JWT owner claim)
- **Auth**: IAM access token — the `owner` claim of a bearer JWT the request's own brand issued (`api.IAMIdentity`); or, behind an authenticating gateway, the header it writes from that same claim (`api.TrustedProxyHeader`)
- **White-label**: brand per request Host (`pkg/brand`, mirroring HIP-0111) — one `amld` serves lux.network, hanzo.ai, zoo.ngo, pars.network
## Build & Run
@@ -92,6 +93,12 @@ pkg/
api/routes.go -- /v1/aml/* HTTP routes + SanctionsStore on Base
api/records.go -- The one place retention, token and replay are joined
api/anomaly.go -- Model state for governance + candidate scoring
api/iam.go -- Identity from an IAM token: brand of the Host pins the
issuer, `owner` claim is the tenant, JWKS with a
bounded cache
api/brand.go -- GET /v1/aml/config: the brand identity of this Host
brand/brand.go -- Brand id and request Host -> issuer + domains
(HIP-0111; the same registry as hanzoai/cloud brand)
```
## Behavioural detection
@@ -171,12 +178,33 @@ scores, learns and reports what it *would* have alerted on, changing nothing.
| POST | /v1/aml/relationships/search | Art. 78 five-year lookback by party |
| POST | /v1/aml/sanctions/search | Search sanctions lists by name |
| GET | /v1/aml/health | Health check, 503 when records cannot be kept |
| GET | /v1/aml/config | Brand identity of the request's Host: brand, display name, issuer, domain |
Every route resolves its tenant through `Handler.Identity`. The deployment wires
`api.TrustedProxyHeader("X-Org-Id")`, which is sound only because the gateway
authenticates the caller, sets the header from the verified JWT owner claim,
Every route resolves its tenant through `Handler.Identity`, and `/v1/aml/config`
is the one exception: it names the issuer a caller needs in order to obtain a
token, so requiring one would be a lock whose key is behind it.
Two identities, one seam. `api.IAMIdentity(api.JWKS(ttl, stale))` takes the tenant
from the `owner` claim of a bearer token, verified against the JWKS of the issuer
belonging to the request's own Host — so a Lux token does not authenticate on a
Zoo host even where one in-cluster IAM publishes both brands' signing certificates.
`api.TrustedProxyHeader("X-Org-Id")` takes it from the header a gateway writes from
that same claim, which is sound only where the gateway authenticates the caller,
strips any client-supplied copy, and is the only route to this service.
## White-label
`pkg/brand` maps a brand id, and a request Host, to that brand's public identity:
its OIDC issuer and the domains it serves on. It is a copy of the fleet's canonical
registry (HIP-0111, `hanzoai/cloud` `brand/brand.go`) kept as a leaf, so one
`amld` answers as Lux on lux.network, as Zoo on zoo.ngo and zoo.cloud, as Hanzo on
hanzo.ai, and as Pars on pars.network — brand, console identity and trusted issuer
all from the Host. An unrecognised Host falls back to Hanzo.
Org names are unique within an issuer, not across issuers. One deployment serving
more than one brand from one record plane therefore needs its store scoped by
brand as well as by org; a deployment per brand needs nothing further.
## Records, tokenisation, and the sandbox
Three obligations, three packages, one join in `pkg/api/records.go`.
+7
View File
@@ -21,6 +21,13 @@ package api
// This is stricter than the fleet edge (hanzoai/cloud auth_identity.go), which
// trusts the union of brand issuers because one binary serves every brand's apps;
// an AML record plane answers for one brand at a time, so it pins.
//
// What the pin does not settle: an org name is unique within an issuer, not across
// issuers, and the tenant returned here is the org name. A deployment serving more
// than one brand out of ONE record plane therefore has to scope that plane by
// brand as well as by org — otherwise two brands' orgs of the same name are one
// tenant. A deployment per brand needs nothing further, and that is the shape this
// runs in today.
import (
"crypto/rsa"
+9
View File
@@ -283,6 +283,15 @@ func TestIAMIdentityRefusesEveryDoubt(t *testing.T) {
r.Header.Set("Authorization", "Basic "+valid)
return r
}(),
// Digest is exactly as long as Bearer, so this row fails only if the scheme
// is actually compared. With "Basic" alone a verifier that skipped the
// compare would still refuse — it would read one character into the token
// and reject a mangled one — and the test would pass for the wrong reason.
func() *http.Request {
r := bearing("api.hanzo.ai", "")
r.Header.Set("Authorization", "Digest "+valid)
return r
}(),
} {
if org, err := id(r); err == nil {
t.Errorf("a token outside the bearer header authenticated as %q", org)