oficina / home Public

Add reusable browser-bound Home assertion consumer

Paul Campbell pushed by paul@keymail.dev 3c550c3491db905008f7e4ee7458aa433d5386fb parent 36f876c
9 files changed, +1194 −1
  • Makefile +4 −0
  • handoff/go.mod +21 −0
  • handoff/go.sum +55 −0
  • handoff/http.go +209 −0
  • handoff/http_test.go +186 −0
  • handoff/migrations/0001_exchange.sql +26 −0
  • handoff/store.go +362 −0
  • handoff/store_test.go +325 −0
  • implementation/team-entry.md +6 −1

CI passed — run details

diff --git a/Makefile b/Makefile
index 1c75520..37690c1 100644
--- a/Makefile
+++ b/Makefile
@@ -22,6 +22,7 @@ test: test-vet test-fmt test-build test-go test-race
test-vet:
CGO_ENABLED=0 go vet ./...
+ cd handoff && CGO_ENABLED=0 go vet ./...
# Fails on unformatted files rather than rewriting them. CI must never
# silently "fix" a diff out from under a branch — a red test-fmt tells
@@ -35,9 +36,11 @@ test-fmt:
test-build:
CGO_ENABLED=0 go build ./...
+ cd handoff && CGO_ENABLED=0 go build ./...
test-go:
CGO_ENABLED=0 go test -count=1 ./...
+ cd handoff && CGO_ENABLED=0 go test -count=1 ./...
# The one concurrency-bearing package. internal/home's link store is
# taken by a single DELETE ... RETURNING precisely so that two clicks on
@@ -51,3 +54,4 @@ test-go:
# cgo-free binary rule, which is about what gets deployed.
test-race:
go test -race -count=1 ./internal/home/
+ cd handoff && CGO_ENABLED=1 go test -race -count=1 ./...
diff --git a/handoff/go.mod b/handoff/go.mod
new file mode 100644
index 0000000..6be0a1e
--- /dev/null
+++ b/handoff/go.mod
@@ -0,0 +1,21 @@
+module amadan.net/oficina/home/handoff
+
+go 1.25.0
+
+require (
+ amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
+ modernc.org/sqlite v1.55.0
+)
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ modernc.org/libc v1.74.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+)
diff --git a/handoff/go.sum b/handoff/go.sum
new file mode 100644
index 0000000..8bd49e5
--- /dev/null
+++ b/handoff/go.sum
@@ -0,0 +1,55 @@
+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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
+modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
+modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
+modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
+modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
+modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/handoff/http.go b/handoff/http.go
new file mode 100644
index 0000000..0eaaf54
--- /dev/null
+++ b/handoff/http.go
@@ -0,0 +1,209 @@
+package handoff
+
+import (
+ "io"
+ "mime"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo/assertion"
+)
+
+const (
+ StartPath = "/auth/start"
+ CallbackPath = "/auth/assertion"
+ FinishPath = "/auth/finish"
+ pendingCookie = "__Host-oficina-assertion-pending"
+ inboxCookie = "__Host-oficina-assertion-inbox"
+)
+
+type ConsumerConfig struct {
+ Store *Store
+ ConsumerID string
+ // Resolve before store transactions, using the app's session middleware.
+ CurrentSubject func(*http.Request) string
+ // Complete runs only after replay consumption is committed. It creates the
+ // app's session, preserving AuthTime. An error cannot restore the assertion.
+ Complete func(http.ResponseWriter, *http.Request, assertion.Claims) error
+ Fail func(http.ResponseWriter, *http.Request, int)
+ Now func() time.Time
+}
+type Consumer struct{ cfg ConsumerConfig }
+
+func NewConsumer(cfg ConsumerConfig) (*Consumer, error) {
+ if cfg.Store == nil || cfg.Store.db == nil || cfg.Complete == nil || !consumerID(cfg.ConsumerID) {
+ return nil, ErrConfig
+ }
+ if cfg.Now == nil {
+ cfg.Now = time.Now
+ }
+ if cfg.CurrentSubject == nil {
+ cfg.CurrentSubject = func(*http.Request) string { return "" }
+ }
+ if cfg.Fail == nil {
+ cfg.Fail = func(w http.ResponseWriter, r *http.Request, status int) {
+ http.Error(w, "Unable to sign in. Start again.", status)
+ }
+ }
+ return &Consumer{cfg}, nil
+}
+func consumerID(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 (c *Consumer) guard(w http.ResponseWriter, r *http.Request, method, path string) bool {
+ w.Header().Set("Cache-Control", "no-store")
+ if r.Host != strings.TrimPrefix(c.cfg.Store.audience, "https://") || r.URL.Path != path {
+ c.cfg.Fail(w, r, http.StatusNotFound)
+ return false
+ }
+ if r.Method != method {
+ w.Header().Set("Allow", method)
+ c.cfg.Fail(w, r, http.StatusMethodNotAllowed)
+ return false
+ }
+ return true
+}
+func cookie(r *http.Request, name string) (string, bool) {
+ value := ""
+ count := 0
+ for _, c := range r.Cookies() {
+ if c.Name == name {
+ count++
+ value = c.Value
+ }
+ }
+ return value, count == 1 && validHandle(value)
+}
+func setCookie(w http.ResponseWriter, name, value string, seconds int) {
+ http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", MaxAge: seconds, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode})
+}
+func (c *Consumer) fail(w http.ResponseWriter, r *http.Request, e error) {
+ status := http.StatusBadRequest
+ if e == ErrStorage || e == ErrClock || e == ErrFull {
+ status = http.StatusServiceUnavailable
+ }
+ c.cfg.Fail(w, r, status)
+}
+
+// Start accepts a bounded local return_to query and writes only sealed intent.
+func (c *Consumer) Start(w http.ResponseWriter, r *http.Request) {
+ if !c.guard(w, r, http.MethodGet, StartPath) {
+ return
+ }
+ q, e := url.ParseQuery(r.URL.RawQuery)
+ if e != nil || len(q) > 1 || len(q["return_to"]) > 1 {
+ c.fail(w, r, ErrRefused)
+ return
+ }
+ path := "/"
+ if len(q) > 0 {
+ values, ok := q["return_to"]
+ if !ok || len(values) != 1 {
+ c.fail(w, r, ErrRefused)
+ return
+ }
+ path = values[0]
+ }
+ matches := 0
+ for _, item := range r.Cookies() {
+ if item.Name == pendingCookie {
+ matches++
+ }
+ }
+ if matches > 1 {
+ c.fail(w, r, ErrRefused)
+ return
+ }
+ previous, ok := cookie(r, pendingCookie)
+ if !ok {
+ previous = ""
+ }
+ secret, nonce, e := c.cfg.Store.Start(r.Context(), path, previous, c.cfg.Now())
+ if e != nil {
+ c.fail(w, r, e)
+ return
+ }
+ setCookie(w, pendingCookie, secret, int(pendingLifetime))
+ target := c.cfg.Store.issuer + "/authorize?" + url.Values{"consumer": {c.cfg.ConsumerID}, "nonce": {nonce}}.Encode()
+ http.Redirect(w, r, target, http.StatusSeeOther)
+}
+
+// Callback is the one route mounted outside ordinary same-origin CSRF checks.
+// It requires the exact issuer Origin even for a same-site request and needs no
+// cookies. Keep every other app mutation inside the app's CSRF middleware.
+func (c *Consumer) Callback(w http.ResponseWriter, r *http.Request) {
+ if !c.guard(w, r, http.MethodPost, CallbackPath) {
+ return
+ }
+ origins := r.Header.Values("Origin")
+ if len(origins) != 1 || origins[0] != c.cfg.Store.issuer || r.URL.RawQuery != "" {
+ c.fail(w, r, ErrRefused)
+ return
+ }
+ media, _, e := mime.ParseMediaType(r.Header.Get("Content-Type"))
+ if e != nil || media != "application/x-www-form-urlencoded" {
+ c.fail(w, r, ErrRefused)
+ return
+ }
+ // Form encoding expands the 8192-byte core wire bound; 12 KiB comfortably
+ // holds a base64url token while bounding input before parsing or crypto.
+ body, e := io.ReadAll(http.MaxBytesReader(w, r.Body, 12*1024))
+ if e != nil {
+ c.fail(w, r, ErrRefused)
+ return
+ }
+ form, e := url.ParseQuery(string(body))
+ if e != nil || len(form) != 1 || len(form["assertion"]) != 1 {
+ c.fail(w, r, ErrRefused)
+ return
+ }
+ handle, e := c.cfg.Store.Receive(r.Context(), form["assertion"][0], c.cfg.Now())
+ if e != nil {
+ c.fail(w, r, e)
+ return
+ }
+ setCookie(w, inboxCookie, handle, int(inboxLifetime))
+ http.Redirect(w, r, FinishPath, http.StatusSeeOther)
+}
+
+// Finish receives the Lax cookies on the top-level GET after the callback's
+// redirect. Only its browser-bound transaction can reach Complete.
+func (c *Consumer) Finish(w http.ResponseWriter, r *http.Request) {
+ if !c.guard(w, r, http.MethodGet, FinishPath) {
+ return
+ }
+ if r.URL.RawQuery != "" {
+ c.fail(w, r, ErrRefused)
+ return
+ }
+ secret, ok := cookie(r, pendingCookie)
+ handle, have := cookie(r, inboxCookie)
+ if !ok || !have {
+ c.fail(w, r, ErrRefused)
+ return
+ }
+ setCookie(w, pendingCookie, "", -1)
+ setCookie(w, inboxCookie, "", -1)
+ existing := c.cfg.CurrentSubject(r)
+ claims, path, e := c.cfg.Store.Finish(r.Context(), secret, handle, existing, c.cfg.Now())
+ if e != nil {
+ c.fail(w, r, e)
+ return
+ }
+ if e = c.cfg.Complete(w, r, claims); e != nil {
+ c.fail(w, r, ErrStorage)
+ return
+ }
+ http.Redirect(w, r, path, http.StatusSeeOther)
+}
diff --git a/handoff/http_test.go b/handoff/http_test.go
new file mode 100644
index 0000000..3decfe3
--- /dev/null
+++ b/handoff/http_test.go
@@ -0,0 +1,186 @@
+package handoff
+
+import (
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo/assertion"
+)
+
+func newConsumer(t *testing.T, f fixture, done func(http.ResponseWriter, *http.Request, assertion.Claims) error) *Consumer {
+ t.Helper()
+ c, e := NewConsumer(ConsumerConfig{Store: f.s, ConsumerID: "directory", Now: func() time.Time { return f.now }, Complete: done})
+ if e != nil {
+ t.Fatal(e)
+ }
+ return c
+}
+func request(method, path, body string) *http.Request {
+ r := httptest.NewRequest(method, "https://app.example.com"+path, strings.NewReader(body))
+ if method == "POST" {
+ r.Header.Set("Origin", "https://id.example.com")
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ }
+ return r
+}
+func matchingCookie(t *testing.T, r *httptest.ResponseRecorder, name string) *http.Cookie {
+ t.Helper()
+ for _, c := range r.Result().Cookies() {
+ if c.Name == name {
+ return c
+ }
+ }
+ t.Fatal("cookie missing", name)
+ return nil
+}
+func TestHTTPExchangeWithoutCallbackCookie(t *testing.T) {
+ f := fresh(t)
+ completed := 0
+ c := newConsumer(t, f, func(w http.ResponseWriter, r *http.Request, a assertion.Claims) error {
+ completed++
+ if a.Subject != "person_123" || a.AuthTime != f.now.Unix()-900 {
+ t.Fatal("identity/freshness lost")
+ }
+ return nil
+ })
+ start := httptest.NewRecorder()
+ c.Start(start, request("GET", StartPath+"?return_to=%2Fdocuments%2Fx%3Fteam%3Dblue", ""))
+ if start.Code != 303 {
+ t.Fatal(start.Code)
+ }
+ target, e := url.Parse(start.Header().Get("Location"))
+ if e != nil {
+ t.Fatal(e)
+ }
+ if target.Host != "id.example.com" || target.Path != "/authorize" || target.Query().Get("consumer") != "directory" {
+ t.Fatal("bad issuer target")
+ }
+ secret := matchingCookie(t, start, pendingCookie)
+ token := f.token(t, target.Query().Get("nonce"))
+ callback := httptest.NewRecorder()
+ c.Callback(callback, request("POST", CallbackPath, url.Values{"assertion": {token}}.Encode()))
+ if callback.Code != 303 || completed != 0 {
+ t.Fatal("callback minted session")
+ }
+ handle := matchingCookie(t, callback, inboxCookie)
+ for _, cookie := range []*http.Cookie{secret, handle} {
+ if !cookie.Secure || !cookie.HttpOnly || cookie.Path != "/" || cookie.Domain != "" || cookie.SameSite != http.SameSiteLaxMode || !strings.HasPrefix(cookie.Name, "__Host-") {
+ t.Fatal("cookie isolation lost")
+ }
+ }
+ if strings.Contains(callback.Header().Get("Location"), token) || callback.Header().Get("Location") != FinishPath {
+ t.Fatal("token in navigation")
+ }
+ finish := httptest.NewRecorder()
+ r := request("GET", FinishPath, "")
+ r.AddCookie(secret)
+ r.AddCookie(handle)
+ c.Finish(finish, r)
+ if completed != 1 || finish.Code != 303 || finish.Header().Get("Location") != "/documents/x?team=blue" {
+ t.Fatal("finish failed")
+ }
+ for _, rec := range []*httptest.ResponseRecorder{start, callback, finish} {
+ if rec.Header().Get("Cache-Control") != "no-store" {
+ t.Fatal("cacheable auth response")
+ }
+ }
+}
+func TestCallbackRefusalsNeverSetCookies(t *testing.T) {
+ f := fresh(t)
+ _, nonce := f.pending(t)
+ token := f.token(t, nonce)
+ body := url.Values{"assertion": {token}}.Encode()
+ c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error {
+ t.Fatal("invalid callback completed")
+ return nil
+ })
+ cases := map[string]func(*http.Request){"missing origin": func(r *http.Request) { r.Header.Del("Origin") }, "null origin": func(r *http.Request) { r.Header.Set("Origin", "null") }, "foreign same site": func(r *http.Request) {
+ r.Header.Set("Origin", "https://evil.example.com")
+ r.Header.Set("Sec-Fetch-Site", "same-site")
+ }, "duplicate origin": func(r *http.Request) { r.Header.Add("Origin", "https://id.example.com") }, "JSON": func(r *http.Request) { r.Header.Set("Content-Type", "application/json") }, "query": func(r *http.Request) { r.URL.RawQuery = "assertion=" + token }, "wrong host": func(r *http.Request) { r.Host = "other.example.com" }, "GET": func(r *http.Request) { r.Method = "GET" }, "wrong path": func(r *http.Request) { r.URL.Path = "/other" }}
+ for name, change := range cases {
+ t.Run(name, func(t *testing.T) {
+ r := request("POST", CallbackPath, body)
+ change(r)
+ rec := httptest.NewRecorder()
+ c.Callback(rec, r)
+ if rec.Code < 400 || len(rec.Result().Cookies()) != 0 || count(t, f.db, "oficina_handoff_inbox") != 0 {
+ t.Fatal("callback accepted")
+ }
+ })
+ }
+ for _, bad := range []string{body + "&" + body, body + "&extra=1", strings.Repeat("x", 12*1024+1), "assertion=%zz", ""} {
+ rec := httptest.NewRecorder()
+ c.Callback(rec, request("POST", CallbackPath, bad))
+ if rec.Code != 400 || len(rec.Result().Cookies()) != 0 {
+ t.Fatal("bad body accepted")
+ }
+ }
+}
+func TestFinishRefusesMissingDuplicateOrWrongBrowserCookies(t *testing.T) {
+ f := fresh(t)
+ secret, nonce := f.pending(t)
+ handle, _ := f.received(t, nonce)
+ c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error {
+ t.Fatal("wrong browser completed")
+ return nil
+ })
+ other, _, e := f.s.Start(t.Context(), "/", "", f.now)
+ if e != nil {
+ t.Fatal(e)
+ }
+ for _, cookies := range [][]*http.Cookie{nil, {{Name: pendingCookie, Value: secret}}, {{Name: inboxCookie, Value: handle}}, {{Name: pendingCookie, Value: other}, {Name: inboxCookie, Value: handle}}, {{Name: pendingCookie, Value: secret}, {Name: pendingCookie, Value: secret}, {Name: inboxCookie, Value: handle}}, {{Name: pendingCookie, Value: secret}, {Name: inboxCookie, Value: handle}, {Name: inboxCookie, Value: handle}}} {
+ r := request("GET", FinishPath, "")
+ for _, cookie := range cookies {
+ r.AddCookie(cookie)
+ }
+ rec := httptest.NewRecorder()
+ c.Finish(rec, r)
+ if rec.Code != 400 {
+ t.Fatal("bad finish accepted")
+ }
+ }
+}
+func TestSessionFailureKeepsAssertionConsumed(t *testing.T) {
+ f := fresh(t)
+ secret, nonce := f.pending(t)
+ handle, token := f.received(t, nonce)
+ c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error {
+ return errors.New("synthetic failure")
+ })
+ r := request("GET", FinishPath, "")
+ r.AddCookie(&http.Cookie{Name: pendingCookie, Value: secret})
+ r.AddCookie(&http.Cookie{Name: inboxCookie, Value: handle})
+ rec := httptest.NewRecorder()
+ c.Finish(rec, r)
+ if rec.Code != 503 || count(t, f.db, "oficina_handoff_replay") != 1 {
+ t.Fatal("failed session unconsumed assertion")
+ }
+ if _, e := f.s.Receive(t.Context(), token, f.now); e != ErrRefused {
+ t.Fatal("failed session replayed")
+ }
+}
+func TestStartRefusesAmbiguousIntent(t *testing.T) {
+ f := fresh(t)
+ c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error { return nil })
+ for _, query := range []string{"?other=1", "?return_to=/&return_to=/documents", "?return_to=//evil.example", "?return_to=/auth/start", "?return_to=%zz"} {
+ rec := httptest.NewRecorder()
+ c.Start(rec, request("GET", StartPath+query, ""))
+ if rec.Code != 400 || len(rec.Result().Cookies()) != 0 {
+ t.Fatal(query)
+ }
+ }
+ r := request("GET", StartPath, "")
+ r.AddCookie(&http.Cookie{Name: pendingCookie, Value: strings.Repeat("A", 43)})
+ r.AddCookie(&http.Cookie{Name: pendingCookie, Value: strings.Repeat("A", 43)})
+ rec := httptest.NewRecorder()
+ c.Start(rec, r)
+ if rec.Code != 400 {
+ t.Fatal("duplicate cookie accepted")
+ }
+}
diff --git a/handoff/migrations/0001_exchange.sql b/handoff/migrations/0001_exchange.sql
new file mode 100644
index 0000000..b0d69d9
--- /dev/null
+++ b/handoff/migrations/0001_exchange.sql
@@ -0,0 +1,26 @@
+CREATE TABLE oficina_handoff_clock (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ observed INTEGER NOT NULL
+);
+INSERT INTO oficina_handoff_clock (id, observed) VALUES (1, 0);
+CREATE TABLE oficina_handoff_pending (
+ secret_hash TEXT PRIMARY KEY,
+ nonce_hash TEXT NOT NULL UNIQUE,
+ sealed_path BLOB NOT NULL,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL
+);
+CREATE INDEX oficina_handoff_pending_expiry ON oficina_handoff_pending(expires_at);
+CREATE TABLE oficina_handoff_inbox (
+ handle_hash TEXT PRIMARY KEY,
+ jti_hash TEXT NOT NULL UNIQUE,
+ sealed_token BLOB NOT NULL,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL
+);
+CREATE INDEX oficina_handoff_inbox_expiry ON oficina_handoff_inbox(expires_at);
+CREATE TABLE oficina_handoff_replay (
+ jti_hash TEXT PRIMARY KEY,
+ expires_at INTEGER NOT NULL
+);
+CREATE INDEX oficina_handoff_replay_expiry ON oficina_handoff_replay(expires_at);
diff --git a/handoff/store.go b/handoff/store.go
new file mode 100644
index 0000000..0d03bdf
--- /dev/null
+++ b/handoff/store.go
@@ -0,0 +1,362 @@
+// Package handoff receives Home identity assertions. It binds each exchange to
+// the browser that started it and records use before the app issues a session.
+// Your app still decides team membership, account linking and item access.
+package handoff
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
+ "database/sql"
+ "embed"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "math"
+ "net/url"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "amadan.net/rastrillo/rastrillo/assertion"
+)
+
+// Migrations must be applied by the app's migration runner before NewStore.
+// Keeping them here gives every consumer the same replay and browser binding.
+//
+//go:embed migrations/*.sql
+var Migrations embed.FS
+
+var (
+ ErrRefused = errors.New("handoff: refused")
+ ErrStorage = errors.New("handoff: storage")
+ ErrClock = errors.New("handoff: clock")
+ ErrFull = errors.New("handoff: capacity")
+ ErrConfig = errors.New("handoff: configuration")
+)
+
+// A login can take several minutes when the user must open their mail client.
+const pendingLifetime = int64(600)
+
+// The inbox never needs to outlive the assertion it holds.
+const inboxLifetime = int64(120)
+
+// Retention matches the tolerated backward adjustment. Pruning earlier could
+// allow a token to be reused after the clock moves backwards.
+const clockTolerance = int64(30)
+const replayRetention = clockTolerance
+
+// Caps bound unauthenticated navigation and verified callback storage. Pending
+// eviction only loses navigation; inbox eviction could disrupt a valid proof.
+const rowLimit = 10000
+
+// Sealer must authenticate its purpose and plaintext. Apps supply their own
+// existing data-key implementation; this package never creates a storage key.
+type Sealer interface {
+ Seal(purpose string, plaintext []byte) ([]byte, error)
+ Open(purpose string, ciphertext []byte) ([]byte, error)
+}
+
+type Store struct {
+ db *sql.DB
+ sealer Sealer
+ verifier *assertion.Verifier
+ issuer, audience string
+}
+
+func NewStore(db *sql.DB, sealer Sealer, issuer, audience string, keys map[string][]byte) (*Store, error) {
+ if db == nil || sealer == nil {
+ return nil, ErrConfig
+ }
+ v, e := assertion.NewVerifier(issuer, audience, keys)
+ if e != nil {
+ return nil, ErrConfig
+ }
+ return &Store{db: db, sealer: sealer, verifier: v, issuer: issuer, audience: audience}, nil
+}
+
+func digest(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
+func jtiHash(c assertion.Claims) string { return digest(c.Issuer + "\x00" + c.ID) }
+func randomHandle() (string, error) {
+ var b [32]byte
+ if _, e := rand.Read(b[:]); e != nil {
+ return "", ErrStorage
+ }
+ return base64.RawURLEncoding.EncodeToString(b[:]), nil
+}
+func validHandle(s string) bool {
+ if len(s) != 43 {
+ return false
+ }
+ b, e := base64.RawURLEncoding.Strict().DecodeString(s)
+ return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s
+}
+
+// ValidNonce rejects alternate encodings, including CR/LF ignored by the base64
+// decoder. The issuer uses this before retaining a browser's request.
+func ValidNonce(s string) bool {
+ if len(s) != 22 {
+ return false
+ }
+ b, e := base64.RawURLEncoding.Strict().DecodeString(s)
+ return e == nil && len(b) == 16 && base64.RawURLEncoding.EncodeToString(b) == s
+}
+
+// SafePath accepts only local navigation outside the exchange itself. The
+// caller stores the returned value sealed, even when it looks harmless.
+func SafePath(s string) bool {
+ if !utf8.ValidString(s) || strings.Contains(s, "#") || len(s) == 0 || len(s) > 2048 || !strings.HasPrefix(s, "/") || strings.HasPrefix(s, "//") {
+ return false
+ }
+ for _, b := range []byte(s) {
+ if b <= 32 || b == 127 || b == '\\' {
+ return false
+ }
+ }
+ u, e := url.ParseRequestURI(s)
+ if e != nil || u.IsAbs() || u.Host != "" || u.Fragment != "" || u.Opaque != "" {
+ return false
+ }
+ path := u.Path
+ for _, b := range []byte(path) {
+ if b < 32 || b == 127 {
+ return false
+ }
+ }
+ if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || strings.ContainsAny(path, "\\\r\n\x00") {
+ return false
+ }
+ // Encoded or dot-segment aliases must not route back into authentication.
+ for _, part := range strings.Split(path, "/") {
+ if part == "." || part == ".." {
+ return false
+ }
+ }
+ return path != "/auth" && !strings.HasPrefix(path, "/auth/") && path != "/signin" && !strings.HasPrefix(path, "/signin/") && path != "/authorize" && !strings.HasPrefix(path, "/authorize/")
+}
+
+// begin takes the SQLite write lock before reading state, so several database
+// connections cannot race a capacity check or upgrade a stale read snapshot.
+func (s *Store) begin(ctx context.Context, now time.Time) (*sql.Tx, int64, error) {
+ n := now.Unix()
+ if n <= 0 || n > math.MaxInt64-pendingLifetime {
+ return nil, 0, ErrClock
+ }
+ tx, e := s.db.BeginTx(ctx, nil)
+ if e != nil {
+ return nil, 0, ErrStorage
+ }
+ result, e := tx.ExecContext(ctx, `UPDATE oficina_handoff_clock SET observed=MAX(observed,?) WHERE id=1 AND observed<=?`, n, n+clockTolerance)
+ if e != nil {
+ tx.Rollback()
+ return nil, 0, ErrStorage
+ }
+ rows, e := result.RowsAffected()
+ if e != nil || rows != 1 {
+ tx.Rollback()
+ return nil, 0, ErrClock
+ }
+ var fence int64
+ if e = tx.QueryRowContext(ctx, `SELECT observed FROM oficina_handoff_clock WHERE id=1`).Scan(&fence); e != nil {
+ tx.Rollback()
+ return nil, 0, ErrStorage
+ }
+ for _, q := range []struct {
+ sql string
+ n int64
+ }{
+ {`DELETE FROM oficina_handoff_pending WHERE expires_at<=?`, n},
+ {`DELETE FROM oficina_handoff_inbox WHERE expires_at<=?`, n},
+ {`DELETE FROM oficina_handoff_replay WHERE expires_at<?`, fence - replayRetention},
+ } {
+ if _, e = tx.ExecContext(ctx, q.sql, q.n); e != nil {
+ tx.Rollback()
+ return nil, 0, ErrStorage
+ }
+ }
+ return tx, n, nil
+}
+
+func (s *Store) Sweep(ctx context.Context, now time.Time) error {
+ tx, _, e := s.begin(ctx, now)
+ if e != nil {
+ return e
+ }
+ defer tx.Rollback()
+ if tx.Commit() != nil {
+ return ErrStorage
+ }
+ return nil
+}
+
+// Start replaces this browser's previous pending request. Browser secrets and
+// nonces are returned to the caller, but only their hashes enter the database.
+func (s *Store) Start(ctx context.Context, path, previous string, now time.Time) (secret, nonce string, err error) {
+ if !SafePath(path) {
+ return "", "", ErrRefused
+ }
+ secret, err = randomHandle()
+ if err != nil {
+ return "", "", err
+ }
+ nonce, err = assertion.RandomID()
+ if err != nil {
+ return "", "", ErrStorage
+ }
+ hash := digest(secret)
+ sealed, e := s.sealer.Seal("handoff.path."+hash, []byte(path))
+ if e != nil {
+ return "", "", ErrStorage
+ }
+ tx, n, e := s.begin(ctx, now)
+ if e != nil {
+ return "", "", e
+ }
+ defer tx.Rollback()
+ if validHandle(previous) {
+ if _, e = tx.ExecContext(ctx, `DELETE FROM oficina_handoff_pending WHERE secret_hash=?`, digest(previous)); e != nil {
+ return "", "", ErrStorage
+ }
+ }
+ var count int
+ if e = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM oficina_handoff_pending`).Scan(&count); e != nil {
+ return "", "", ErrStorage
+ }
+ if count >= rowLimit {
+ if _, e = tx.ExecContext(ctx, `DELETE FROM oficina_handoff_pending WHERE secret_hash IN (SELECT secret_hash FROM oficina_handoff_pending ORDER BY created_at,secret_hash LIMIT ?)`, count-rowLimit+1); e != nil {
+ return "", "", ErrStorage
+ }
+ }
+ if _, e = tx.ExecContext(ctx, `INSERT INTO oficina_handoff_pending(secret_hash,nonce_hash,sealed_path,created_at,expires_at) VALUES(?,?,?,?,?)`, hash, digest(nonce), sealed, n, n+pendingLifetime); e != nil {
+ return "", "", ErrStorage
+ }
+ if tx.Commit() != nil {
+ return "", "", ErrStorage
+ }
+ return secret, nonce, nil
+}
+
+// Receive deliberately needs no cookie: a cross-site POST may carry none. A
+// repeated assertion gets no new inbox row or cookie, even before Finish runs.
+func (s *Store) Receive(ctx context.Context, token string, now time.Time) (string, error) {
+ claims, e := s.verifier.Verify(token, now)
+ if e != nil {
+ return "", ErrRefused
+ }
+ handle, e := randomHandle()
+ if e != nil {
+ return "", e
+ }
+ hash := digest(handle)
+ jti := jtiHash(claims)
+ sealed, e := s.sealer.Seal("handoff.token."+hash, []byte(token))
+ if e != nil {
+ return "", ErrStorage
+ }
+ tx, n, e := s.begin(ctx, now)
+ if e != nil {
+ return "", e
+ }
+ defer tx.Rollback()
+ var count int
+ if e = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM oficina_handoff_replay WHERE jti_hash=?`, jti).Scan(&count); e != nil {
+ return "", ErrStorage
+ }
+ if count != 0 {
+ return "", ErrRefused
+ }
+ if e = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM oficina_handoff_inbox WHERE jti_hash=?`, jti).Scan(&count); e != nil {
+ return "", ErrStorage
+ }
+ if count != 0 {
+ return "", ErrRefused
+ }
+ if e = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM oficina_handoff_inbox`).Scan(&count); e != nil {
+ return "", ErrStorage
+ }
+ if count >= rowLimit {
+ return "", ErrFull
+ }
+ expires := claims.ExpiresAt
+ if expires > n+inboxLifetime {
+ expires = n + inboxLifetime
+ }
+ if _, e = tx.ExecContext(ctx, `INSERT INTO oficina_handoff_inbox(handle_hash,jti_hash,sealed_token,created_at,expires_at) VALUES(?,?,?,?,?)`, hash, jti, sealed, n, expires); e != nil {
+ return "", ErrStorage
+ }
+ if tx.Commit() != nil {
+ return "", ErrStorage
+ }
+ return handle, nil
+}
+
+// Finish commits replay consumption before returning an identity to the app.
+// existingSubject is resolved before calling: reading a session from inside
+// this transaction could deadlock an app's single-connection writer.
+func (s *Store) Finish(ctx context.Context, secret, handle, existingSubject string, now time.Time) (assertion.Claims, string, error) {
+ zero := assertion.Claims{}
+ if !validHandle(secret) || !validHandle(handle) {
+ return zero, "", ErrRefused
+ }
+ tx, n, e := s.begin(ctx, now)
+ if e != nil {
+ return zero, "", e
+ }
+ defer tx.Rollback()
+ secretHash, handleHash := digest(secret), digest(handle)
+ var nonceHash string
+ var sealedPath, sealedToken []byte
+ if e = tx.QueryRowContext(ctx, `SELECT nonce_hash,sealed_path FROM oficina_handoff_pending WHERE secret_hash=? AND expires_at>?`, secretHash, n).Scan(&nonceHash, &sealedPath); e != nil {
+ return zero, "", readError(e)
+ }
+ if e = tx.QueryRowContext(ctx, `SELECT sealed_token FROM oficina_handoff_inbox WHERE handle_hash=? AND expires_at>?`, handleHash, n).Scan(&sealedToken); e != nil {
+ return zero, "", readError(e)
+ }
+ token, e := s.sealer.Open("handoff.token."+handleHash, sealedToken)
+ if e != nil {
+ return zero, "", ErrRefused
+ }
+ claims, e := s.verifier.Verify(string(token), now)
+ if e != nil {
+ return zero, "", ErrRefused
+ }
+ if subtle.ConstantTimeCompare([]byte(nonceHash), []byte(digest(claims.Nonce))) != 1 || existingSubject != "" && existingSubject != claims.Subject {
+ return zero, "", ErrRefused
+ }
+ path, e := s.sealer.Open("handoff.path."+secretHash, sealedPath)
+ if e != nil || !SafePath(string(path)) {
+ return zero, "", ErrRefused
+ }
+ result, e := tx.ExecContext(ctx, `INSERT INTO oficina_handoff_replay(jti_hash,expires_at) VALUES(?,?) ON CONFLICT DO NOTHING`, jtiHash(claims), claims.ExpiresAt)
+ if e != nil {
+ return zero, "", ErrStorage
+ }
+ rows, e := result.RowsAffected()
+ if e != nil {
+ return zero, "", ErrStorage
+ }
+ if rows != 1 {
+ return zero, "", ErrRefused
+ }
+ for _, q := range []struct{ sql, key string }{{`DELETE FROM oficina_handoff_pending WHERE secret_hash=?`, secretHash}, {`DELETE FROM oficina_handoff_inbox WHERE handle_hash=?`, handleHash}} {
+ result, e = tx.ExecContext(ctx, q.sql, q.key)
+ if e != nil {
+ return zero, "", ErrStorage
+ }
+ rows, e = result.RowsAffected()
+ if e != nil || rows != 1 {
+ return zero, "", ErrStorage
+ }
+ }
+ if tx.Commit() != nil {
+ return zero, "", ErrStorage
+ }
+ return claims, string(path), nil
+}
+func readError(e error) error {
+ if errors.Is(e, sql.ErrNoRows) {
+ return ErrRefused
+ }
+ return ErrStorage
+}
diff --git a/handoff/store_test.go b/handoff/store_test.go
new file mode 100644
index 0000000..9f259a4
--- /dev/null
+++ b/handoff/store_test.go
@@ -0,0 +1,325 @@
+package handoff
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "database/sql"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo/assertion"
+ "amadan.net/rastrillo/rastrillo/crypto"
+ _ "modernc.org/sqlite"
+)
+
+type testSealer struct{ key []byte }
+
+func (s testSealer) Seal(p string, b []byte) ([]byte, error) {
+ return crypto.SealSym(crypto.Derive(s.key, p), b)
+}
+func (s testSealer) Open(p string, b []byte) ([]byte, error) {
+ return crypto.OpenSym(crypto.Derive(s.key, p), b)
+}
+
+type fixture struct {
+ s *Store
+ k *crypto.Keypair
+ now time.Time
+ dbpath string
+ db *sql.DB
+}
+
+func fresh(t *testing.T) fixture {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "handoff.db")
+ db, e := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
+ if e != nil {
+ t.Fatal(e)
+ }
+ db.SetMaxOpenConns(4)
+ t.Cleanup(func() { db.Close() })
+ schema, e := Migrations.ReadFile("migrations/0001_exchange.sql")
+ if e != nil {
+ t.Fatal(e)
+ }
+ if _, e = db.Exec(string(schema)); e != nil {
+ t.Fatal(e)
+ }
+ key := make([]byte, 32)
+ rand.Read(key)
+ k, e := crypto.Generate()
+ if e != nil {
+ t.Fatal(e)
+ }
+ s, e := NewStore(db, testSealer{key}, "https://id.example.com", "https://app.example.com", map[string][]byte{"k1": k.SignPub()})
+ if e != nil {
+ t.Fatal(e)
+ }
+ return fixture{s: s, k: k, now: time.Unix(1700000000, 0), dbpath: path, db: db}
+}
+func (f fixture) token(t *testing.T, nonce string) string {
+ t.Helper()
+ id, e := assertion.RandomID()
+ if e != nil {
+ t.Fatal(e)
+ }
+ s, e := assertion.Sign(f.k, assertion.Claims{Version: 1, Issuer: "https://id.example.com", Audience: "https://app.example.com", Subject: "person_123", KeyID: "k1", ID: id, Nonce: nonce, IssuedAt: f.now.Unix(), ExpiresAt: f.now.Unix() + 120, AuthTime: f.now.Unix() - 900})
+ if e != nil {
+ t.Fatal(e)
+ }
+ return s
+}
+func (f fixture) pending(t *testing.T) (string, string) {
+ t.Helper()
+ secret, nonce, e := f.s.Start(context.Background(), "/documents/private-canary?team=blue", "", f.now)
+ if e != nil {
+ t.Fatal(e)
+ }
+ return secret, nonce
+}
+func (f fixture) received(t *testing.T, nonce string) (string, string) {
+ t.Helper()
+ token := f.token(t, nonce)
+ handle, e := f.s.Receive(context.Background(), token, f.now)
+ if e != nil {
+ t.Fatal(e)
+ }
+ return handle, token
+}
+func count(t *testing.T, db *sql.DB, table string) int {
+ t.Helper()
+ var n int
+ if e := db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n); e != nil {
+ t.Fatal(e)
+ }
+ return n
+}
+func failFinish(t *testing.T, f fixture, secret, handle, subject string, now time.Time, want error) {
+ t.Helper()
+ c, p, e := f.s.Finish(context.Background(), secret, handle, subject, now)
+ if e != want || p != "" || !reflect.DeepEqual(c, assertion.Claims{}) {
+ t.Fatalf("finish refused: %v wanted %v zero=%v", e, want, reflect.DeepEqual(c, assertion.Claims{}))
+ }
+}
+
+func TestExchangeAndReplay(t *testing.T) {
+ f := fresh(t)
+ secret, nonce := f.pending(t)
+ handle, token := f.received(t, nonce)
+ c, path, e := f.s.Finish(context.Background(), secret, handle, "", f.now)
+ if e != nil || c.Subject != "person_123" || c.AuthTime != f.now.Unix()-900 || path != "/documents/private-canary?team=blue" {
+ t.Fatalf("finish: %v", e)
+ }
+ if count(t, f.db, "oficina_handoff_pending") != 0 || count(t, f.db, "oficina_handoff_inbox") != 0 || count(t, f.db, "oficina_handoff_replay") != 1 {
+ t.Fatal("consumption not atomic")
+ }
+ failFinish(t, f, secret, handle, "", f.now, ErrRefused)
+ if h, e := f.s.Receive(context.Background(), token, f.now); e != ErrRefused || h != "" {
+ t.Fatal("replayed callback accepted")
+ }
+ if count(t, f.db, "oficina_handoff_inbox") != 0 {
+ t.Fatal("replay allocated inbox")
+ }
+}
+func TestBrowserAndSubjectBinding(t *testing.T) {
+ f := fresh(t)
+ alice, an := f.pending(t)
+ bob, bn := f.pending(t)
+ ah, _ := f.received(t, an)
+ bh, _ := f.received(t, bn)
+ failFinish(t, f, alice, bh, "", f.now, ErrRefused)
+ failFinish(t, f, bob, ah, "", f.now, ErrRefused)
+ failFinish(t, f, alice, ah, "someone_else", f.now, ErrRefused)
+ if _, _, e := f.s.Finish(context.Background(), alice, ah, "person_123", f.now); e != nil {
+ t.Fatal("failed attempts consumed legitimate request", e)
+ }
+}
+func TestConcurrentFinishAndCallback(t *testing.T) {
+ f := fresh(t)
+ secret, nonce := f.pending(t)
+ token := f.token(t, nonce)
+ var mu sync.Mutex
+ var handles []string
+ var wg sync.WaitGroup
+ for range 12 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ h, e := f.s.Receive(context.Background(), token, f.now)
+ if e == nil {
+ mu.Lock()
+ handles = append(handles, h)
+ mu.Unlock()
+ } else if e != ErrRefused {
+ t.Error(e)
+ }
+ }()
+ }
+ wg.Wait()
+ if len(handles) != 1 {
+ t.Fatalf("callback winners %d", len(handles))
+ }
+ wins := 0
+ for range 12 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ _, _, e := f.s.Finish(context.Background(), secret, handles[0], "", f.now)
+ if e == nil {
+ mu.Lock()
+ wins++
+ mu.Unlock()
+ } else if e != ErrRefused {
+ t.Error(e)
+ }
+ }()
+ }
+ wg.Wait()
+ if wins != 1 {
+ t.Fatalf("finish winners %d", wins)
+ }
+}
+func TestReplacementAndExpiry(t *testing.T) {
+ f := fresh(t)
+ old, nonce := f.pending(t)
+ handle, _ := f.received(t, nonce)
+ if _, _, e := f.s.Start(context.Background(), "/", old, f.now); e != nil {
+ t.Fatal(e)
+ }
+ failFinish(t, f, old, handle, "", f.now, ErrRefused)
+ secret, nonce := f.pending(t)
+ handle, _ = f.received(t, nonce)
+ failFinish(t, f, secret, handle, "", f.now.Add(120*time.Second), ErrRefused)
+ if e := f.s.Sweep(context.Background(), f.now.Add(601*time.Second)); e != nil {
+ t.Fatal(e)
+ }
+ if count(t, f.db, "oficina_handoff_pending") != 0 || count(t, f.db, "oficina_handoff_inbox") != 0 {
+ t.Fatal("expired rows survive")
+ }
+}
+func TestClockFenceAndRestart(t *testing.T) {
+ f := fresh(t)
+ secret, nonce := f.pending(t)
+ handle, token := f.received(t, nonce)
+ if _, _, e := f.s.Finish(context.Background(), secret, handle, "", f.now); e != nil {
+ t.Fatal(e)
+ }
+ if e := f.s.Sweep(context.Background(), f.now.Add(150*time.Second)); e != nil {
+ t.Fatal(e)
+ }
+ if count(t, f.db, "oficina_handoff_replay") != 1 {
+ t.Fatal("replay pruned at retention boundary")
+ }
+ if e := f.s.Sweep(context.Background(), f.now.Add(151*time.Second)); e != nil {
+ t.Fatal(e)
+ }
+ if count(t, f.db, "oficina_handoff_replay") != 0 {
+ t.Fatal("old replay not pruned")
+ }
+ if e := f.db.Close(); e != nil {
+ t.Fatal(e)
+ }
+ db, e := sql.Open("sqlite", f.dbpath)
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer db.Close()
+ f.s.db = db
+ if e = f.s.Sweep(context.Background(), f.now.Add(121*time.Second)); e != nil {
+ t.Fatal("tolerated rollback refused", e)
+ }
+ if _, e = f.s.Receive(context.Background(), token, f.now.Add(121*time.Second)); e != ErrRefused {
+ t.Fatal("pruned token reappeared", e)
+ }
+ if e = f.s.Sweep(context.Background(), f.now.Add(120*time.Second)); e != ErrClock {
+ t.Fatal("large rollback not fenced", e)
+ }
+ if replayRetention < clockTolerance {
+ t.Fatal("clock rollback exceeds retained history")
+ }
+}
+func TestStorageFailureDoesNotReturnIdentity(t *testing.T) {
+ f := fresh(t)
+ secret, nonce := f.pending(t)
+ handle, _ := f.received(t, nonce)
+ if _, e := f.db.Exec(`CREATE TRIGGER break_delete BEFORE DELETE ON oficina_handoff_pending BEGIN SELECT RAISE(ABORT,'synthetic failure'); END`); e != nil {
+ t.Fatal(e)
+ }
+ failFinish(t, f, secret, handle, "", f.now, ErrStorage)
+ if count(t, f.db, "oficina_handoff_replay") != 0 || count(t, f.db, "oficina_handoff_pending") != 1 || count(t, f.db, "oficina_handoff_inbox") != 1 {
+ t.Fatal("partial transaction survived")
+ }
+}
+func TestPrivateValuesNeverReachDBOrWAL(t *testing.T) {
+ f := fresh(t)
+ secret, nonce := f.pending(t)
+ handle, token := f.received(t, nonce)
+ for _, path := range []string{f.dbpath, f.dbpath + "-wal"} {
+ b, e := os.ReadFile(path)
+ if e != nil {
+ t.Fatal(e)
+ }
+ for _, value := range []string{secret, nonce, handle, token, "private-canary", "?team=blue", "person_123"} {
+ if bytes.Contains(b, []byte(value)) {
+ t.Fatalf("plaintext leaked in %s", filepath.Base(path))
+ }
+ }
+ }
+}
+func TestBounds(t *testing.T) {
+ f := fresh(t)
+ tx, e := f.db.Begin()
+ if e != nil {
+ t.Fatal(e)
+ }
+ for i := 0; i < rowLimit; i++ {
+ if _, e = tx.Exec(`INSERT INTO oficina_handoff_pending(secret_hash,nonce_hash,sealed_path,created_at,expires_at) VALUES(printf('%d',?),printf('%d',?),x'00',?,?)`, i, i, f.now.Unix()-1, f.now.Unix()+600); e != nil {
+ t.Fatal(e)
+ }
+ }
+ if e = tx.Commit(); e != nil {
+ t.Fatal(e)
+ }
+ _, nonce := f.pending(t)
+ if count(t, f.db, "oficina_handoff_pending") != rowLimit {
+ t.Fatal("pending cap not bounded")
+ }
+ tx, e = f.db.Begin()
+ if e != nil {
+ t.Fatal(e)
+ }
+ for i := 0; i < rowLimit; i++ {
+ if _, e = tx.Exec(`INSERT INTO oficina_handoff_inbox(handle_hash,jti_hash,sealed_token,created_at,expires_at) VALUES(printf('%d',?),printf('%d',?),x'00',?,?)`, i, i, f.now.Unix(), f.now.Unix()+120); e != nil {
+ t.Fatal(e)
+ }
+ }
+ if e = tx.Commit(); e != nil {
+ t.Fatal(e)
+ }
+ if h, e := f.s.Receive(context.Background(), f.token(t, nonce), f.now); e != ErrFull || h != "" {
+ t.Fatal("full inbox admitted", e)
+ }
+}
+func TestSafePathsAndNonce(t *testing.T) {
+ for _, s := range []string{"/", "/documents/a?team=1", "/sheets?q=hello%20world"} {
+ if !SafePath(s) {
+ t.Fatal(s)
+ }
+ }
+ for _, s := range []string{"", "//evil.example/", "https://evil.example/", "/\\evil", "/%2f%2fevil", "/%2e%2e/auth/start", "/auth/start", "/%61uth/start", "/signin", "/authorize/resume", "/a\n", "/a#fragment", strings.Repeat("a", 2049)} {
+ if SafePath(s) {
+ t.Fatal(s)
+ }
+ }
+ for _, s := range []string{"", "A", "AAAAAAAAAAAAAAAAAAAAAA\n", "AAAAAAAAAAAAAAAAAAAAAB"} {
+ if ValidNonce(s) {
+ t.Fatal("invalid nonce accepted")
+ }
+ }
+}
diff --git a/implementation/team-entry.md b/implementation/team-entry.md
index 14cfa94..9ccfe9e 100644
--- a/implementation/team-entry.md
+++ b/implementation/team-entry.md
@@ -1,6 +1,6 @@
# Home and team app entry
-Status: implementation proposal, 2026-09-08. The user has prioritized docs.oficina.works → Home sign-in → team choice → team Docs, and accepted the existing team-subdomain shape. This branch starts with the shared Home/directory exchange; the acceptance target still includes Docs and two teams. Copy review is waived for this round. Fable reviews each implementation stage.
+Status: bounded Home/directory transport design approved by Claude Fable 5.1 at 36f876c on 2026-09-08; code still requires review. The user has prioritized docs.oficina.works → Home sign-in → team choice → team Docs, and accepted the existing team-subdomain shape. This branch starts with the shared Home/directory exchange; the acceptance target still includes Docs and two teams. Copy review is waived for this round. Fable reviews each implementation stage.
## Deployment and authority
@@ -47,3 +47,8 @@ Fable reviewed d116692 and found a functional referrer-policy bug, missing expli
Update the Home spec in the implementation commit: §4/§10 describe the explicit role topology; §5.3 records P-256, signed nonce/auth_time and pinned pilot keys; claims omit optional prefs and roster_epoch in this bounded stage rather than falsely claiming those consumers are implemented. Preserve their later contracts. Amend script ruling 21 with the current user's approved automatic-return requirement and the narrowly chosen static handoff enhancement; label the implementation decision honestly, not as a verbatim historical ruling. Extend scriptAllowed with exactly the handoff template and static path, credentialReturn with /authorize/resume, and existing privacy/log gates with sealed inbox/pending, replay, directory session and known assertion-byte canaries.
Reuse the consumer exchange across Home and Docs rather than implementing two subtly different callbacks. The framework's pure core approval does not approve this adapter: its storage/HTTP implementation will have its own tests and review. A shared consumer accepts the application's database handle, sealer and session-issuance callback; it does not hold memberships or item grants. Keep legacy app authentication working while adding opaque Home subject mapping; do not require a full framework/UI migration merely to import the pure assertion primitive.
+
+
+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.