oficina / home Public

Connect credential and directory sign-in through assertions

Paul Campbell pushed by paul@keymail.dev d1526e4fd7f74b6d56dccd1ab7295ee296cff382 parent 3c550c3
22 files changed, +836 −65
  • AGENTS.md +3 −2
  • cmd/home/main.go +3 −9
  • docs/superpowers/specs/2026-08-30-home-design.md +17 −7
  • go.mod +3 −0
  • go.sum +4 −4
  • implementation/team-entry.md +4 −2
  • internal/home/app.go +14 −3
  • internal/home/directory.go +89 −0
  • internal/home/exchange.go +105 −0
  • internal/home/exchange_test.go +290 −0
  • internal/home/noscript_test.go +2 −1
  • internal/home/origins.go +4 −0
  • internal/home/passkey.go +1 −1
  • internal/home/passkey_test.go +8 −32
  • internal/home/runtime.go +151 −0
  • internal/home/runtime_test.go +95 −0
  • internal/home/security.go +19 −3
  • internal/home/signin_return.go +1 −1
  • internal/home/static/assertion.js +2 −0
  • internal/home/templates/assertion.html +10 −0
  • internal/home/templates/directory.html +6 −0
  • internal/home/templates/directory_signed_out.html +5 −0

CI passed — run details

diff --git a/AGENTS.md b/AGENTS.md
index 2c39dd8..f4bb583 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -308,7 +308,7 @@ quietly ignored).
copy of that test. There is no vendored `static/` in the
tree today — `internal/home/page.go` links no stylesheet, deliberately —
so this is an instruction with no object until the first asset arrives.
-- Zero JS, **with exactly one carve-out** (ruling 21): the passkey
+- Zero-JS baseline, with named exceptions (rulings21 and24): the passkey
enrolment and ceremony screens **may load script**. rastrillo ships the
hard half — `webauthn.JS()` embeds `js/webauthn.mjs` — but that module
only exports `navigator.credentials` wrappers and makes no network call,
@@ -317,7 +317,8 @@ quietly ignored).
never inline `<script>`**.
That sentence used to read "no surface here has any reason to require
script, and the day one does is the day the spec is wrong" — and the day
- came, with passkeys. It was a tripwire and it fired correctly. Everything
+ came, with passkeys. It was a tripwire and it fired correctly. The assertion handoff alone also loads its static auto-submit enhancement
+ (ruling24), retaining a visible Continue button when scripts are off. Everything
else stays scriptless: magic-link sign-in, recovery-code redemption and
the springboard, so script-off keeps a working sign-in and loses only an
optional second factor. The no-`<script>` test carries an **explicit
diff --git a/cmd/home/main.go b/cmd/home/main.go
index de742d6..3990902 100644
--- a/cmd/home/main.go
+++ b/cmd/home/main.go
@@ -69,7 +69,7 @@ func main() {
// emailed. The database path cannot be empty on this path — it is
// set below — but LoadSecrets checks it anyway, for the caller that
// builds its own Options.
- secrets, err := home.LoadSecrets(opts.DBPath, logger)
+ runtime, err := home.LoadRuntime(opts.DBPath, logger)
if err != nil {
logger.Error("config", "err", err)
os.Exit(1)
@@ -90,18 +90,12 @@ func main() {
// Ledgered, once at boot, forward-only. A boot that cannot reconcile
// its schema refuses rather than guessing, same as a failed open.
- if _, err := migrate.Apply(context.Background(), d, home.BootSchema); err != nil {
+ if _, err := migrate.Apply(context.Background(), d, runtime.Schema()); err != nil {
logger.Error("migrate", "err", err)
os.Exit(1)
}
- origins, err := home.LoadOrigins()
- if err != nil {
- logger.Error("config", "err", err)
- os.Exit(1)
- }
-
- app, err := home.NewWithOrigins(secrets, d, logger, origins)
+ app, err := home.NewRuntime(runtime, d, logger)
if err != nil {
logger.Error("wire app", "err", err)
os.Exit(1)
diff --git a/docs/superpowers/specs/2026-08-30-home-design.md b/docs/superpowers/specs/2026-08-30-home-design.md
index 7119ace..bd7b00f 100644
--- a/docs/superpowers/specs/2026-08-30-home-design.md
+++ b/docs/superpowers/specs/2026-08-30-home-design.md
@@ -18,6 +18,7 @@ reviewed by Fable across four passes
(2026-09-05) — three returning CHANGES NEEDED, the fourth approving; the
`?team=` decision took a second opinion from GPT-6 Astra via Codex.**
Source claims re-verified by this session before acting.
+**2026-09-08 transport amendment:** bounded design approved by Claude Fable 5.1 at Home36f876c; shared consumer3c550c3 and Rastrillo assertion29e4491 passed independent code review and CI. Home mounting still requires code and canary review.
**Scope:** the suite's home — identity, directory, provisioning, person
preferences. Consumed by Sheets and Docs. Not an application; holds no
documents.
@@ -135,6 +136,7 @@ blind, replaceable*.
| 21 | **§11's zero-JS rule gains one named carve-out: passkey enrolment and ceremony screens may load script. Everything else stays scriptless** — §11 | Passkeys leaving slice one; a blanket exemption; a progressive-enhancement version, since the ceremony itself cannot happen without script either way | Paul, 2026-09-05 |
| 22 | **The team selection is navigation state, not session state: `?team=<sqid>` on every team view, with `/` a generic entry that picks a team and `302`s to the explicit URL** — §11 | A session cookie (history does not restore one, so Back has no correct behaviour; and a shared cookie couples every same-origin team-dependent request across tabs, not just New); a URL fragment read by CSS `:target` (the server never learns the team, so nothing server-rendered can be team-scoped — not the grid, not the switcher's `aria-current`, not gate 4's 404); a `prefs` row (§14) | Paul, 2026-09-05, after a second opinion from **GPT-6 Astra via Codex** (its default; `gpt-5.3-codex` and `gpt-5.3-sol` were both refused by the account, so the model is named from Codex's own rollout record rather than from what was asked for) |
| 23 | **The suite sharing doctrine (`oficina/suite`) binds this spec, and membership becomes a record of the home's own.** The team is the boundary; the home owns identity, the team and the membership roster; it never owns a per-item grant. `(team, person, role)` is written only at the directory origin by an owner behind step-up, or seeded by the drain with the team's first owner. A directory row never grants team standing. Call 8, roster pull, is added to §9. | **The status quo this overturns: team standing living only in each instance's grants, with the home holding no roster at all** (§5.5, §7 as they stood). And the doctrine's own first revision, which derived the roster from call-2 directory rows: the row has no owner role (§6 — `kind` is member or guest), §7 says the home never learns who an owner is, the row is scoped per (team, app) where the roster is per team, and any instance can write one under its consumer token — so deriving from it routes around the step-up entirely | Paul, 2026-09-05 (suite ruling 12). Numbered 23 because the doctrine's appendix said "add ruling 22" and 22 was already taken; a review of the doctrine caught the collision |
+| 24 | **Automatic app entry uses the shared P-256 assertion core, separate credential/directory processes, and a static auto-POST enhancement on the handoff screen.** Exact contract: implementation/team-entry.md at36f876c, with the reviewed consumer implementation at3c550c3. The Continue button works without script; no other credential screen gains script. | A second app-specific verifier; shared-domain cookies; an arbitrary return URL; requiring a manual handoff click when scripts are enabled | Codex implementation decision, 2026-09-08, under Paul's explicit approval in this session of Home sign-in and automatic return to the selected team app. Reviewed by Claude Fable 5.1; not attributed to the older ruling21. |
---
@@ -447,6 +449,8 @@ are not.
## 4. Two origins
+The 2026-09-08 implementation runs explicit credential and directory roles in separate processes and databases, matching the paired preview topology. Each role refuses the other hostname. The directory loads its sealing key and pinned verification keys only; a private assertion key in its configuration is refused. Both databases need their own privacy checks and restore drill before production.
+
A team name is account-authored, so a home rendering names on its RP origin
is what `decisions.md` warns against. Tito's answer: *credentials only ever
on the root origin, account-authored content only on the account's own host.*
@@ -579,16 +583,18 @@ the contract. Two consumers were about to guess.
| Claim | Value |
|---|---|
| `sub` | The **person ref** — the home's opaque identifier, stable across email change. Never an address. |
-| `aud` | `app` + **instance host**. The directory origin has its own audience form. |
+| `aud` | The exact canonical HTTPS consumer origin, identifying the app and team instance. The directory uses its own exact origin. |
| `iss` | The credential origin. |
-| `kid` | Which verification key signed it. Two are active during rotation; a verifier caches by `kid` and refetches once on an unknown one, then fails closed. |
+| `kid` | Which pinned verification key signed it. The initial transport performs no fetch on an unknown key and fails closed. Deploy old and new public keys before rotating the signer. |
| `iat` / `exp` | See expiry below. |
| `jti` | Single use, per verifier. |
-| `prefs` | Theme only (§8), subject to §5.4's test. |
-| `roster_epoch` | The team's roster epoch (ruling 23). **An invalidation hint, not a decision** — §5.4 records the narrow exception it needs, and the instance enforces call 8's answer, never this. |
+| `v` | Wire version1. |
+| `nonce` | The consumer's browser-bound request nonce, independent from jti. |
+| `auth_time` | Original credential authentication time; never refreshed by the handoff. |
+| `ext` | Optional cosmetic object. The initial Home transport omits it; later theme delivery remains subject to §5.4. |
+| Future roster hint | Not emitted by the initial transport. Ruling23's invalidation-only contract in §5.4 remains for the separately reviewed roster adapter; it is not an unimplemented claim silently added to wire version1. |
-**Signature:** Ed25519. Private key in platform config, never in the
-database. Verification keys published on the credential origin.
+**Signature:** P-256 through the shared Rastrillo assertion package (ruling24). The private key stays in credential-only platform config, never in either database. Initial isolated consumers pin public keys. Published-key discovery and production consumer registration remain later, separately reviewed changes.
**Expiry is 120 seconds, not "seconds"** — sized to worst-case cold wake
plus clock skew. The exchange request *is* what wakes a hibernating
@@ -1027,6 +1033,8 @@ ceremony.
## 10. Data model
+The explicit directory role has its own sessions table and the shared handoff module's pending, inbox, replay and clock-fence tables. Paths and tokens are sealed before insertion; only hashes identify browser handles, nonces and assertion IDs. The credential database retains people, sealed sign-in links and passkey state. This split does not make the directory authoritative for any instance item.
+
- `people` — person ref, blind index, created_at. **No address column.**
- `credentials`, `recovery_codes` — keyed on person ref (§5.1).
- `signin_links` — sealed address, expiry. `secure_delete` on.
@@ -1114,6 +1122,8 @@ restored is a hope, not a backup* — the restore drill runs before launch.
## 11. Shape and design
+Ruling24 adds exactly one progressive enhancement: assertion.html may load /static/assertion.js to submit its registered-destination form. The visible Continue button remains usable with scripts off. This extends the earlier passkey-only allowlist without a blanket script exemption. Credential form-action is the process-wide exact consumer manifest; markup injection could post to any registered origin, which is why the credential origin must render no account-authored markup.
+
rastrillo `server` shape, zero-JS baseline, `day` theme, and the
**attribute** vocabulary — `rst-btn`, not `class="rst-btn"` (ruling 20).
@@ -1370,7 +1380,7 @@ No template gallery, no upsell chrome, no waffle.
credential origin. §4 made mechanical.
6. **Scriptless-affordance diff** — every page, script-stripped. **For the
ruling-21 screens: every affordance except the passkey ceremony button
- itself, and the allowlist is named explicitly in the test.** Exempting
+ itself, and the allowlist is named explicitly in the test. The ruling24 handoff keeps its Continue button with scripts off.** Exempting
the whole screen would be wrong rather than merely loose — the
confirm page carries the ceremony *and* the recovery-code form, and
`passkey.SignInRecovery` is a plain form POST precisely "because
diff --git a/go.mod b/go.mod
index 16b4c93..5cfbf05 100644
--- a/go.mod
+++ b/go.mod
@@ -4,8 +4,11 @@ go 1.25.0
require (
amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335
+ amadan.net/oficina/home/handoff v0.0.0-20260908081549-3c550c3491db
+ amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
github.com/carlosframework/rastrillo v0.23.0
github.com/go-chi/chi/v5 v5.3.2
+ github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2
github.com/keymaildev/signin v0.1.1
golang.org/x/crypto v0.55.0
gorm.io/plugin/dbresolver v1.6.2
diff --git a/go.sum b/go.sum
index 43597ac..4946db0 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,9 @@
-amadan.net/oficina/estilo.git v0.0.0-20260907201639-532746acdbc1 h1:9Qrzr6xT0QuaTAK4Bq3M8nWDCgkBmXy7H9BR3C+DZOo=
-amadan.net/oficina/estilo.git v0.0.0-20260907201639-532746acdbc1/go.mod h1:AZvUebwQkfBh2d0TIOAO/AViIIsRx774jL7ic8XOckg=
amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335 h1:YogZKYINrPNXRfPedXxOIDbGCMY4ff7yAdohYphEWMg=
amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY=
-github.com/carlosframework/rastrillo v0.22.0 h1:tG02WAiy2VSYqmLT6kRfYbUaiJd/pBhTIWxxjjsEoI8=
-github.com/carlosframework/rastrillo v0.22.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
+amadan.net/oficina/home/handoff v0.0.0-20260908081549-3c550c3491db h1:3aN3BlNC03KJuFEk/C7EFEprt0Csto/LFjdwYeDv0FM=
+amadan.net/oficina/home/handoff v0.0.0-20260908081549-3c550c3491db/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k=
+amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc=
+amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE=
github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w=
github.com/carlosframework/rastrillo v0.23.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
diff --git a/implementation/team-entry.md b/implementation/team-entry.md
index 9ccfe9e..4924ead 100644
--- a/implementation/team-entry.md
+++ b/implementation/team-entry.md
@@ -21,7 +21,7 @@ The new rastrillo assertion package signs and verifies the opaque person_ref, ex
5. A callback accepts only POST application/x-www-form-urlencoded with a bounded 12 KiB body, exactly one assertion field and the exact configured issuer Origin. Mount this route outside ordinary same-origin CSRF middleware only; all other mutations retain that middleware. Reject a foreign Origin even when its Sec-Fetch-Site says same-site. No CORS is added.
6. SameSite=Lax cookies may be absent on a cross-site POST, so the callback must not depend on receiving them. Verify the assertion, write it sealed into a two-minute consumer inbox row keyed by the hash of a fresh random 32-byte handle, set that handle in a __Host- handoff cookie with Secure, HttpOnly, Path=/, SameSite=Lax and no Domain, and 303 to the consumer's fixed GET /auth/finish. No session is minted yet. Prune expired inbox/pending rows on each write and boot; cap each table at 10,000 live rows. Evict the oldest pending row to admit a new start when full; this discards only unauthenticated navigation intent and requires that browser to restart. Refuse callbacks if the inbox is full. Tokens stay in sealed storage, never in a cookie large enough to hit browser size limits. Inbox has a unique (issuer, jti-hash) index, so replaying a callback cannot allocate another row. That top-level GET carries the consumer's pending cookie. Read and clear the handoff cookie, load its sealed inbox row by handle hash, reverify the assertion against the current clock, compare the signed nonce with the pending request using constant-time equality, and compare the browser secret hash. An attacker may overwrite a handoff cookie only to cause a failed login: a different browser's pending state never matches.
7. Atomically delete the live pending request and its inbox row and insert (issuer, jti, expiry) into the replay table before issuing a session. Duplicate/concurrent submission has one winner. On a storage failure, refuse. A later session-write failure consumes the assertion; the user starts again. Never roll back replay consumption after a session cookie may have been sent. Existing identity is not silently switched by a handoff: an already signed-in consumer with a different subject refuses and requires explicit sign-out first.
-8. Replay pruning requires a durable clock fence. Track the maximum observed wall time and maximum accepted assertion expiry in the same transaction. Refuse exchanges after clock rollback more than 30 seconds below that wall-time fence; do not prune or mint while fenced. Retain replay rows through expiry plus 30 seconds so a tolerated backward clock adjustment cannot resurrect a pruned token. On normal operation, delete entries whose expiry plus 30 seconds precedes the stored wall-time fence, after advancing that fence transactionally. All comparisons are overflow-safe. A process restart uses the stored fence. A restored consumer database requires draining and an explicit reset of assertion trust/pending state before accepting new traffic; deployment must not claim arbitrary snapshot rollback is replay safe.
+8. Replay pruning requires a durable clock fence. Track the maximum observed wall time; accepted expiry remains on each replay row in the same transaction. Refuse exchanges after clock rollback more than 30 seconds below that wall-time fence; do not prune or mint while fenced. Retain replay rows through expiry plus 30 seconds so a tolerated backward clock adjustment cannot resurrect a pruned token. On normal operation, delete entries whose expiry plus 30 seconds precedes the stored wall-time fence, after advancing that fence transactionally. All comparisons are overflow-safe. A process restart uses the stored fence. A restored consumer database requires draining and an explicit reset of assertion trust/pending state before accepting new traffic; deployment must not claim arbitrary snapshot rollback is replay safe.
9. Issue a separate consumer session with the verified subject and original AuthTime, then redirect to the stored local path. The directory session proves identity only. Every team choice and app request still needs membership or an existing individual grant.
## Directory and Docs stages
@@ -51,4 +51,6 @@ Reuse the consumer exchange across Home and Docs rather than implementing two su
Shared consumer implementation is a nested module at handoff/, module amadan.net/oficina/home/handoff, with a minimal dependency graph. The actual hub now resolves go-import for both amadan.net/oficina/home and amadan.net/rastrillo/rastrillo: go list -m successfully fetched Home36f876c and Rastrillo29e4491 on 2026-09-08. This corrects the review's stale discovery assumption. A nested module remains necessary to avoid importing the Home binary's unrelated dependency requirements. Verify remote fetching of the nested module from a frozen pushed commit before any app pins it.
-The pure core29e4491 passed full local and actual CI and Fable whole-code/docs review, and merged on Rastrillo main997935b; mirror fast-forward verified. The standalone consumer first adds sealed pending/inbox/replay storage and HTTP handlers with tests; it is not yet mounted in Home. Before mounting, amend the Home spec, credential script allowlist and privacy gates as listed above. No deployment is claimed for these library changes.
+The pure core29e4491 passed full local and actual CI and Fable whole-code/docs review, and merged on Rastrillo main997935b; mirror fast-forward verified. The standalone consumer adds sealed pending/inbox/replay storage and HTTP handlers with tests. Its source3c550c3 is approved and remotely fetchable; the Home mounting work consumes that exact version. Before mounting, amend the Home spec, credential script allowlist and privacy gates as listed above. No deployment is claimed for these library changes.
+
+Home mounting now adds explicit role loaders, issuer intent and signed handoff, directory session callbacks, narrow script allowance and manifest CSP. Integration tests cover both databases, opaque subject, preserved authentication time, fresh email continuation and recovery second factor. Deployment and independent Home code review remain pending.
diff --git a/internal/home/app.go b/internal/home/app.go
index 318f119..1cdcf77 100644
--- a/internal/home/app.go
+++ b/internal/home/app.go
@@ -9,9 +9,9 @@
// codes, sealed link state, the person ref and the blind index. The
// front page is still a placeholder (page.go).
//
-// Not built: the directory and its calls, assertion minting and the
-// redirect protocol, provisioning, the rekey journal. Those are §13
-// steps 3 onwards.
+// Explicit credential and directory roles now exchange browser-bound assertions.
+// Team selection, roster calls, provisioning and the rekey journal remain later
+// stages; a directory session establishes identity only.
package home
import (
@@ -23,6 +23,7 @@ import (
"time"
"amadan.net/oficina/estilo.git/theme"
+ "amadan.net/oficina/home/handoff"
"github.com/carlosframework/rastrillo/csrf"
"github.com/carlosframework/rastrillo/db"
"github.com/carlosframework/rastrillo/passkey"
@@ -37,6 +38,8 @@ import (
// is sealed on the way in, and the database handle because that is what
// it is sealed into. Nothing here reaches for a raw key.
type App struct {
+ runtime *Runtime
+ consumer *handoff.Consumer
origins Origins
credentialHost string
directoryHost string
@@ -143,6 +146,9 @@ func NewWithOrigins(secrets Secrets, d *db.DB, logger *slog.Logger, origins Orig
// Router returns the mounted handler.
func (a *App) Router() http.Handler {
+ if a.runtime != nil && a.runtime.role == "directory" {
+ return a.directoryRouter()
+ }
r := chi.NewRouter()
r.Use(a.serveKnownHost)
@@ -174,6 +180,11 @@ func (a *App) Router() http.Handler {
// evidence (Sec-Fetch-Site, then Origin, then Referer) that never
// consults a session, so it works before anybody is signed in.
r.Use(csrf.Protect(a.origins.Credential))
+ if a.runtime != nil && a.runtime.role == "credential" {
+ r.Get("/authorize", a.handleAuthorize)
+ r.With(a.sessions.Require).Get("/authorize/resume", a.handleAssertion)
+ r.Get("/static/assertion.js", serveAsset(staticAsset("assertion.js"), "text/javascript; charset=utf-8"))
+ }
r.Get("/signin", a.handleSigninForm)
r.Post("/signin", a.handleSigninBegin)
r.Get("/signin/sent", a.handleSigninSent)
diff --git a/internal/home/directory.go b/internal/home/directory.go
new file mode 100644
index 0000000..6a8c854
--- /dev/null
+++ b/internal/home/directory.go
@@ -0,0 +1,89 @@
+package home
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+
+ "amadan.net/oficina/estilo.git/theme"
+ "amadan.net/oficina/home/handoff"
+ "amadan.net/rastrillo/rastrillo/assertion"
+ "github.com/carlosframework/rastrillo/csrf"
+ "github.com/carlosframework/rastrillo/db"
+ "github.com/carlosframework/rastrillo/sessions"
+ "github.com/go-chi/chi/v5"
+)
+
+func newDirectory(cfg Runtime, d *db.DB, logger *slog.Logger) (*App, error) {
+ if cfg.origins.validate() != nil || cfg.secrets.Sealer == nil || d == nil || cfg.signer != nil {
+ return nil, errExchangeConfig
+ }
+ if logger == nil {
+ logger = slog.Default()
+ }
+ renderer, err := newRenderer()
+ if err != nil {
+ return nil, err
+ }
+ sess, err := sessions.New(sessions.Config{DB: d.Writer(), Origin: cfg.origins.Directory, TTL: 30 * 24 * time.Hour, SigninPath: handoff.StartPath, Logger: logger})
+ if err != nil {
+ return nil, err
+ }
+ store, err := handoff.NewStore(d.Writer(), cfg.secrets.Sealer, cfg.origins.Credential, cfg.origins.Directory, cfg.keys)
+ if err != nil {
+ return nil, err
+ }
+ if err = store.Sweep(context.Background(), time.Now()); err != nil {
+ return nil, err
+ }
+ a := &App{runtime: &cfg, origins: cfg.origins, credentialHost: strings.TrimPrefix(cfg.origins.Credential, "https://"), directoryHost: strings.TrimPrefix(cfg.origins.Directory, "https://"), secrets: cfg.secrets, db: d, logger: logger, renderer: renderer, sessions: sess}
+ a.consumer, err = handoff.NewConsumer(handoff.ConsumerConfig{Store: store, ConsumerID: "directory", CurrentSubject: func(r *http.Request) string {
+ s, ok := sessions.Current(r)
+ if !ok {
+ return ""
+ }
+ return s.Subject
+ }, Complete: func(w http.ResponseWriter, r *http.Request, c assertion.Claims) error {
+ return a.sessions.SignIn(w, r, sessions.Session{Subject: c.Subject, Method: "assertion", AuthTime: time.Unix(c.AuthTime, 0)})
+ }, Fail: func(w http.ResponseWriter, r *http.Request, status int) {
+ a.render(w, status, "signin_failed.html", map[string]any{})
+ }})
+ if err != nil {
+ return nil, err
+ }
+ return a, nil
+}
+func (a *App) directoryRouter() http.Handler {
+ r := chi.NewRouter()
+ r.Use(a.serveKnownHost)
+ r.Use(a.sessions.Middleware)
+ // This callback alone accepts a cross-origin POST, with its own exact issuer
+ // check and browser-bound finish. Every other mutation retains CSRF protection.
+ r.Post(handoff.CallbackPath, a.consumer.Callback)
+ r.Group(func(r chi.Router) {
+ r.Use(csrf.Protect(a.origins.Directory))
+ r.Get(handoff.StartPath, a.consumer.Start)
+ r.Get(handoff.FinishPath, a.consumer.Finish)
+ r.Get("/signin", func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, handoff.StartPath, http.StatusSeeOther)
+ })
+ r.With(a.sessions.Require).Get("/", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store")
+ a.render(w, 200, "directory.html", map[string]any{})
+ })
+ r.Post("/signout", func(w http.ResponseWriter, r *http.Request) {
+ a.sessions.SignOut(w, r)
+ http.Redirect(w, r, "/signed-out", http.StatusSeeOther)
+ })
+ r.Get("/signed-out", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store")
+ a.render(w, 200, "directory_signed_out.html", map[string]any{})
+ })
+ r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
+ r.Get("/static/tokens.css", serveAsset(staticAsset("tokens.css"), "text/css; charset=utf-8"))
+ r.Get("/static/theme.css", serveAsset(theme.CSS(), "text/css; charset=utf-8"))
+ })
+ return r
+}
diff --git a/internal/home/exchange.go b/internal/home/exchange.go
new file mode 100644
index 0000000..d75307a
--- /dev/null
+++ b/internal/home/exchange.go
@@ -0,0 +1,105 @@
+package home
+
+import (
+ "encoding/base64"
+ "net/http"
+ "net/url"
+ "time"
+
+ "amadan.net/oficina/home/handoff"
+ "amadan.net/rastrillo/rastrillo/assertion"
+ "github.com/carlosframework/rastrillo/sessions"
+ json "github.com/go-json-experiment/json"
+)
+
+const assertionIntentCookie = "__Host-oficina-assertion-intent"
+
+type assertionIntent struct {
+ Consumer string `json:"consumer"`
+ Nonce string `json:"nonce"`
+ Expires int64 `json:"expires"`
+}
+
+func (a *App) handleAuthorize(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store")
+ q, err := url.ParseQuery(r.URL.RawQuery)
+ if err != nil || len(q) != 2 || len(q["consumer"]) != 1 || len(q["nonce"]) != 1 || !handoff.ValidNonce(q.Get("nonce")) || a.runtime.consumers[q.Get("consumer")] == "" {
+ a.signinFailed(w)
+ return
+ }
+ b, err := json.Marshal(assertionIntent{q.Get("consumer"), q.Get("nonce"), time.Now().Unix() + 600})
+ if err != nil {
+ a.signinFailed(w)
+ return
+ }
+ sealed, err := a.secrets.Sealer.Seal("assertion.intent", b)
+ if err != nil {
+ a.signinFailed(w)
+ return
+ }
+ http.SetCookie(w, &http.Cookie{Name: assertionIntentCookie, Value: base64.RawURLEncoding.EncodeToString(sealed), Path: "/", MaxAge: 600, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode})
+ http.Redirect(w, r, "/authorize/resume", http.StatusSeeOther)
+}
+func (a *App) handleAssertion(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store")
+ // no-referrer would turn the cross-origin form POST's Origin into null.
+ w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
+ if r.URL.RawQuery != "" {
+ a.signinFailed(w)
+ return
+ }
+ value := ""
+ count := 0
+ for _, cookie := range r.Cookies() {
+ if cookie.Name == assertionIntentCookie {
+ count++
+ value = cookie.Value
+ }
+ }
+ if count != 1 || len(value) > 2048 {
+ a.signinFailed(w)
+ return
+ }
+ sealed, err := base64.RawURLEncoding.Strict().DecodeString(value)
+ if err != nil || base64.RawURLEncoding.EncodeToString(sealed) != value {
+ a.signinFailed(w)
+ return
+ }
+ plain, err := a.secrets.Sealer.Open("assertion.intent", sealed)
+ if err != nil {
+ a.signinFailed(w)
+ return
+ }
+ var intent assertionIntent
+ now := time.Now()
+ if json.Unmarshal(plain, &intent, json.RejectUnknownMembers(true)) != nil || intent.Expires <= now.Unix() || intent.Expires > now.Unix()+600 || !handoff.ValidNonce(intent.Nonce) {
+ a.signinFailed(w)
+ return
+ }
+ target, ok := a.runtime.consumers[intent.Consumer]
+ if !ok {
+ a.signinFailed(w)
+ return
+ }
+ session, ok := sessions.Current(r)
+ if !ok {
+ a.signinFailed(w)
+ return
+ }
+ authTime := session.AuthTime
+ if authTime.IsZero() {
+ authTime = session.At
+ }
+ id, err := assertion.RandomID()
+ if err != nil {
+ a.signinFailed(w)
+ return
+ }
+ token, err := assertion.Sign(a.runtime.signer, assertion.Claims{Version: 1, Issuer: a.origins.Credential, Audience: target, Subject: session.Subject, KeyID: a.runtime.keyID, ID: id, Nonce: intent.Nonce, IssuedAt: now.Unix(), ExpiresAt: now.Unix() + 120, AuthTime: authTime.Unix()})
+ if err != nil {
+ a.signinFailed(w)
+ return
+ }
+ http.SetCookie(w, &http.Cookie{Name: assertionIntentCookie, Value: "", Path: "/", MaxAge: -1, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode})
+ a.render(w, http.StatusOK, "assertion.html", map[string]any{"Action": target + handoff.CallbackPath, "Token": token})
+}
diff --git a/internal/home/exchange_test.go b/internal/home/exchange_test.go
new file mode 100644
index 0000000..dbc9b79
--- /dev/null
+++ b/internal/home/exchange_test.go
@@ -0,0 +1,290 @@
+package home
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "html"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/home/handoff"
+ "amadan.net/oficina/home/internal/home/seal"
+ "amadan.net/oficina/home/internal/home/sealtest"
+ "amadan.net/rastrillo/rastrillo/assertion"
+ assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
+ "github.com/carlosframework/rastrillo/db"
+ "github.com/carlosframework/rastrillo/migrate"
+)
+
+type exchangePair struct {
+ credential *testApp
+ directory *App
+ directoryPath string
+ logs *bytes.Buffer
+}
+
+func newExchangePair(t *testing.T) exchangePair {
+ t.Helper()
+ cred := newTestApp(t)
+ key, err := assertioncrypto.Generate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ sum := sha256.Sum256(key.SignPub())
+ kid := hex.EncodeToString(sum[:])
+ cred.runtime = &Runtime{role: "credential", origins: cred.origins, secrets: cred.secrets, signer: key, keyID: kid, keys: map[string][]byte{kid: key.SignPub()}, consumers: map[string]string{"directory": directoryOrigin, "team_docs": "https://blue.docs.oficina.works"}}
+ path := filepath.Join(t.TempDir(), "directory.db")
+ d, err := db.Open(path, quietLogger())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { d.Close() })
+ if _, err = migrate.Apply(context.Background(), d, directorySchema); err != nil {
+ t.Fatal(err)
+ }
+ dek := make([]byte, 32)
+ rand.Read(dek)
+ sealer, err := seal.New(dek)
+ if err != nil {
+ t.Fatal(err)
+ }
+ logs := new(bytes.Buffer)
+ logger := slog.New(slog.NewTextHandler(logs, nil))
+ cfg := Runtime{role: "directory", origins: cred.origins, secrets: Secrets{Sealer: sealer}, keys: cred.runtime.keys}
+ directory, err := NewRuntime(cfg, d, logger)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cred.logger = logger
+ return exchangePair{cred, directory, path, logs}
+}
+func exchangeRequest(a *App, method, target, origin string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
+ body := ""
+ if form != nil {
+ body = form.Encode()
+ }
+ r := httptest.NewRequest(method, target, strings.NewReader(body))
+ if form != nil {
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ }
+ if origin != "" {
+ r.Header.Set("Origin", origin)
+ }
+ for _, cookie := range cookies {
+ if cookie != nil {
+ r.AddCookie(cookie)
+ }
+ }
+ rec := httptest.NewRecorder()
+ a.Router().ServeHTTP(rec, r)
+ return rec
+}
+func namedCookie(t *testing.T, r *httptest.ResponseRecorder, name string) *http.Cookie {
+ t.Helper()
+ for _, cookie := range r.Result().Cookies() {
+ if cookie.Name == name && cookie.Value != "" {
+ return cookie
+ }
+ }
+ t.Fatal("missing cookie", name)
+ return nil
+}
+func (p exchangePair) start(t *testing.T) (*http.Cookie, *http.Cookie) {
+ t.Helper()
+ start := exchangeRequest(p.directory, "GET", directoryOrigin+handoff.StartPath+"?return_to=%2F%3Fteam%3Dblue", "", nil)
+ if start.Code != 303 {
+ t.Fatal("start", start.Code)
+ }
+ pending := namedCookie(t, start, "__Host-oficina-assertion-pending")
+ authorize := exchangeRequest(p.credential.App, "GET", start.Header().Get("Location"), "", nil)
+ if authorize.Code != 303 || authorize.Header().Get("Location") != "/authorize/resume" {
+ t.Fatal("authorize", authorize.Code)
+ }
+ return pending, namedCookie(t, authorize, assertionIntentCookie)
+}
+func (p exchangePair) mint(t *testing.T, intent, session *http.Cookie) (string, *httptest.ResponseRecorder) {
+ t.Helper()
+ rec := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, intent, session)
+ if rec.Code != 200 {
+ t.Fatal("mint", rec.Code)
+ }
+ m := regexp.MustCompile(`name="assertion" value="([^"]+)"`).FindStringSubmatch(rec.Body.String())
+ if len(m) != 2 {
+ t.Fatal("no assertion form")
+ }
+ token := html.UnescapeString(m[1])
+ if !strings.Contains(rec.Body.String(), `action="`+directoryOrigin+handoff.CallbackPath+`"`) || rec.Header().Get("Referrer-Policy") != "strict-origin-when-cross-origin" {
+ t.Fatal("handoff transport changed")
+ }
+ return token, rec
+}
+func (p exchangePair) finish(t *testing.T, token string, pending *http.Cookie) *http.Cookie {
+ t.Helper()
+ callback := exchangeRequest(p.directory, "POST", directoryOrigin+handoff.CallbackPath, Origin, url.Values{"assertion": {token}})
+ if callback.Code != 303 || sessionCookie(callback) != nil {
+ t.Fatal("callback minted session or refused", callback.Code)
+ }
+ inbox := namedCookie(t, callback, "__Host-oficina-assertion-inbox")
+ finish := exchangeRequest(p.directory, "GET", directoryOrigin+handoff.FinishPath, "", nil, pending, inbox)
+ if finish.Code != 303 || finish.Header().Get("Location") != "/?team=blue" || sessionCookie(finish) == nil {
+ t.Fatal("finish", finish.Code)
+ }
+ return sessionCookie(finish)
+}
+
+func TestHomeDirectoryExchangeAndPrivacy(t *testing.T) {
+ p := newExchangePair(t)
+ credentialSession := signInFully(t, p.credential, sealtest.CanaryAddress)
+ old := time.Now().Add(-time.Hour).UTC().Truncate(time.Second)
+ if _, err := p.credential.db.Writer().Exec(`UPDATE sessions SET auth_time=?`, old.Format(time.RFC3339)); err != nil {
+ t.Fatal(err)
+ }
+ pending, intent := p.start(t)
+ token, _ := p.mint(t, intent, credentialSession)
+ directorySession := p.finish(t, token, pending)
+ page := exchangeRequest(p.directory, "GET", directoryOrigin+"/", "", nil, directorySession)
+ if page.Code != 200 || !strings.Contains(page.Body.String(), "You are signed in.") {
+ t.Fatal("directory session missing")
+ }
+ var subject, authTime string
+ if err := p.directory.db.Writer().QueryRow(`SELECT subject,auth_time FROM sessions`).Scan(&subject, &authTime); err != nil {
+ t.Fatal(err)
+ }
+ if subject == "" || strings.Contains(subject, "@") || authTime != old.Format(time.RFC3339) {
+ t.Fatal("identity or freshness changed")
+ }
+ if rec := exchangeRequest(p.directory, "GET", directoryOrigin+"/", "", nil, credentialSession); rec.Code != 303 {
+ t.Fatal("credential cookie opened directory")
+ }
+ if rec := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, intent, directorySession); rec.Code != 303 {
+ t.Fatal("directory cookie opened credentials")
+ }
+ for _, path := range []string{p.credential.dbPath, p.directoryPath} {
+ sealtest.AssertNoPlaintext(t, path)
+ for _, suffix := range []string{"", "-wal"} {
+ b, err := os.ReadFile(path + suffix)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, canary := range []string{token, "/?team=blue", pending.Value, intent.Value} {
+ if bytes.Contains(b, []byte(canary)) {
+ t.Fatal("exchange plaintext at rest")
+ }
+ }
+ }
+ }
+ if strings.Contains(p.logs.String(), token) || strings.Contains(p.logs.String(), sealtest.CanaryAddress) {
+ t.Fatal("exchange leaked in logs")
+ }
+}
+func TestHomeAssertionWaitsForCompleteSignIn(t *testing.T) {
+ p := newExchangePair(t)
+ pending, intent := p.start(t)
+ resume := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, intent)
+ if resume.Code != 303 || !strings.Contains(resume.Header().Get("Location"), "return_to=") {
+ t.Fatal("missing sign-in continuation")
+ }
+ signin := exchangeRequest(p.credential.App, "GET", Origin+resume.Header().Get("Location"), "", nil, intent)
+ navigation := namedCookie(t, signin, returnCookie)
+ if navigation.Value != "/authorize/resume" {
+ t.Fatal("intent not remembered")
+ }
+ signIn(t, p.credential, sealtest.CanaryAddress)
+ redeemed := exchangeRequest(p.credential.App, "POST", Origin+linkPath, Origin, url.Values{"token": {capturedLink(t, p.credential)}}, navigation, intent)
+ if redeemed.Code != 303 || redeemed.Header().Get("Location") != "/authorize/resume" {
+ t.Fatal("sign-in lost continuation")
+ }
+ token, _ := p.mint(t, intent, sessionCookie(redeemed))
+ p.finish(t, token, pending)
+}
+func TestHomeAssertionWaitsForRecoverySecondFactor(t *testing.T) {
+ p := newExchangePair(t)
+ oldSession := signInFully(t, p.credential, sealtest.CanaryAddress)
+ enrol(t, p.credential, oldSession)
+ codes, err := p.credential.passkeys.RegenerateRecoveryCodes(subjectOf(t, p.credential, oldSession))
+ if err != nil {
+ t.Fatal(err)
+ }
+ pending, intent := p.start(t)
+ signin := exchangeRequest(p.credential.App, "GET", Origin+"/signin?return_to=/authorize/resume", "", nil, intent)
+ navigation := namedCookie(t, signin, returnCookie)
+ signIn(t, p.credential, sealtest.CanaryAddress)
+ gated := exchangeRequest(p.credential.App, "POST", Origin+linkPath, Origin, url.Values{"token": {capturedLink(t, p.credential)}}, navigation, intent)
+ if sessionCookie(gated) != nil || gated.Header().Get("Location") != confirmPath {
+ t.Fatal("first factor bypassed gate")
+ }
+ before := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, append(gated.Result().Cookies(), intent)...)
+ if before.Code != 303 {
+ t.Fatal("first factor minted assertion")
+ }
+ recovery := exchangeRequest(p.credential.App, "POST", Origin+"/passkey/signin/recovery", Origin, url.Values{"code": {codes[0]}}, gated.Result().Cookies()...)
+ if sessionCookie(recovery) == nil || recovery.Header().Get("Location") != "/authorize/resume" {
+ t.Fatal("recovery lost continuation", recovery.Code)
+ }
+ token, _ := p.mint(t, intent, sessionCookie(recovery))
+ p.finish(t, token, pending)
+}
+func TestHomeRoleAndMutationIsolation(t *testing.T) {
+ p := newExchangePair(t)
+ for _, route := range []string{"/signin", "/authorize", "/authorize/resume", "/passkey/enrol", "/static/assertion.js"} {
+ rec := exchangeRequest(p.directory, "GET", Origin+route, "", nil)
+ if rec.Code != 404 {
+ t.Fatal("directory served credential host", route, rec.Code)
+ }
+ }
+ for _, route := range []string{"/", "/signin", "/authorize", "/auth/start", "/static/assertion.js"} {
+ rec := exchangeRequest(p.credential.App, "GET", directoryOrigin+route, "", nil)
+ if rec.Code != 404 {
+ t.Fatal("credential served directory host", route, rec.Code)
+ }
+ }
+ rec := exchangeRequest(p.directory, "POST", directoryOrigin+"/signout", Origin, url.Values{})
+ if rec.Code != 403 {
+ t.Fatal("ordinary directory mutation lost CSRF")
+ }
+ nonce, _ := assertion.RandomID()
+ for _, query := range []string{"consumer=evil&nonce=" + nonce, "consumer=directory&nonce=x", "consumer=directory&nonce=" + nonce + "&nonce=" + nonce, "consumer=directory&nonce=" + nonce + "&return_to=https://evil.invalid"} {
+ rec := exchangeRequest(p.credential.App, "GET", Origin+"/authorize?"+query, "", nil)
+ if rec.Code != 400 || len(rec.Result().Cookies()) != 0 {
+ t.Fatal("invalid authorize retained intent")
+ }
+ }
+}
+func TestHomePolicyMatchesRegisteredConsumers(t *testing.T) {
+ p := newExchangePair(t)
+ baseline := (&App{origins: p.credential.origins}).SecurityPolicy()
+ want := strings.Replace(baseline, "form-action 'self' "+directoryOrigin, "form-action 'self' https://blue.docs.oficina.works "+directoryOrigin, 1)
+ if p.credential.SecurityPolicy() != want {
+ t.Fatal("CSP differs outside exact manifest")
+ }
+}
+
+func TestDirectoryRoleUpgradePreservesExistingDatabase(t *testing.T) {
+ old := newTestApp(t)
+ ref, err := old.findOrMintPerson(context.Background(), sealtest.CanaryAddress)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = migrate.Apply(context.Background(), old.db, directorySchema); err != nil {
+ t.Fatal(err)
+ }
+ var got string
+ if err = old.db.Writer().QueryRow(`SELECT person_ref FROM people`).Scan(&got); err != nil || got != ref {
+ t.Fatal("role migration changed existing data")
+ }
+ if _, err = migrate.Apply(context.Background(), old.db, BootSchema); err != nil {
+ t.Fatal("additive role schema prevents legacy rollback", err)
+ }
+ sealtest.AssertNoPlaintext(t, old.dbPath)
+}
diff --git a/internal/home/noscript_test.go b/internal/home/noscript_test.go
index e60e27c..ad0985b 100644
--- a/internal/home/noscript_test.go
+++ b/internal/home/noscript_test.go
@@ -33,6 +33,7 @@ import (
// the only assets that have to be nameable in a security rule, and a
// rule that cannot name its subject is not a rule.
var scriptAllowed = map[string][]string{
+ "assertion.html": {"/static/assertion.js"},
"passkey_confirm.html": {"/static/passkey.js"},
"passkey_enrol.html": {"/static/passkey.js"},
}
@@ -186,7 +187,7 @@ func TestNoScriptTagOutsideTheAllowlist(t *testing.T) {
permitted, ok := scriptAllowed[name]
if !ok {
t.Errorf("%s carries a <script>; §11 says every screen works with scripts "+
- "off and ruling 21 carves out only the passkey screens", name)
+ "off and the allowlist names only passkey screens and the approved assertion handoff", name)
continue
}
for _, srcAttr := range srcs {
diff --git a/internal/home/origins.go b/internal/home/origins.go
index fb6829f..3cd5222 100644
--- a/internal/home/origins.go
+++ b/internal/home/origins.go
@@ -87,6 +87,10 @@ func (o Origins) validate() error {
func (a *App) serveKnownHost(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := strings.ToLower(r.Host)
+ if a.runtime != nil && ((a.runtime.role == "credential" && host != a.credentialHost) || (a.runtime.role == "directory" && host != a.directoryHost)) {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
if host != a.credentialHost && host != a.directoryHost {
w.WriteHeader(http.StatusNotFound)
return
diff --git a/internal/home/passkey.go b/internal/home/passkey.go
index 3966bef..4eb2e64 100644
--- a/internal/home/passkey.go
+++ b/internal/home/passkey.go
@@ -10,7 +10,7 @@ import (
"github.com/carlosframework/rastrillo/webauthn"
)
-//go:embed static/passkey.js static/tokens.css
+//go:embed static/passkey.js static/tokens.css static/assertion.js
var staticFS embed.FS
// confirmPath is where the Gate sends a first-factor-verified caller who
diff --git a/internal/home/passkey_test.go b/internal/home/passkey_test.go
index d1b1aed..7bc24a7 100644
--- a/internal/home/passkey_test.go
+++ b/internal/home/passkey_test.go
@@ -128,41 +128,17 @@ func redeemAfterSignIn(t *testing.T, app *testApp, addr string) *httptest.Respon
return redeem(t, app, capturedLink(t, app))
}
-// A TRIPWIRE, not a proof, and the difference matters — an earlier
-// version of this comment claimed it was a proof and was wrong twice
-// over.
-//
-// It was wrong about what it catches: it is a substring match on one
-// spelling, and a reviewer removed the Gate from the keymail path by
-// assigning a.sessions to a local first. The suite stayed green. The
-// real guards are behavioural and live in keymail_test.go and
-// passkey_test.go: an enrolled person must land on confirmPath and mint
-// nothing, down BOTH doors.
-//
-// And it was wrong about the invariant: the deployed app mints sessions
-// in FOUR places, not one. This package's completeSignIn is one;
-// passkey.SignInFinish, SignInRecovery and StepUpFinish are the others,
-// all mounted on this router. Those three are legitimate — each one IS
-// the second factor, so gating them would be circular — but "exactly
-// one call site" was never true of the app, only of this package's own
-// source.
-//
-// What it still buys: if somebody adds a fifth mint to THIS package, it
-// says so. That is worth keeping as long as nobody mistakes it for the
-// thing that makes the Gate safe.
-func TestThisPackageMintsSessionsInOnePlace(t *testing.T) {
+// This source tripwire names the two app-owned session boundaries. Behavioural
+// tests prove the credential Gate and the directory assertion exchange; aliases
+// can evade a source search, so the search alone is not a security proof.
+func TestSessionIssuanceStaysAtCredentialAndDirectoryBoundaries(t *testing.T) {
got := grepPackage(t, "a.sessions.SignIn(")
- if len(got) != 1 {
- t.Errorf("this package mints a session in %d places (%v), want 1. If that is "+
- "deliberate, the new one needs its own behavioural test that passkey.Gate "+
- "covers it — see TestKeymailSignInIsGatedForAnEnrolledPerson.", len(got), got)
+ if len(got) != 2 || got[0] != "directory.go" || got[1] != "signin.go" {
+ t.Fatalf("unexpected app session boundaries: %v", got)
}
gate := grepPackage(t, "a.passkeys.Gate(")
- if len(gate) != 1 {
- t.Errorf("passkey.Gate is called in %d places (%v), want exactly one", len(gate), gate)
- }
- if len(got) == 1 && len(gate) == 1 && got[0] != gate[0] {
- t.Errorf("the Gate is in %s but the session is minted in %s", gate[0], got[0])
+ if len(gate) != 1 || gate[0] != "signin.go" {
+ t.Fatalf("credential gate moved: %v", gate)
}
}
diff --git a/internal/home/runtime.go b/internal/home/runtime.go
new file mode 100644
index 0000000..bb48e6e
--- /dev/null
+++ b/internal/home/runtime.go
@@ -0,0 +1,151 @@
+package home
+
+import (
+ "crypto/elliptic"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "log/slog"
+ "os"
+
+ "amadan.net/oficina/home/handoff"
+ "amadan.net/rastrillo/rastrillo/assertion"
+ assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
+ "github.com/carlosframework/rastrillo/db"
+ "github.com/carlosframework/rastrillo/migrate"
+ "github.com/carlosframework/rastrillo/sessions"
+ json "github.com/go-json-experiment/json"
+)
+
+var errExchangeConfig = errors.New("home: invalid assertion configuration")
+var directorySchema = migrate.Merge(sessions.Schema, migrate.MustFromFS(handoff.Migrations, "oficina_handoff"))
+
+type Runtime struct {
+ role string
+ origins Origins
+ secrets Secrets
+ signer *assertioncrypto.Keypair
+ keyID string
+ keys map[string][]byte
+ consumers map[string]string
+}
+
+// LoadRuntime keeps existing deployments on the legacy path until a role is
+// explicitly configured. Directory boot never reads credential secrets.
+func LoadRuntime(dbPath string, logger *slog.Logger) (Runtime, error) {
+ var cfg Runtime
+ var err error
+ cfg.origins, err = LoadOrigins()
+ if err != nil {
+ return Runtime{}, err
+ }
+ cfg.role = os.Getenv("HOME_ROLE")
+ switch cfg.role {
+ case "":
+ cfg.secrets, err = LoadSecrets(dbPath, logger)
+ return cfg, err
+ case "credential":
+ cfg.secrets, err = LoadSecrets(dbPath, logger)
+ if err != nil {
+ return Runtime{}, err
+ }
+ encodedPrivate := os.Getenv("HOME_ASSERTION_KEY")
+ if len(encodedPrivate) > 4096 {
+ return Runtime{}, errExchangeConfig
+ }
+ raw, err := base64.StdEncoding.DecodeString(encodedPrivate)
+ if err != nil {
+ return Runtime{}, errExchangeConfig
+ }
+ cfg.signer, err = assertioncrypto.UnmarshalKeypair(raw)
+ if err != nil {
+ return Runtime{}, errExchangeConfig
+ }
+ if cfg.signer.SignPriv.D.Sign() <= 0 || cfg.signer.SignPriv.D.Cmp(elliptic.P256().Params().N) >= 0 {
+ return Runtime{}, errExchangeConfig
+ }
+ pub := cfg.signer.SignPub()
+ sum := sha256.Sum256(pub)
+ cfg.keyID = hex.EncodeToString(sum[:])
+ cfg.keys = map[string][]byte{cfg.keyID: pub}
+ rawManifest := os.Getenv("HOME_CONSUMERS")
+ // Sixteen pilot origins keep the exact CSP manifest within ordinary header
+ // limits. Production registration needs a separately reviewed scalable path.
+ if len(rawManifest) > 8192 || json.Unmarshal([]byte(rawManifest), &cfg.consumers) != nil || len(cfg.consumers) == 0 || len(cfg.consumers) > 16 {
+ return Runtime{}, errExchangeConfig
+ }
+ for id, origin := range cfg.consumers {
+ if !consumerName(id) {
+ return Runtime{}, errExchangeConfig
+ }
+ if _, err = assertion.NewVerifier(cfg.origins.Credential, origin, cfg.keys); err != nil {
+ return Runtime{}, errExchangeConfig
+ }
+ }
+ if cfg.consumers["directory"] != cfg.origins.Directory {
+ return Runtime{}, errExchangeConfig
+ }
+ case "directory":
+ if dbPath == "" || os.Getenv("HOME_ASSERTION_KEY") != "" {
+ return Runtime{}, errExchangeConfig
+ }
+ cfg.secrets.Sealer, err = LoadSealer()
+ if err != nil {
+ return Runtime{}, err
+ }
+ var encoded map[string]string
+ raw := os.Getenv("HOME_ASSERTION_KEYS")
+ if len(raw) > 4096 || json.Unmarshal([]byte(raw), &encoded) != nil || len(encoded) == 0 || len(encoded) > 8 {
+ return Runtime{}, errExchangeConfig
+ }
+ cfg.keys = make(map[string][]byte, len(encoded))
+ for kid, value := range encoded {
+ key, err := base64.StdEncoding.DecodeString(value)
+ if err != nil {
+ return Runtime{}, errExchangeConfig
+ }
+ cfg.keys[kid] = key
+ }
+ if _, err = assertion.NewVerifier(cfg.origins.Credential, cfg.origins.Directory, cfg.keys); err != nil {
+ return Runtime{}, errExchangeConfig
+ }
+ default:
+ return Runtime{}, errExchangeConfig
+ }
+ return cfg, nil
+}
+func consumerName(s string) bool {
+ if len(s) == 0 || len(s) > 64 {
+ return false
+ }
+ for _, b := range []byte(s) {
+ if !(b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || b == '_' || b == '-') {
+ return false
+ }
+ }
+ return true
+}
+func (cfg Runtime) Schema() *migrate.Set {
+ if cfg.role == "directory" {
+ return directorySchema
+ }
+ return BootSchema
+}
+func NewRuntime(cfg Runtime, d *db.DB, logger *slog.Logger) (*App, error) {
+ if cfg.role == "directory" {
+ return newDirectory(cfg, d, logger)
+ }
+ a, err := NewWithOrigins(cfg.secrets, d, logger, cfg.origins)
+ if err != nil {
+ return nil, err
+ }
+ if cfg.role != "" && cfg.role != "credential" {
+ return nil, errExchangeConfig
+ }
+ if cfg.role == "credential" && (cfg.signer == nil || cfg.consumers["directory"] != cfg.origins.Directory) {
+ return nil, errExchangeConfig
+ }
+ a.runtime = &cfg
+ return a, nil
+}
diff --git a/internal/home/runtime_test.go b/internal/home/runtime_test.go
new file mode 100644
index 0000000..3b852de
--- /dev/null
+++ b/internal/home/runtime_test.go
@@ -0,0 +1,95 @@
+package home
+
+import (
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "testing"
+
+ assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
+)
+
+func runtimeEnv(t *testing.T) (string, string) {
+ t.Helper()
+ setSecretEnv(t, nil)
+ for _, k := range []string{"HOME_ROLE", "HOME_ASSERTION_KEY", "HOME_ASSERTION_KEYS", "HOME_CONSUMERS", "HOME_CREDENTIAL_ORIGIN", "HOME_DIRECTORY_ORIGIN"} {
+ t.Setenv(k, "")
+ }
+ t.Setenv("HOME_CREDENTIAL_ORIGIN", Origin)
+ t.Setenv("HOME_DIRECTORY_ORIGIN", directoryOrigin)
+ key, err := assertioncrypto.Generate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, err := assertioncrypto.MarshalKeypair(key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sum := sha256.Sum256(key.SignPub())
+ keys, _ := json.Marshal(map[string]string{hex.EncodeToString(sum[:]): base64.StdEncoding.EncodeToString(key.SignPub())})
+ return base64.StdEncoding.EncodeToString(raw), string(keys)
+}
+func TestRuntimeKeepsLegacyAndRequiresExplicitRoles(t *testing.T) {
+ runtimeEnv(t)
+ cfg, err := LoadRuntime("home.db", quietLogger())
+ if err != nil || cfg.role != "" || cfg.secrets.Sealer == nil {
+ t.Fatal("legacy config broken", err)
+ }
+ t.Setenv("HOME_ROLE", "typo")
+ if _, err = LoadRuntime("home.db", quietLogger()); err != errExchangeConfig {
+ t.Fatal("unknown role accepted")
+ }
+}
+func TestDirectoryRuntimeNeedsNoCredentialMaterial(t *testing.T) {
+ _, keys := runtimeEnv(t)
+ t.Setenv("HOME_ROLE", "directory")
+ t.Setenv("HOME_ASSERTION_KEYS", keys)
+ for _, k := range []string{"HOME_PEPPER", "HOME_SIGNIN_KEY", "CARLOS_SMTP_HOST", "CARLOS_SMTP_FROM", "CARLOS_SMTP_PASS"} {
+ t.Setenv(k, "")
+ }
+ cfg, err := LoadRuntime("directory.db", quietLogger())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.signer != nil || len(cfg.secrets.Pepper) != 0 || cfg.secrets.Mailer != nil || cfg.Schema() != directorySchema {
+ t.Fatal("directory holds credentials")
+ }
+ t.Setenv("HOME_ASSERTION_KEY", "present")
+ if _, err = LoadRuntime("directory.db", quietLogger()); err != errExchangeConfig {
+ t.Fatal("directory accepted private signing material")
+ }
+}
+func TestCredentialRuntimeValidatesManifestAndSigningKey(t *testing.T) {
+ private, _ := runtimeEnv(t)
+ t.Setenv("HOME_ROLE", "credential")
+ t.Setenv("HOME_ASSERTION_KEY", private)
+ t.Setenv("HOME_CONSUMERS", `{"directory":"https://oficina.works","docs":"https://blue.docs.oficina.works"}`)
+ cfg, err := LoadRuntime("home.db", quietLogger())
+ if err != nil || cfg.signer == nil || len(cfg.consumers) != 2 {
+ t.Fatal("valid signer refused", err)
+ }
+ for _, manifest := range []string{`null`, `{}`, `{"directory":"https://evil.invalid"}`, `{"directory":"https://oficina.works","docs":"https://id.oficina.works"}`, `{"directory":"https://oficina.works","directory":"https://oficina.works"}`, `{"directory":"https://oficina.works","bad id":"https://docs.example.com"}`, `{"directory":"https://oficina.works","docs":"https://0x"}`} {
+ t.Setenv("HOME_CONSUMERS", manifest)
+ if _, err = LoadRuntime("home.db", quietLogger()); err != errExchangeConfig {
+ t.Fatal("bad manifest accepted")
+ }
+ }
+ t.Setenv("HOME_CONSUMERS", `{"directory":"https://oficina.works"}`)
+ for _, key := range []string{"", "bad", base64.StdEncoding.EncodeToString([]byte(`{"sign_priv_d":"00","box_priv_d":"0101010101010101010101010101010101010101010101010101010101010101"}`))} {
+ t.Setenv("HOME_ASSERTION_KEY", key)
+ if _, err = LoadRuntime("home.db", quietLogger()); err != errExchangeConfig {
+ t.Fatal("invalid signing key accepted")
+ }
+ }
+}
+func TestDirectoryRuntimeRefusesMalformedKeys(t *testing.T) {
+ runtimeEnv(t)
+ t.Setenv("HOME_ROLE", "directory")
+ for _, keys := range []string{"", `null`, `{}`, `{"k":"AAAA"}`, `{"k":"AAAA","k":"AAAA"}`} {
+ t.Setenv("HOME_ASSERTION_KEYS", keys)
+ if _, err := LoadRuntime("directory.db", quietLogger()); err != errExchangeConfig {
+ t.Fatal("invalid verification keys accepted")
+ }
+ }
+}
diff --git a/internal/home/security.go b/internal/home/security.go
index 21fb5fc..caaf7e9 100644
--- a/internal/home/security.go
+++ b/internal/home/security.go
@@ -1,8 +1,24 @@
package home
-// SecurityPolicy preserves the framework policy while allowing the configured
-// directory origin at the end of a credential form's redirect chain.
+import (
+ "sort"
+ "strings"
+)
+
+// SecurityPolicy preserves framework defaults and names only registered form
+// destinations. The credential origin renders no account-authored markup.
func (a *App) SecurityPolicy() string {
+ origins := map[string]bool{a.origins.Directory: true}
+ if a.runtime != nil {
+ for _, origin := range a.runtime.consumers {
+ origins[origin] = true
+ }
+ }
+ list := make([]string, 0, len(origins))
+ for origin := range origins {
+ list = append(list, origin)
+ }
+ sort.Strings(list)
return "default-src 'self'; style-src 'self' 'unsafe-inline'; " +
- "img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self' " + a.origins.Directory
+ "img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self' " + strings.Join(list, " ")
}
diff --git a/internal/home/signin_return.go b/internal/home/signin_return.go
index 7464359..8363b65 100644
--- a/internal/home/signin_return.go
+++ b/internal/home/signin_return.go
@@ -11,7 +11,7 @@ const returnTTL = 10 * time.Minute
func credentialReturn(value string) string {
switch value {
- case "/passkey/enrol", "/passkey/codes":
+ case "/passkey/enrol", "/passkey/codes", "/authorize/resume":
return value
}
return "/"
diff --git a/internal/home/static/assertion.js b/internal/home/static/assertion.js
new file mode 100644
index 0000000..ce33ffb
--- /dev/null
+++ b/internal/home/static/assertion.js
@@ -0,0 +1,2 @@
+const form = document.querySelector("form[data-assertion-handoff]");
+if (form instanceof HTMLFormElement) form.requestSubmit();
diff --git a/internal/home/templates/assertion.html b/internal/home/templates/assertion.html
new file mode 100644
index 0000000..c70e695
--- /dev/null
+++ b/internal/home/templates/assertion.html
@@ -0,0 +1,10 @@
+{{define "title"}}Continue to Oficina · Oficina{{end}}
+{{define "content"}}
+<h1>Continue to Oficina</h1>
+<p>Select Continue if this page does not move on.</p>
+<form method="post" action="{{.Action}}" rst-form data-assertion-handoff>
+<input type="hidden" name="assertion" value="{{.Token}}">
+<button type="submit" rst-btn="primary">Continue</button>
+</form>
+{{end}}
+{{define "scripts"}}<script src="/static/assertion.js" defer></script>{{end}}
diff --git a/internal/home/templates/directory.html b/internal/home/templates/directory.html
new file mode 100644
index 0000000..7c27538
--- /dev/null
+++ b/internal/home/templates/directory.html
@@ -0,0 +1,6 @@
+{{define "title"}}Oficina{{end}}
+{{define "content"}}
+<h1>Oficina</h1>
+<p>You are signed in.</p>
+<form method="post" action="/signout" rst-form><button type="submit" rst-btn>Sign out</button></form>
+{{end}}
diff --git a/internal/home/templates/directory_signed_out.html b/internal/home/templates/directory_signed_out.html
new file mode 100644
index 0000000..ace69eb
--- /dev/null
+++ b/internal/home/templates/directory_signed_out.html
@@ -0,0 +1,5 @@
+{{define "title"}}Oficina{{end}}
+{{define "content"}}
+<h1>You are signed out</h1>
+<a href="/auth/start" rst-btn="primary">Sign in</a>
+{{end}}