Carry the tenant SPIFFE SAN in enrollment CSRs; stop the TLS downgrade (WDY-2498, WDY-2799) - #1855
Carry the tenant SPIFFE SAN in enrollment CSRs; stop the TLS downgrade (WDY-2498, WDY-2799)#1855justsem wants to merge 1 commit into
Conversation
WDY-2498: cloud relays certificate issuance to pki-core under the
"service-identity" profile with a signed grant, and refuses to sign that
grant unless the CSR carries exactly one tenant SPIFFE URI SAN equal to
the grant principal. Today's CSRs carry only urn:wendy, so grant-relayed
issuance rejects every one of them.
Cloud now puts the org's pki tenant in its own enrollment-token JWT as a
"tenant_uuid" claim (WDY-2584). The agent and CLI read it and add
spiffe://wendy.sh/tenant/<uuid>/service/asset-<id> (or /service/user-<id>)
to the CSR. Two constraints from pki-core shape this:
- The SPIFFE SAN is carried *alongside* the urn:wendy SAN, not instead
of it. The urn is what the agent's own org gate reads out of a peer
certificate; a SPIFFE principal names a tenant UUID, not the int32
org the gate compares, so replacing it would leave device certs with
no parseable org identity and silently disarm org-equality
enforcement across the fleet.
- The CSR stays URI-SAN-only. "service-identity" is the one profile
that consults the tenant domain allow-list, so any dNSName fails the
mint with ErrDNSSANNotAuthorized.
tenant_uuid is optional: cloud omits it for organizations with no pki
tenant, the normal state for the local and GCP CAS backends. Absence
means "build the CSR as before" and is never an error, so those
deployments keep enrolling unchanged.
GenerateCSR now takes a slice of identity URIs rather than a single URN,
which is what lets one CSR carry both SANs. All four token-derived call
sites go through it: the agent's StartProvisioning, `wendy auth login`,
`wendy auth login --api-key`, and `wendy os provision`. The certificate
*refresh* path is untouched — it derives identity from the stored cert
rather than a token, so it has no tenant claim to read.
WDY-2799, folded in as it is the same code: the enrollment dial no longer
picks its transport by port heuristic. ":443 means TLS, anything else
means plaintext" meant a cloudHost written without a port became
<host>:50051 and shipped the enrollment token -- a bearer credential --
in cleartext to a public host. TLS is now the default for every address,
a port-less host resolves to :443 rather than the plaintext provisioning
port, and a downgrade requires WENDY_CLOUD_INSECURE to be explicitly set.
Enrolling against a local plaintext pki-core still works by naming its
port and setting that variable, and the agent logs a warning naming the
host whenever it does.
Tests: the CSR assertions parse the CSR the fake cloud actually received
and check both SAN sets plus the absence of DNS SANs; the transport guard
points the real dialer at a plaintext server on a non-443 port and
asserts the RPC only succeeds once the opt-out is set. Both were
confirmed to fail against the pre-fix code.
AI Docs ReviewNote Automated docs coverage suggestions from Claude. Apply, adapt, or dismiss as needed. Claude found docs coverage suggestions for this PR. 🛑 Error clients/wendy-cli/commands/device/enroll.md and provision.md document only the urn:wendy SAN
Details## enroll.md — suggested diff
```diff
-› **Certificate identity:** The CSR submitted during provisioning includes the
-› device's authoritative Wendy identity as a URI Subject Alternative Name
-› (`urn:wendy:org:‹org›:asset:‹assetID›`). The cloud certificate service
-› validates this SAN against the enrollment token at issuance time.
+› **Certificate identity:** The CSR submitted during provisioning always
+› includes the device's authoritative Wendy identity as a URI Subject
+› Alternative Name (`urn:wendy:org:‹org›:asset:‹assetID›`). When the
+› enrollment token includes a `tenant_uuid` claim, a second URI SAN
+› (`spiffe://wendy.sh/tenant/‹uuid›/service/asset-‹assetID›`) is added;
+› cloud requires this SAN to sign relay grants. Orgs without a pki tenant
+› receive only the urn:wendy SAN, as before.
```
## provision.md — same change
Apply the identical replacement in the callout at the top of `clients/wendy-cli/commands/device/provision.md`.🛑 Error pki/README.md does not document the WENDY_CLOUD_INSECURE opt-out or the TLS-default change
Details## Suggested addition to `pki/README.md`
In the **Local development with pki-core** → **Provision a device** sub-section, add a note after the `wendy device provision` example:
```diff
+› **Plaintext dial (local pki-core only):** The enrollment dial is TLS by
+› default for every address. When pointing at a local pki-core that serves
+› plaintext gRPC (the default `pkicore serve wendy --dev` configuration),
+› set `WENDY_CLOUD_INSECURE=1` before running `wendy device provision` or
+› `wendy auth login-local`. The agent logs a warning naming the host whenever
+› this variable is set, because the enrollment token — a bearer credential —
+› is then sent in cleartext. Never set this variable on a real device.
+›
+› A port-less hostname now resolves to `:443` (previously `:50051`). Local
+› pki-core addresses must include an explicit port, e.g. `192.168.0.102:50051`.
```
Also update the existing note about the end-to-end test helper to reflect the `nil` rather than `""` signature change (minor, but keeps the note accurate):
```diff
-› **Note:** The end-to-end test helper `go run ./cmd/local-pki-test` passes an
-› empty identity URN, so the CSR it generates has no URI SAN.
+› **Note:** The end-to-end test helper `go run ./cmd/local-pki-test` passes
+› `nil` for identity URIs, so the CSR it generates has no URI SAN.
```🛑 Error pki/README.md omits tenant SPIFFE URI SAN added to enrollment CSRs
Details## Suggested diff for `pki/README.md`
In the **Certificate identity and URI SAN** section, extend the table and the surrounding explanation:
```diff
## Certificate identity and URI SAN
Every CSR generated by the CLI and agent now carries the authoritative Wendy
identity as a **URI Subject Alternative Name** in addition to the legacy
Subject CommonName:
| Entity | URI SAN format |
|--------|---------------|
| User (CLI) | `urn:wendy:org:‹orgID›:user:‹userID›` |
| Device / agent | `urn:wendy:org:‹orgID›:asset:‹assetID›` |
+When the enrollment token includes a `tenant_uuid` claim, a second URI SAN is
+added alongside the urn:wendy SAN:
+
+| Entity | Tenant SPIFFE URI SAN format |
+|--------|------------------------------|
+| Device / agent | `spiffe://wendy.sh/tenant/‹uuid›/service/asset-‹assetID›` |
+| User (CLI) | `spiffe://wendy.sh/tenant/‹uuid›/service/user-‹userID›` |
+
+Cloud refuses to sign a relay grant unless the CSR carries exactly this
+SPIFFE principal. The urn:wendy SAN is retained — it is what the agent's
+org-equality gate reads from peer certificates, and removing it would
+silently disable mTLS org enforcement. Only URI SANs are emitted; pki-core's
+`service-identity` profile rejects CSRs that carry any DNS SAN.
+
+Orgs with no pki tenant receive no `tenant_uuid` claim and enroll exactly as
+before — the absence of the claim is never an error.
```
|
AI Security ReviewNote Automated security review from Claude. Apply, adapt, silence with Input coverage: 11/11 changed files; 36,225/36,225 bytes reviewed; diff SHA-256 Claude found security review findings for this PR.
|
Agent + CLI half of the enrollment chain. Cloud's half (the
tenant_uuidclaim) is being built in parallel — the wire contract is pinned on WDY-2584.WDY-2498 — tenant SPIFFE SAN
Cloud relays issuance to pki-core under the
service-identityprofile with a signed grant, and refuses to sign that grant unless the CSR carries exactly one tenant SPIFFE URI SAN equal to the grant principal (FabricRelayCertBackend.swift:119-125). Our CSRs carry onlyurn:wendy, so every one is rejected.The agent and CLI now read the
tenant_uuidclaim from cloud's enrollment token and addspiffe://wendy.sh/tenant/<uuid>/service/asset-<id>(or/service/user-<id>).Two constraints from pki-core shaped this:
urn:wendySAN, not instead of it. A SPIFFE principal names a tenant UUID, not the int32 org thatcerts.IdentityFromCertcompares. Replacing the urn would leave device certs with no parseable org identity, andmain.go:623-632then degrades the mTLS gate toOrgModeOff— silently disarming org-equality enforcement fleet-wide.service-identityis the one profile that consults the tenant domain allow-list, so anydNSNamefails the mint withErrDNSSANNotAuthorized. There is a test asserting the CSR carries none.tenant_uuidis optional — cloud omits it for orgs with no pki tenant (normal for the local and GCP CAS backends). Absence means "build the CSR as before" and is never an error, so those deployments are unaffected.GenerateCSRnow takes[]stringof identity URIs instead of a single URN, which is what lets one CSR carry both. All four token-derived call sites route through it: the agent'sStartProvisioning,wendy auth login,wendy auth login --api-key, andwendy os provision. The certificate refresh path is deliberately untouched — it derives identity from the stored cert, not a token, so there is no tenant claim to read.WDY-2799 — enrollment TLS downgrade (security)
Folded in per dispatch; it is the same function. The dial picked its transport by port heuristic —
:443meant TLS, anything else meant plaintext — so acloudHostwritten without a port became<host>:50051and shipped the enrollment token, a bearer credential, in cleartext to a public host.TLS is now the default for every address, a port-less host resolves to
:443, and a downgrade requiresWENDY_CLOUD_INSECURE. Local plaintext pki-core still works by naming its port and setting that variable; the agent logs a warning naming the host when it does.Testing
go build ./...,go vet ./go/...,go test ./go/...all clean exceptinternal/agent/ociGPU entitlement failures, which fail identically on cleanmain(verified by stashing) — environment-dependent, unrelated to this change.Both new guards were confirmed to fail against the pre-fix code, not just pass against the new:
DefaultCloudDialerat a plaintext server on a non-443 port and asserts the RPC only succeeds once the opt-out is set.Notes for review
go/internal/cli/commands/auth.go, whichfeat/cli-oidc-loginalso modifies heavily.enrollmentTokenIdentitychanged signature there. Worth sequencing with that branch.h2) is now fixed and re-verified live —api.dev.wendy.sh:443negotiatesh2, so both the CLI and the device can reachIssueCertificate. The remaining legs are cloud'stenant_uuidPR and WDY-2804's dev-data repair (noorganizationsrow maps the acme realm's tenant, so the claim would be omitted on dev regardless).