Add reusable browser-bound Home assertion consumer
9 files changed,
+1194
−1
Makefile+4 −0handoff/go.mod+21 −0handoff/go.sum+55 −0handoff/http.go+209 −0handoff/http_test.go+186 −0handoff/migrations/0001_exchange.sql+26 −0handoff/store.go+362 −0handoff/store_test.go+325 −0implementation/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. |