- Status: done — box-side verifier, owner auto-create, session exchange, and the hosted unauthenticated→portal bounce. Real end-to-end (portal mints → box lands) is verified jointly with the cloud half in the on-ramp (cloud
docs/ops/e2e-onramp.md), not the inner loop. - Date: 2026-06-28
- Specs touched:
ENVIRONMENT.md(# Admin bootstrap — replaced the one-time bootstrap-secret/setupflow with the SSO handshake on hosted; the seed now carriesassertion_verification_key, notadmin_bootstrap_secret),docs/architecture.md(newinternal/assertionpackage; the/_malmo/ssobrain route).
Realizes the box half of the portal-to-box SSO handshake (issue #275), the lockstep counterpart to cloud #51 / PR #52. A hosted box's owner now reaches the box dashboard through their existing malmo.network login — no box password, no copied secret, no /setup page. This replaces the hosted admin-bootstrap-secret /setup flow shipped in C3a (#206, hosted-setup-gate.md); the appliance profile is unchanged — it has no portal, so it keeps /setup + open-on-empty-box. The two repos are merged together: a hosted box booting the new seed (which drops admin_bootstrap_secret, adds assertion_verification_key) has no working bootstrap until this lands.
- New leaf package mirroring the cloud reference (
cloud internal/assertion.Verify) byte-for-byte: a token isbase64url(claims-json) "." base64url(ed25519-sig), the signature covers the exact transmitted first-segment bytes, and verification is a singleed25519.Verifyplus an expiry check — not a JWT, so thealg-confusion /alg:nonefootgun class is structurally absent (no JWT library on the path).Verifychecks signature +exponly; issuer / box-id / replay are the handler's policy (typed sentinelsErrMalformed/ErrSignature/ErrExpiredso the handler can log the reason without leaking it).
profile.Seed:AdminBootstrapSecret→AssertionVerificationKey(the portal's Ed25519 public key, standard base64).ReadSeednow requiresbox_id+assertion_verification_key.loadHostedEnvironmentpersists the base64 key (not a hash — it's a public key) inbox_metaunderBoxMetaAssertionKey, in key → enrollment → box-id commit order (box-id stays the crash-safe marker; a key-persist failure aborts before the marker so the seed re-ingests next boot). Frozen-identity boots reload the stored key and ignore the seed, exactly as before.cmd/braindecodes the base64 key to aned25519.PublicKeyonce at startup (decodeAssertionKey, length-validated); an invalid key logs and disables SSO (nil key) rather than crashing. The decoded key is handed to the API viaSetEnvironment(prof, boxID, assertionKey)(the oldbootstrapSecretHashparam is gone).- New
storekeysBoxMetaOwnerSub/BoxMetaOwnerUserID(the SSO owner identity) and aused_assertions(jti, expires_at)single-use ledger withUseAssertionJTI(jti, exp, now)— insert-or-ErrConflict, pruning past-expiry rows on each write so the table stays bounded to the in-flight token set.
GET /_malmo/sso?token=..., registered raw on the mux (a redirect+Set-Cookie endpoint, outside the OpenAPI surface) and public to the auth middleware (the assertion is the credential). Hosted-only — appliance returns 404; an un-provisioned hosted box (no key) returns 503.- On a valid assertion it applies box-side policy (
iss == NetworkApex,box == boxID, non-emptysub/email/jti, single-usejti) — the field-presence check runs before the jti is recorded so a signed-but-emptysubcan't poison owner state nor an emptyjticonsume a slot — then resolves the owner: the first valid assertion auto-creates a passwordless PAM admin (username derived from the email local-part, random discarded password — login is only ever via this handshake) following the brain-commits-first ordering /setup uses (create row → host SetPassword/SetRole → record owner meta as the commit marker, with rollback on host failure and a deterministic-username adopt path for a partial prior create). Every later assertion enforces owner-only (submust match the recorded owner; v1 grants no other accounts) and reuses the stored admin. - It then mints the box's own host-only session (
auth.Manager.Cookiecarries noDomain, so the cookie is scoped to<box-id>.<apex>and never sent to<slug>.<box-id>.<apex>app subdomains) and 303s to the dashboard with a relativeLocation: /(the browser resolves it against the HTTPS URL the request arrived on, so no hardcoded scheme/host and a no-cert box can't be stranded on an unservable HTTPS URL). Every assertion that fails verification or box-side policy (bad signature, expiry, wrong issuer/box, replay, non-owner) returns one opaque status and auditssso.failure(new action, mirrorslogin.failure); the pre-checks before any credential evaluation (missing token → 400, un-provisioned box → 503) return without auditing, like login's empty-credential 401, so an unauthenticated probe can't append audit rows. The token is never logged; success auditssso.success. The derived username is length-capped (32) and the adopt-existing-admin recovery path re-runs the idempotent hostSetPassword/SetRoleso an adopted owner always has a backing PAM account.
- On hosted,
POST /setupis disabled (403, audited) — the owner bootstraps via SSO, and an open/setupwould be a second unauthenticated path to the founding admin.gateBootstrapand thebootstrap_secretbody field are removed. Appliance/setupis byte-for-byte unchanged.
caddy.EnsureDashboard: the brain leg now matches/api/*and/_malmo/*(so the SSO landing reaches the brain; everything else is still the SPA).- web-ui: on the hosted profile there is no login or setup page — an unauthenticated visitor is bounced to
https://malmo.network(auth.tsredirectToPortal, driven by awatchEffectinApp.vuethat fires on first boot and on any later 401).AdminStep.vueloses the hosted bootstrap-secret field + link-prefill (dead on hosted; the step is appliance-only). The wizard's later steps (time zone / telemetry / done) still run on hosted after SSO, since the admin already exists (Setup.vue skips the admin step).
internal/assertion: round-trip, tamper, wrong-key, expiry, malformed.internal/api/sso_test.go: valid→owner-create+host-only session+303; second owner assertion reuses the admin; non-owner 403; tampered/wrong-key 401; expired 401; wrong-box/wrong-issuer 403; replay 401; un-provisioned 503; appliance 404.internal/store:UseAssertionJTIsingle-use + prune.cmd/brain:loadHostedEnvironmentingest/frozen/abort matrix retargeted to the assertion key;decodeAssertionKeyvalid/invalid/wrong-length.auth_hosted_test.go: hosted/setup403+audited; appliance/setupopen + omits box_id.make test-nopam,gofmt,go vet,make openapi-check, andweb-uivue-tsc+vite buildall green.
- Joint live run with the cloud half (cloud #52,
docs/ops/e2e-onramp.md): owner logs in once atmalmo.network, clicks into the box, lands on the box dashboard. Must merge in lockstep with the cloud deploy — a hosted box on the new seed has no bootstrap until both sides ship. - Deferred (tracked in cloud
NEXT.md): granting box access to othermalmo.networkaccounts (v1 is owner-only), portal-outage break-glass offline admin, signing-key rotation (thekidclaim reserves it; the box trusts the one seed-carried key).