Merge branch 'feat/shared-connect' into main
25 files changed,
+3705
−0
Makefile+6 −0connect/README.md+89 −0connect/api.go+73 −0connect/bridge.go+285 −0connect/bridge_test.go+487 −0connect/config.go+106 −0connect/config_test.go+191 −0connect/go.mod+23 −0connect/go.sum+59 −0connect/link_handlers.go+114 −0connect/linkstore/flows.go+326 −0connect/linkstore/migrations/0001_links.sql+19 −0connect/linkstore/migrations/0002_person_binding.sql+1 −0connect/linkstore/store.go+236 −0connect/linkstore/store_test.go+647 −0connect/navigation.go+164 −0connect/navigation_negative_test.go+126 −0connect/navigation_return.go+79 −0connect/navigation_return_test.go+82 −0connect/navigation_test.go+132 −0connect/oauth.go+92 −0connect/oauth_test.go+48 −0connect/routes.go+163 −0connect/routes_test.go+102 −0connect/view.go+55 −0
CI passed — run details
diff --git a/Makefile b/Makefile| index 7b90e7a..e3638a0 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -1,3 +1,5 @@ |
| +export GOPRIVATE := $(GOPRIVATE),amadan.net/oficina/estilo.git |
| + |
| private_comma := , |
| export GOPRIVATE := $(if $(GOPRIVATE),$(GOPRIVATE)$(private_comma))amadan.net/oficina/estilo.git |
| @@ -24,6 +26,7 @@ test-vet: |
| CGO_ENABLED=0 go vet ./... |
| cd handoff && CGO_ENABLED=0 go vet ./... |
| cd roster && CGO_ENABLED=0 go vet ./... |
| + cd connect && 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 |
| @@ -40,11 +43,13 @@ test-build: |
| CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ./cmd/home ./cmd/oficina |
| cd handoff && CGO_ENABLED=0 go build ./... |
| cd roster && CGO_ENABLED=0 go build ./... |
| + cd connect && CGO_ENABLED=0 go build ./... |
| test-go: |
| CGO_ENABLED=0 go test -count=1 ./... |
| cd handoff && CGO_ENABLED=0 go test -count=1 ./... |
| cd roster && CGO_ENABLED=0 go test -count=1 ./... |
| + cd connect && 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 |
| @@ -60,3 +65,4 @@ test-race: |
| go test -race -count=1 ./internal/home/ ./internal/operator/ ./internal/provisioner/ ./internal/home/directorydata/ |
| cd handoff && CGO_ENABLED=1 go test -race -count=1 ./... |
| cd roster && CGO_ENABLED=1 go test -race -count=1 ./... |
| + cd connect && CGO_ENABLED=1 go test -race -count=1 ./... |
diff --git a/connect/README.md b/connect/README.md| new file mode 100644 |
| index 0000000..6cc18bf |
| --- /dev/null |
| +++ b/connect/README.md |
| @@ -0,0 +1,89 @@ |
| +# Connect an app to Home |
| + |
| +`connect` lets an app keep its existing local accounts and accept Home sign-in |
| +after the person explicitly links both identities. The app still decides who |
| +may enter and what they may access. |
| + |
| +Load optional settings with `CredentialsFromEnv("SHEETS")`, then validate them |
| +with `DecodeCredentials(settings, origin)` once you know the app's origin. |
| +The variables end in `_HOME_ISSUER`, `_HOME_CONSUMER_ID`, `_HOME_KEYS_BASE64` |
| +and `_HOME_DEK_BASE64`. All empty means disabled; a partial configuration fails. |
| +Use a dedicated random 32-byte storage key and a distinct namespace per app. |
| +Keep this key stable: it protects proof state and keyed local-person bindings. |
| +Replacing it without a migration makes existing links unusable; rotation requires |
| +an explicit migration with both keys, which this package does not yet provide. |
| + |
| +Apply `handoff.Migrations` and `linkstore.Migrations` using your framework's |
| +migration package. The latter's namespace is `linkstore.SchemaNamespace`. |
| +This package imports neither framework session nor migration types, so old |
| +and new Rastrillo apps can share the same protocol. |
| + |
| +Construct `Bridge` with the hooks in `Config`. Read the actual session cookie; |
| +`MintHomeSession` stores the opaque Home subject with method `HomeMethod` and |
| +the supplied authentication time. `LookupLocalSession` reads an existing |
| +local account. `LookupPerson` uses its supplied SQL query object and rechecks |
| +current admission; return `sql.ErrNoRows` for missing or disallowed accounts. |
| +Neither hook creates accounts, samples or identity mappings. The returned label |
| +must be the canonical verified local address, never an editable display name. |
| +Local IDs must never be reused, even for a new account with the same address. |
| +Delete the account's `home_identity_links` rows in the same transaction as the |
| +local account. The shared schema cannot impose a foreign key on app-owned tables. |
| +A keyed binding detects reuse with a different address at every proof stage and |
| +resolution. Address changes require explicit removal and fresh linking; old or |
| +unbound links fail closed, rather than adopting the current account. |
| + |
| +Session cookie values must be exactly 43 characters of canonical unpadded |
| +base64url encoding of 32 bytes. `Session.AuthTime` is the method's actual |
| +verification time. `Session.At` is immutable verified-session creation time, |
| +never last-seen time or request time: legacy magic-link sessions can use it when |
| +`AuthTime` is absent. Keymail sessions cannot use that fallback. |
| + |
| +On every authenticated app request, resolve Home sessions through |
| +`Bridge.ResolveSession`. Use the returned local ID for ownership and grants. |
| +Never pass an opaque Home subject through a helper that treats subjects as |
| +email addresses or creates a local account. Keep ordinary local sign-in's |
| +existing behavior in the app's local authentication path. |
| + |
| +`Routes` supplies shared navigation, local reauthentication markers and the |
| +narrow callback exception to your app's CSRF middleware. Mount its methods |
| +alongside `Bridge.Start`, `Callback`, `Finish` and `ConfirmPage`. The local |
| +plugin's successful return must be `LocalCompletePath`. Its pending cookie must |
| +be `__Host-rastrillo_pending`, and the local entry is `/signin/local`. |
| +Mount exactly `Bridge.Callback` at `POST /auth/assertion`: this path alone bypasses |
| +CSRF protection because the bridge verifies its browser-bound assertion. |
| +`LocalLandingPath` optionally sets a validated local destination after ordinary |
| +local sign-in (default `/`); explicit account-link navigation takes precedence. Render shared pages |
| +inside the app's existing Estilo layout; all paths work without JavaScript. |
| +Apps with additional content-security directives must supply their existing |
| +policy with the validated Home issuer added to `form-action`. |
| + |
| +Wrap the trusted local plugin's Begin handler with `OAuthContinuation` when it |
| +can redirect to Keymail. Render the supplied destination with `OAuthPage` and the |
| +shared `home-oauth-head` and `home-oauth` templates inside your existing layout. |
| +The helper validates the client origin, `/auth/callback`, scope and PKCE method, |
| +then ends the POST with a no-store continuation page so Safari does not apply |
| +form-action to Keymail's redirect chain. It does not discover or trust arbitrary |
| +request-provided destinations. Keep the existing local plugin callback route. |
| + |
| +## Extraction record |
| + |
| +The link proof protocol, bridge, freshness rules and navigation originate in |
| +Oficina Docs commit `3fffd1be535ec87ab6e92da753046a4f1e4c754c`, whose tree |
| +matches reviewed deployment `a8f5c0e1d67ad042e89793aa941135eacd98aa23`. |
| +The original files live under `internal/docs/home_*` and `internal/docs/linkstore`. |
| +This extraction preserves the consumed proof stages, nonce/session binding, |
| +clock watermark, freshness windows and encrypted flow purpose strings. |
| + |
| +Changes are the neutral session DTO and app lookup hooks, new `home_*` link |
| +tables, app-selected encryption namespace, and shared rendering/navigation. |
| +The local-person foreign key becomes a transaction-scoped existence/admission |
| +lookup, because apps use different local identity tables. Every link write |
| +and subsequent resolution still checks the current local account. Additive |
| +migration 0002 adds a keyed person binding; earlier unbound rows fail closed. |
| +OAuth continuation also derives from Docs `internal/docs/auth_navigation.go`. |
| + |
| +The original Docs state-machine tests were ported with synthetic session and |
| +local-account hooks. The app-owned content-preservation test now checks an |
| +unchanged content row; framework CSRF coverage belongs to the shared Routes |
| +and each app's router integration tests. Existing Docs tables and bytes are |
| +untouched. This is not a migration path for an already deployed Docs instance. |
diff --git a/connect/api.go b/connect/api.go| new file mode 100644 |
| index 0000000..91aee15 |
| --- /dev/null |
| +++ b/connect/api.go |
| @@ -0,0 +1,73 @@ |
| +// Package connect adapts the Home handoff to an application's existing identity. |
| +// It grants no membership and never creates local accounts or sample content. |
| +package connect |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "net/http" |
| + "time" |
| + |
| + "amadan.net/oficina/home/connect/linkstore" |
| +) |
| + |
| +const HomeMethod = "home-assertion" |
| + |
| +// Session times must come from the verified stored session, never request or last-seen time. |
| +// AuthTime is the method verification time; At is immutable session creation time. |
| +type Session struct { |
| + Subject, Method string |
| + AuthTime, At time.Time |
| +} |
| + |
| +// Person.Label is the canonical verified local address, never a display name. |
| +// Local IDs must never be reused, including for a new account with the same address. |
| +type Person struct { |
| + ID int64 |
| + Label string |
| +} |
| + |
| +type Config struct { |
| + DB *sql.DB |
| + Origin, Issuer, ConsumerID, Namespace, SessionCookie string |
| + Keys map[string][]byte |
| + DEK []byte |
| + // ReadSession must resolve the actual verified session, never a supplied subject. |
| + ReadSession func(*http.Request) (*Session, error) |
| + MintHomeSession func(http.ResponseWriter, *http.Request, Session) error |
| + // Both lookup hooks must read current existing, admitted local accounts. |
| + // They never create accounts or infer a Home link from an email address. |
| + LookupLocalSession func(context.Context, Session) (Person, error) |
| + LookupPerson linkstore.LookupPerson |
| + Render func(http.ResponseWriter, *http.Request, string, string, int) |
| + ReturnPath func(*http.Request) string |
| + Now func() time.Time |
| +} |
| + |
| +func (b *Bridge) Resolve(ctx context.Context, subject string) (Person, error) { |
| + id, label, err := b.links.Resolve(ctx, b.issuer, subject) |
| + return Person{ID: id, Label: label}, err |
| +} |
| +func (b *Bridge) ResolveSession(r *http.Request) (Person, error) { |
| + session, err := b.actualSession(r) |
| + if err != nil || session == nil { |
| + return Person{}, errIdentityRefused |
| + } |
| + return b.existingPerson(r, *session) |
| +} |
| + |
| +// FreshLocal accepts only independent local verification. A Keymail session |
| +// without auth_time cannot borrow its local session creation time. |
| +func FreshLocal(s Session, now time.Time) (time.Time, bool) { |
| + if s.Subject == "" || (s.Method != "magiclink" && s.Method != "keymail") { |
| + return time.Time{}, false |
| + } |
| + at := s.AuthTime |
| + if at.IsZero() && s.Method == "magiclink" { |
| + at = s.At |
| + } |
| + if at.IsZero() || at.After(now) || now.Sub(at) > 5*time.Minute { |
| + return time.Time{}, false |
| + } |
| + return at, true |
| +} |
diff --git a/connect/bridge.go b/connect/bridge.go| new file mode 100644 |
| index 0000000..245a441 |
| --- /dev/null |
| +++ b/connect/bridge.go |
| @@ -0,0 +1,285 @@ |
| +package connect |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "errors" |
| + "net/http" |
| + "strings" |
| + "time" |
| + |
| + "amadan.net/oficina/home/connect/linkstore" |
| + "amadan.net/oficina/home/handoff" |
| + "amadan.net/oficina/home/handoff/seal" |
| + "amadan.net/rastrillo/rastrillo/assertion" |
| +) |
| + |
| +const homeInitCookie = "__Host-oficina-home-init" |
| +const homeProofCookie = "__Host-oficina-home-proof" |
| + |
| +var errHomeBridge = errors.New("home connect: exchange unavailable") |
| +var errIdentityRefused = errors.New("home connect: identity refused") |
| + |
| +type Bridge struct { |
| + origin, issuer, sessionCookie string |
| + namespace string |
| + keyHash [32]byte |
| + links *linkstore.Store |
| + ordinary, fresh *handoff.Consumer |
| + now func() time.Time |
| + returnPath func(*http.Request) string |
| + readSession func(*http.Request) (*Session, error) |
| + mintSession func(http.ResponseWriter, *http.Request, Session) error |
| + localPerson func(context.Context, Session) (Person, error) |
| + render func(http.ResponseWriter, *http.Request, string, string, int) |
| +} |
| +type homeFinishKey struct{} |
| +type homeFinishResult struct { |
| + session *Session |
| + init string |
| + state string |
| + status int |
| +} |
| + |
| +// Both subsystem schemas must be migrated before construction. The consumers |
| +// share one replay store; choosing a different Finish cannot weaken freshness. |
| +func New(c Config) (*Bridge, error) { |
| + if c.DB == nil || c.ReadSession == nil || c.MintHomeSession == nil || c.LookupLocalSession == nil || c.LookupPerson == nil || c.Render == nil || !validSessionCookie(c.SessionCookie) { |
| + return nil, errHomeBridge |
| + } |
| + sealer, err := seal.New(c.DEK, c.Namespace) |
| + if err != nil { |
| + return nil, errHomeBridge |
| + } |
| + exchange, err := handoff.NewStore(c.DB, sealer, c.Issuer, c.Origin, c.Keys) |
| + if err != nil { |
| + return nil, errHomeBridge |
| + } |
| + linker, err := linkstore.New(c.DB, c.DEK, c.Origin, c.Issuer, c.Namespace, c.LookupPerson) |
| + if err != nil { |
| + return nil, errHomeBridge |
| + } |
| + now := c.Now |
| + if now == nil { |
| + now = time.Now |
| + } |
| + b := &Bridge{namespace: c.Namespace, keyHash: sha256.Sum256(c.DEK), origin: c.Origin, issuer: c.Issuer, sessionCookie: c.SessionCookie, links: linker, now: now, returnPath: c.ReturnPath, readSession: c.ReadSession, mintSession: c.MintHomeSession, localPerson: c.LookupLocalSession, render: c.Render} |
| + cfg := handoff.ConsumerConfig{Store: exchange, ConsumerID: c.ConsumerID, Complete: b.complete, Fail: b.fail, Now: func() time.Time { return b.now() }, CurrentSubject: func(r *http.Request) string { |
| + result, _ := r.Context().Value(homeFinishKey{}).(*homeFinishResult) |
| + if result != nil && result.session != nil && result.session.Method == HomeMethod { |
| + return result.session.Subject |
| + } |
| + return "" |
| + }} |
| + b.ordinary, err = handoff.NewConsumer(cfg) |
| + if err != nil { |
| + return nil, errHomeBridge |
| + } |
| + cfg.RequireFreshAuth = true |
| + cfg.BindStart = func(r *http.Request, nonce string) error { |
| + secret, present, ok := homeCookie(r, homeInitCookie) |
| + if !present || !ok { |
| + return errIdentityRefused |
| + } |
| + local, _, err := b.localBinding(r) |
| + if err != nil { |
| + return err |
| + } |
| + return b.links.BindNonce(r.Context(), secret, local, nonce, b.now()) |
| + } |
| + b.fresh, err = handoff.NewConsumer(cfg) |
| + if err != nil { |
| + return nil, errHomeBridge |
| + } |
| + if exchange.Sweep(context.Background(), b.now()) != nil || linker.Sweep(context.Background(), b.now()) != nil { |
| + return nil, errHomeBridge |
| + } |
| + return b, nil |
| +} |
| + |
| +// homeCookie distinguishes absent cookies from malformed or duplicate values. |
| +// Neither a duplicate nor an invalid link cookie may select ordinary sign-in. |
| +func homeCookie(r *http.Request, name string) (value string, present, valid bool) { |
| + count := 0 |
| + for _, c := range r.Cookies() { |
| + if c.Name == name { |
| + count++ |
| + value = c.Value |
| + } |
| + } |
| + if count == 0 { |
| + return "", false, true |
| + } |
| + if count != 1 || len(value) != 43 { |
| + return "", true, false |
| + } |
| + raw, err := base64.RawURLEncoding.Strict().DecodeString(value) |
| + return value, true, err == nil && len(raw) == 32 && base64.RawURLEncoding.EncodeToString(raw) == value |
| +} |
| +func setHomeCookie(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 (b *Bridge) actualSession(r *http.Request) (*Session, error) { |
| + _, present, ok := homeCookie(r, b.sessionCookie) |
| + if !ok { |
| + return nil, errIdentityRefused |
| + } |
| + if !present { |
| + return nil, nil |
| + } |
| + session, err := b.readSession(r) |
| + if err != nil || session == nil { |
| + return nil, errIdentityRefused |
| + } |
| + return session, nil |
| +} |
| +func (b *Bridge) Start(w http.ResponseWriter, r *http.Request) { |
| + _, present, ok := homeCookie(r, homeInitCookie) |
| + if !ok { |
| + b.fail(w, r, http.StatusBadRequest) |
| + return |
| + } |
| + // Duplicate auth cookies must fail before a fresh hook or future mint. |
| + if _, err := b.actualSession(r); err != nil { |
| + b.fail(w, r, http.StatusForbidden) |
| + return |
| + } |
| + if present { |
| + b.fresh.Start(w, r) |
| + } else { |
| + b.ordinary.Start(w, r) |
| + } |
| +} |
| +func (b *Bridge) Callback(w http.ResponseWriter, r *http.Request) { b.ordinary.Callback(w, r) } |
| +func (b *Bridge) Finish(w http.ResponseWriter, r *http.Request) { |
| + init, _, ok := homeCookie(r, homeInitCookie) |
| + if !ok { |
| + b.fail(w, r, http.StatusBadRequest) |
| + return |
| + } |
| + _, _, ok = homeCookie(r, homeProofCookie) |
| + if !ok { |
| + b.fail(w, r, http.StatusBadRequest) |
| + return |
| + } |
| + session, err := b.actualSession(r) |
| + if err != nil { |
| + b.fail(w, r, http.StatusForbidden) |
| + return |
| + } |
| + result := &homeFinishResult{session: session, init: init} |
| + b.ordinary.Finish(w, r.WithContext(context.WithValue(r.Context(), homeFinishKey{}, result))) |
| +} |
| +func (b *Bridge) fail(w http.ResponseWriter, r *http.Request, status int) { |
| + state := "failed" |
| + if result, ok := r.Context().Value(homeFinishKey{}).(*homeFinishResult); ok && result.state != "" { |
| + state, status = result.state, result.status |
| + } |
| + b.render(w, r, state, "", status) |
| +} |
| +func rejectHome(r *http.Request, state string, status int) error { |
| + if result, ok := r.Context().Value(homeFinishKey{}).(*homeFinishResult); ok { |
| + result.state, result.status = state, status |
| + } |
| + return errIdentityRefused |
| +} |
| +func (b *Bridge) complete(w http.ResponseWriter, r *http.Request, claims assertion.Claims) error { |
| + result, ok := r.Context().Value(homeFinishKey{}).(*homeFinishResult) |
| + if !ok { |
| + return errHomeBridge |
| + } |
| + if result.init != "" { |
| + setHomeCookie(w, homeInitCookie, "", -1) |
| + local, _, err := b.localBinding(r) |
| + if err != nil { |
| + return rejectHome(r, "failed", http.StatusForbidden) |
| + } |
| + secret, err := b.links.Complete(r.Context(), result.init, local, linkstore.Home{Issuer: claims.Issuer, Subject: claims.Subject, Nonce: claims.Nonce, AuthTime: time.Unix(claims.AuthTime, 0)}, b.now()) |
| + if errors.Is(err, linkstore.ErrRefused) { |
| + return rejectHome(r, "failed", http.StatusForbidden) |
| + } |
| + if err != nil { |
| + return errHomeBridge |
| + } |
| + setHomeCookie(w, homeProofCookie, secret, 300) |
| + return nil |
| + } |
| + id, _, err := b.links.Resolve(r.Context(), claims.Issuer, claims.Subject) |
| + if errors.Is(err, linkstore.ErrRefused) { |
| + return rejectHome(r, "unknown", http.StatusForbidden) |
| + } |
| + if err != nil { |
| + return errHomeBridge |
| + } |
| + if result.session != nil { |
| + person, err := b.existingPerson(r, *result.session) |
| + if err != nil || person.ID != id { |
| + return rejectHome(r, "failed", http.StatusForbidden) |
| + } |
| + } |
| + if err := b.mintSession(w, r, Session{Subject: claims.Subject, Method: HomeMethod, AuthTime: time.Unix(claims.AuthTime, 0)}); err != nil { |
| + return errHomeBridge |
| + } |
| + return nil |
| +} |
| + |
| +// existingPerson never creates a person or samples, even during ordinary SSO. |
| +func (b *Bridge) existingPerson(r *http.Request, session Session) (Person, error) { |
| + if session.Method == HomeMethod { |
| + return b.Resolve(r.Context(), session.Subject) |
| + } |
| + if session.Method != "magiclink" && session.Method != "keymail" { |
| + return Person{}, errIdentityRefused |
| + } |
| + p, err := b.localPerson(r.Context(), session) |
| + if err != nil || p.ID <= 0 || p.Label == "" { |
| + return Person{}, errIdentityRefused |
| + } |
| + return p, nil |
| +} |
| +func (b *Bridge) localBinding(r *http.Request) (linkstore.Local, Person, error) { |
| + session, err := b.actualSession(r) |
| + if err != nil || session == nil { |
| + return linkstore.Local{}, Person{}, errIdentityRefused |
| + } |
| + at, fresh := FreshLocal(*session, b.now()) |
| + if !fresh { |
| + return linkstore.Local{}, Person{}, errIdentityRefused |
| + } |
| + person, err := b.existingPerson(r, *session) |
| + if err != nil { |
| + return linkstore.Local{}, Person{}, err |
| + } |
| + token, _, _ := homeCookie(r, b.sessionCookie) |
| + return linkstore.Local{PersonID: person.ID, SessionToken: token, AuthTime: at}, person, nil |
| +} |
| +func (b *Bridge) guard(w http.ResponseWriter, r *http.Request, method, path string) bool { |
| + w.Header().Set("Cache-Control", "no-store") |
| + if r.Host != strings.TrimPrefix(b.origin, "https://") || r.URL.Path != path { |
| + b.fail(w, r, http.StatusNotFound) |
| + return false |
| + } |
| + if r.Method != method { |
| + w.Header().Set("Allow", method) |
| + b.fail(w, r, http.StatusMethodNotAllowed) |
| + return false |
| + } |
| + if r.URL.RawQuery != "" { |
| + b.fail(w, r, http.StatusBadRequest) |
| + return false |
| + } |
| + return true |
| +} |
| + |
| +func validSessionCookie(name string) bool { |
| + if !strings.HasPrefix(name, "__Host-") || len(name) <= 7 || len(name) > 128 { |
| + return false |
| + } |
| + for _, c := range name { |
| + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
diff --git a/connect/bridge_test.go b/connect/bridge_test.go| new file mode 100644 |
| index 0000000..ffa0569 |
| --- /dev/null |
| +++ b/connect/bridge_test.go |
| @@ -0,0 +1,487 @@ |
| +package connect |
| + |
| +import ( |
| + "amadan.net/oficina/home/connect/linkstore" |
| + "amadan.net/oficina/home/handoff" |
| + "amadan.net/rastrillo/rastrillo/assertion" |
| + assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| + "bytes" |
| + "context" |
| + "crypto/rand" |
| + "database/sql" |
| + "encoding/base64" |
| + "io/fs" |
| + _ "modernc.org/sqlite" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +const bridgeOrigin = "https://blue.docs.oficina.works" |
| +const bridgeIssuer = "https://id.oficina.works" |
| + |
| +type fakeSessions struct { |
| + cookie string |
| + values map[string]Session |
| +} |
| + |
| +func (f *fakeSessions) Mint(s Session) (string, error) { |
| + raw := make([]byte, 32) |
| + if _, err := rand.Read(raw); err != nil { |
| + return "", err |
| + } |
| + token := base64.RawURLEncoding.EncodeToString(raw) |
| + s.At = time.Now() |
| + f.values[token] = s |
| + return token, nil |
| +} |
| +func (f *fakeSessions) From(r *http.Request) (Session, bool) { |
| + c, e := r.Cookie(f.cookie) |
| + if e != nil { |
| + return Session{}, false |
| + } |
| + s, ok := f.values[c.Value] |
| + return s, ok |
| +} |
| + |
| +type bridgeFixture struct { |
| + b *Bridge |
| + db *sql.DB |
| + key *assertioncrypto.Keypair |
| + sessions *fakeSessions |
| +} |
| + |
| +func newBridgeFixture(t *testing.T) *bridgeFixture { |
| + t.Helper() |
| + d, e := sql.Open("sqlite", filepath.Join(t.TempDir(), "connect.db")+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + for _, schema := range []fs.FS{handoff.Migrations, linkstore.Migrations} { |
| + files, e := fs.Glob(schema, "migrations/*.sql") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + for _, name := range files { |
| + raw, e := fs.ReadFile(schema, name) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = d.Exec(string(raw)); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + } |
| + if _, e = d.Exec("CREATE TABLE people(id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE)"); e != nil { |
| + t.Fatal(e) |
| + } |
| + key, e := assertioncrypto.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + sessions := &fakeSessions{cookie: "__Host-local-session", values: map[string]Session{}} |
| + f := &bridgeFixture{db: d, key: key, sessions: sessions} |
| + cfg := Config{DB: d, Origin: bridgeOrigin, Issuer: bridgeIssuer, ConsumerID: "blue-docs", Namespace: "oficina/docs", DEK: bytes.Repeat([]byte{41}, 32), Keys: map[string][]byte{"key": key.SignPub()}, SessionCookie: sessions.cookie, |
| + ReadSession: func(r *http.Request) (*Session, error) { |
| + s, ok := sessions.From(r) |
| + if !ok { |
| + return nil, nil |
| + } |
| + return &s, nil |
| + }, |
| + MintHomeSession: func(w http.ResponseWriter, r *http.Request, s Session) error { |
| + token, e := sessions.Mint(s) |
| + if e != nil { |
| + return e |
| + } |
| + http.SetCookie(w, &http.Cookie{Name: sessions.cookie, Value: token, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}) |
| + return nil |
| + }, |
| + LookupLocalSession: func(ctx context.Context, s Session) (Person, error) { |
| + var p Person |
| + e := d.QueryRowContext(ctx, "SELECT id,email FROM people WHERE email=?", s.Subject).Scan(&p.ID, &p.Label) |
| + return p, e |
| + }, |
| + LookupPerson: func(ctx context.Context, q linkstore.Queryer, id int64) (string, error) { |
| + var label string |
| + e := q.QueryRowContext(ctx, "SELECT email FROM people WHERE id=? AND email<>''", id).Scan(&label) |
| + return label, e |
| + }, |
| + Render: func(w http.ResponseWriter, r *http.Request, state, label string, status int) { |
| + w.WriteHeader(status) |
| + w.Write([]byte(state)) |
| + }} |
| + f.b, e = New(cfg) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return f |
| +} |
| +func bridgeRequest(method, path string, cookies []*http.Cookie) *http.Request { |
| + r := httptest.NewRequest(method, bridgeOrigin+path, nil) |
| + for _, cookie := range cookies { |
| + r.AddCookie(cookie) |
| + } |
| + return r |
| +} |
| +func bridgeCookie(t *testing.T, w *httptest.ResponseRecorder, name string) *http.Cookie { |
| + t.Helper() |
| + for _, cookie := range w.Result().Cookies() { |
| + if cookie.Name == name && cookie.MaxAge >= 0 { |
| + if !cookie.Secure || !cookie.HttpOnly || cookie.Domain != "" || cookie.Path != "/" || cookie.SameSite != http.SameSiteLaxMode { |
| + t.Fatal("unsafe cookie attributes") |
| + } |
| + return cookie |
| + } |
| + } |
| + t.Fatalf("missing cookie %s; status=%d", name, w.Code) |
| + return nil |
| +} |
| +func bridgeNoSession(t *testing.T, b *Bridge, w *httptest.ResponseRecorder) { |
| + t.Helper() |
| + for _, c := range w.Result().Cookies() { |
| + if c.Name == b.sessionCookie { |
| + t.Fatal("unexpected app session mutation") |
| + } |
| + } |
| +} |
| +func (f *bridgeFixture) local(t *testing.T, address, method string, authTime time.Time) *http.Cookie { |
| + t.Helper() |
| + if _, err := f.db.Exec("INSERT INTO people(email) VALUES(?) ON CONFLICT DO NOTHING", address); err != nil { |
| + t.Fatal(err) |
| + } |
| + token, err := f.sessions.Mint(Session{Subject: address, Method: method, AuthTime: authTime}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return &http.Cookie{Name: f.b.sessionCookie, Value: token} |
| +} |
| +func (f *bridgeFixture) start(t *testing.T, cookies []*http.Cookie, path string) (*http.Cookie, string) { |
| + t.Helper() |
| + w := httptest.NewRecorder() |
| + f.b.Start(w, bridgeRequest(http.MethodGet, "/auth/start?return_to="+url.QueryEscape(path), cookies)) |
| + if w.Code != 303 { |
| + t.Fatalf("start=%d body=%s", w.Code, w.Body.String()) |
| + } |
| + target, err := url.Parse(w.Header().Get("Location")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return bridgeCookie(t, w, "__Host-oficina-assertion-pending"), target.Query().Get("nonce") |
| +} |
| +func (f *bridgeFixture) callback(t *testing.T, subject, nonce string, authTime time.Time) *http.Cookie { |
| + t.Helper() |
| + now := time.Now().Unix() |
| + id, err := assertion.RandomID() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + token, err := assertion.Sign(f.key, assertion.Claims{Version: 1, Issuer: bridgeIssuer, Audience: bridgeOrigin, Subject: subject, KeyID: "key", ID: id, Nonce: nonce, IssuedAt: now, ExpiresAt: now + 90, AuthTime: authTime.Unix()}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + form := url.Values{"assertion": {token}} |
| + r := httptest.NewRequest(http.MethodPost, bridgeOrigin+"/auth/assertion", strings.NewReader(form.Encode())) |
| + r.Header.Set("Origin", bridgeIssuer) |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + w := httptest.NewRecorder() |
| + f.b.Callback(w, r) |
| + if w.Code != 303 { |
| + t.Fatalf("callback=%d", w.Code) |
| + } |
| + bridgeNoSession(t, f.b, w) |
| + return bridgeCookie(t, w, "__Host-oficina-assertion-inbox") |
| +} |
| +func (f *bridgeFixture) linkProof(t *testing.T, local *http.Cookie, subject string) (*http.Cookie, []*http.Cookie) { |
| + t.Helper() |
| + w := httptest.NewRecorder() |
| + f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local})) |
| + if w.Code != 303 { |
| + t.Fatalf("link start=%d", w.Code) |
| + } |
| + init := bridgeCookie(t, w, homeInitCookie) |
| + cookies := []*http.Cookie{local, init} |
| + start := httptest.NewRecorder() |
| + f.b.Start(start, bridgeRequest(http.MethodGet, w.Header().Get("Location"), cookies)) |
| + if start.Code != 303 { |
| + t.Fatalf("fresh start=%d", start.Code) |
| + } |
| + target, _ := url.Parse(start.Header().Get("Location")) |
| + if target.Query().Get("max_age") != "300" { |
| + t.Fatal("link exchange did not request fresh authentication") |
| + } |
| + pending := bridgeCookie(t, start, "__Host-oficina-assertion-pending") |
| + inbox := f.callback(t, subject, target.Query().Get("nonce"), time.Now().Add(-30*time.Second)) |
| + finish := httptest.NewRecorder() |
| + f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", append(cookies, pending, inbox))) |
| + if finish.Code != 303 || finish.Header().Get("Location") != "/account/home/confirm" { |
| + t.Fatalf("link finish=%d location=%q", finish.Code, finish.Header().Get("Location")) |
| + } |
| + bridgeNoSession(t, f.b, finish) |
| + return bridgeCookie(t, finish, homeProofCookie), []*http.Cookie{local} |
| +} |
| +func (f *bridgeFixture) link(t *testing.T, local *http.Cookie, subject string) { |
| + t.Helper() |
| + proof, cookies := f.linkProof(t, local, subject) |
| + cookies = append(cookies, proof) |
| + page := httptest.NewRecorder() |
| + f.b.ConfirmPage(page, bridgeRequest(http.MethodGet, "/account/home/confirm", cookies)) |
| + if page.Code != 200 { |
| + t.Fatalf("confirm page=%d", page.Code) |
| + } |
| + w := httptest.NewRecorder() |
| + f.b.ConfirmPost(w, bridgeRequest(http.MethodPost, "/account/home/confirm", cookies)) |
| + if w.Code != 303 || w.Header().Get("Location") != "/" { |
| + t.Fatalf("confirm=%d", w.Code) |
| + } |
| + bridgeNoSession(t, f.b, w) |
| +} |
| + |
| +func TestHomeBridgeLinkThenOrdinarySignInPreservesIdentity(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + local := f.local(t, "one@example.com", "magiclink", time.Time{}) |
| + var person int64 |
| + if err := f.db.QueryRow("SELECT id FROM people WHERE email='one@example.com'").Scan(&person); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.db.Exec("CREATE TABLE content(owner INTEGER, bytes BLOB); INSERT INTO content VALUES(1,x'010203')"); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.link(t, local, "opaque-subject-one") |
| + if _, ok := f.sessions.From(bridgeRequest(http.MethodGet, "/", []*http.Cookie{local})); !ok { |
| + t.Fatal("linking replaced local session") |
| + } |
| + pending, nonce := f.start(t, nil, "/d/existing") |
| + original := time.Now().Add(-time.Hour).Truncate(time.Second) |
| + inbox := f.callback(t, "opaque-subject-one", nonce, original) |
| + finish := httptest.NewRecorder() |
| + f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{pending, inbox})) |
| + if finish.Code != 303 || finish.Header().Get("Location") != "/d/existing" { |
| + t.Fatalf("ordinary finish=%d %q", finish.Code, finish.Header().Get("Location")) |
| + } |
| + cookie := bridgeCookie(t, finish, f.b.sessionCookie) |
| + r := bridgeRequest(http.MethodGet, "/", []*http.Cookie{cookie}) |
| + session, ok := f.sessions.From(r) |
| + if !ok || session.Method != HomeMethod || session.Subject != "opaque-subject-one" || !session.AuthTime.Equal(original) { |
| + t.Fatalf("wrong Home session: %+v", session) |
| + } |
| + identity, err := f.b.ResolveSession(r) |
| + if err != nil || identity.ID != person || identity.Label != "one@example.com" { |
| + t.Fatal("identity changed", err) |
| + } |
| + var content []byte |
| + if err = f.db.QueryRow("SELECT bytes FROM content WHERE owner=?", person).Scan(&content); err != nil || !bytes.Equal(content, []byte{1, 2, 3}) { |
| + t.Fatal("existing ownership changed", err) |
| + } |
| + var count int |
| + if err := f.db.QueryRow("SELECT COUNT(*) FROM people").Scan(&count); err != nil || count != 1 { |
| + t.Fatal("SSO created an extra person", count, err) |
| + } |
| + replay := httptest.NewRecorder() |
| + f.b.Finish(replay, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{pending, inbox})) |
| + if replay.Code == 303 { |
| + t.Fatal("assertion replay minted session") |
| + } |
| + bridgeNoSession(t, f.b, replay) |
| +} |
| + |
| +func TestHomeBridgeUnknownAndDifferentCurrentAccountNeverMint(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| + other := f.local(t, "two@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| + f.link(t, local, "linked-subject") |
| + for _, tc := range []struct { |
| + name, subject string |
| + cookies []*http.Cookie |
| + }{{"unknown", "unknown-private-subject", nil}, {"different-local", "linked-subject", []*http.Cookie{other}}} { |
| + t.Run(tc.name, func(t *testing.T) { |
| + pending, n := f.start(t, tc.cookies, "/") |
| + inbox := f.callback(t, tc.subject, n, time.Now().Add(-time.Minute)) |
| + w := httptest.NewRecorder() |
| + f.b.Finish(w, bridgeRequest(http.MethodGet, "/auth/finish", append(tc.cookies, pending, inbox))) |
| + if w.Code != 403 { |
| + t.Fatalf("rejected exchange=%d", w.Code) |
| + } |
| + bridgeNoSession(t, f.b, w) |
| + if tc.name == "unknown" && w.Body.String() != "unknown" { |
| + t.Fatal("unknown identity did not select local continuation state") |
| + } |
| + if strings.Contains(w.Body.String(), tc.subject) { |
| + t.Fatal("opaque identity leaked into response") |
| + } |
| + retry := httptest.NewRecorder() |
| + f.b.Finish(retry, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{pending, inbox})) |
| + if retry.Code == 303 { |
| + t.Fatal("rejected completion remained reusable") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestHomeBridgeChangedLocalSessionAndProofReplayRefused(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + authTime := time.Now().Add(-time.Minute) |
| + original := f.local(t, "one@example.com", "magiclink", authTime) |
| + proof, cookies := f.linkProof(t, original, "opaque") |
| + rotated := f.local(t, "one@example.com", "magiclink", authTime) |
| + w := httptest.NewRecorder() |
| + f.b.ConfirmPost(w, bridgeRequest(http.MethodPost, "/account/home/confirm", []*http.Cookie{rotated, proof})) |
| + if w.Code != 403 { |
| + t.Fatal("different session linked proof", w.Code) |
| + } |
| + retry := httptest.NewRecorder() |
| + f.b.ConfirmPost(retry, bridgeRequest(http.MethodPost, "/account/home/confirm", append(cookies, proof))) |
| + if retry.Code != 403 { |
| + t.Fatal("rejected proof reusable", retry.Code) |
| + } |
| +} |
| + |
| +func TestHomeBridgeStrictCookiesAndStaleLocalRefusal(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| + for _, cookies := range [][]*http.Cookie{{{Name: homeInitCookie, Value: "bad"}}, {{Name: homeInitCookie, Value: strings.Repeat("A", 43)}, {Name: homeInitCookie, Value: strings.Repeat("A", 43)}}, {local, local}, {{Name: f.b.sessionCookie, Value: strings.Repeat("A", 43)}}} { |
| + w := httptest.NewRecorder() |
| + f.b.Start(w, bridgeRequest(http.MethodGet, "/auth/start", cookies)) |
| + if w.Code == 303 { |
| + t.Fatal("malformed/duplicate/unresolved cookie downgraded into ordinary sign-in") |
| + } |
| + } |
| + for _, method := range []string{"magiclink", "keymail", HomeMethod} { |
| + cookie := f.local(t, "one@example.com", method, time.Now().Add(-6*time.Minute)) |
| + w := httptest.NewRecorder() |
| + f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{cookie})) |
| + if w.Code != 403 { |
| + t.Fatal("stale or Home method began linking", method, w.Code) |
| + } |
| + } |
| + pending, n := f.start(t, nil, "/") |
| + inbox := f.callback(t, "unknown", n, time.Now().Add(-time.Minute)) |
| + for _, cookies := range [][]*http.Cookie{{pending, inbox, local, local}, {pending, inbox, {Name: f.b.sessionCookie, Value: strings.Repeat("A", 43)}}} { |
| + w := httptest.NewRecorder() |
| + f.b.Finish(w, bridgeRequest(http.MethodGet, "/auth/finish", cookies)) |
| + if w.Code != 403 { |
| + t.Fatal("unresolved/ambiguous current account accepted", w.Code) |
| + } |
| + bridgeNoSession(t, f.b, w) |
| + } |
| +} |
| + |
| +func TestHomeBridgeCallbackHasNoSessionDependencyAndExactOrigin(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + _, n := f.start(t, nil, "/") |
| + id, _ := assertion.RandomID() |
| + now := time.Now().Unix() |
| + token, err := assertion.Sign(f.key, assertion.Claims{Version: 1, Issuer: bridgeIssuer, Audience: bridgeOrigin, Subject: "opaque", KeyID: "key", ID: id, Nonce: n, IssuedAt: now, ExpiresAt: now + 90, AuthTime: now}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, origin := range []string{"", "https://evil.example", bridgeIssuer} { |
| + r := httptest.NewRequest(http.MethodPost, bridgeOrigin+"/auth/assertion", strings.NewReader(url.Values{"assertion": {token}}.Encode())) |
| + r.Header.Set("Origin", origin) |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + // Even malformed duplicate app cookies cannot affect the issuer callback. |
| + r.AddCookie(&http.Cookie{Name: f.b.sessionCookie, Value: "bad"}) |
| + r.AddCookie(&http.Cookie{Name: f.b.sessionCookie, Value: "bad"}) |
| + w := httptest.NewRecorder() |
| + f.b.Callback(w, r) |
| + bridgeNoSession(t, f.b, w) |
| + if origin == bridgeIssuer && w.Code != 303 { |
| + t.Fatal("callback resolved local session", w.Code) |
| + } |
| + if origin != bridgeIssuer && w.Code == 303 { |
| + t.Fatal("callback accepted different origin") |
| + } |
| + } |
| +} |
| + |
| +func TestHomeBridgeOrdinaryProofCannotBecomeLinkProof(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| + w := httptest.NewRecorder() |
| + f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local})) |
| + init := bridgeCookie(t, w, homeInitCookie) |
| + // Bind an explicit link to its own generated nonce. |
| + f.start(t, []*http.Cookie{local, init}, "/account/home/confirm") |
| + // A separate ordinary sign-in produces a valid assertion with another nonce. |
| + pending, n := f.start(t, []*http.Cookie{local}, "/") |
| + inbox := f.callback(t, "opaque", n, time.Now().Add(-time.Minute)) |
| + finish := httptest.NewRecorder() |
| + f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{local, init, pending, inbox})) |
| + if finish.Code != 403 { |
| + t.Fatal("ordinary exchange reused for linking", finish.Code) |
| + } |
| + bridgeNoSession(t, f.b, finish) |
| + for _, cookie := range finish.Result().Cookies() { |
| + if cookie.Name == homeProofCookie && cookie.MaxAge > 0 { |
| + t.Fatal("wrong nonce created proof") |
| + } |
| + } |
| + var rows int |
| + if err := f.db.QueryRow("SELECT COUNT(*) FROM home_link_flows").Scan(&rows); err != nil || rows != 0 { |
| + t.Fatal("failed link initiation not burned", rows, err) |
| + } |
| +} |
| + |
| +func TestHomeBridgeStaleFreshExchangeCannotMintOrCreateProof(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| + w := httptest.NewRecorder() |
| + f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local})) |
| + init := bridgeCookie(t, w, homeInitCookie) |
| + pending, n := f.start(t, []*http.Cookie{local, init}, "/account/home/confirm") |
| + inbox := f.callback(t, "opaque", n, time.Now().Add(-6*time.Minute)) |
| + for i := 0; i < 2; i++ { |
| + finish := httptest.NewRecorder() |
| + f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{local, init, pending, inbox})) |
| + if finish.Code == 303 { |
| + t.Fatal("stale Home proof accepted or revived") |
| + } |
| + bridgeNoSession(t, f.b, finish) |
| + } |
| + var rows int |
| + if err := f.db.QueryRow("SELECT COUNT(*) FROM home_link_flows WHERE stage = 'pending'").Scan(&rows); err != nil || rows != 0 { |
| + t.Fatal("stale Home gesture created a pending link", rows, err) |
| + } |
| +} |
| + |
| +func TestHomeBridgeCurrentHomeSubjectIsCheckedByConsumer(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| + f.link(t, local, "subject-one") |
| + other := f.local(t, "two@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| + f.link(t, other, "subject-two") |
| + homeToken, err := f.sessions.Mint(Session{Subject: "subject-one", Method: HomeMethod, AuthTime: time.Now().Add(-time.Minute)}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + current := &http.Cookie{Name: f.b.sessionCookie, Value: homeToken} |
| + pending, n := f.start(t, []*http.Cookie{current}, "/") |
| + inbox := f.callback(t, "subject-two", n, time.Now().Add(-time.Minute)) |
| + w := httptest.NewRecorder() |
| + f.b.Finish(w, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{current, pending, inbox})) |
| + if w.Code == 303 { |
| + t.Fatal("signed-in Home identity silently replaced") |
| + } |
| + bridgeNoSession(t, f.b, w) |
| +} |
| + |
| +func TestHomeBridgeReturnPathCallbackRemainsSafe(t *testing.T) { |
| + for _, tc := range []struct{ input, want string }{{"/d/existing-document?view=reading", "/d/existing-document?view=reading"}, {"https://evil.example/private", "/"}, {"//evil.example", "/"}, {"/auth/finish", "/"}, {"/%2fhost", "/"}, {"", "/"}} { |
| + t.Run(tc.input, func(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + f.b.returnPath = func(*http.Request) string { return tc.input } |
| + local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| + proof, cookies := f.linkProof(t, local, "opaque") |
| + w := httptest.NewRecorder() |
| + f.b.ConfirmPost(w, bridgeRequest(http.MethodPost, "/account/home/confirm", append(cookies, proof))) |
| + if w.Code != 303 || w.Header().Get("Location") != tc.want { |
| + t.Fatalf("return location=%q status=%d", w.Header().Get("Location"), w.Code) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/connect/config.go b/connect/config.go| new file mode 100644 |
| index 0000000..0ae04bc |
| --- /dev/null |
| +++ b/connect/config.go |
| @@ -0,0 +1,106 @@ |
| +package connect |
| + |
| +import ( |
| + "encoding/base64" |
| + "errors" |
| + "os" |
| + |
| + "amadan.net/rastrillo/rastrillo/assertion" |
| + json "github.com/go-json-experiment/json" |
| +) |
| + |
| +// Credentials explicitly enables Home authentication. The public key manifest |
| +// is StdBase64-encoded JSON mapping key IDs to StdBase64 P-256 public keys. |
| +// DEKBase64 is a dedicated handoff/storage key, never the document content key. |
| +type Credentials struct { |
| + Issuer string |
| + ConsumerID string |
| + KeysBase64 string |
| + DEKBase64 string |
| +} |
| + |
| +type DecodedCredentials struct { |
| + Issuer string |
| + ConsumerID string |
| + Keys map[string][]byte |
| + DEK []byte |
| +} |
| + |
| +var ErrConfig = errors.New("home connect: invalid configuration") |
| + |
| +// A nil config preserves standalone authentication; an incomplete config |
| +// cannot silently fall back to it. Errors never include supplied values. |
| +func DecodeCredentials(cfg *Credentials, origin string) (*DecodedCredentials, error) { |
| + if cfg == nil { |
| + return nil, nil |
| + } |
| + if !homeConsumerID(cfg.ConsumerID) { |
| + return nil, ErrConfig |
| + } |
| + dek, ok := homeConfigBase64(cfg.DEKBase64, 32) |
| + if !ok { |
| + return nil, ErrConfig |
| + } |
| + if len(dek) != 32 { |
| + return nil, ErrConfig |
| + } |
| + manifest, ok := homeConfigBase64(cfg.KeysBase64, 4096) |
| + if !ok { |
| + return nil, ErrConfig |
| + } |
| + var encoded map[string]string |
| + // This decoder rejects duplicate names, invalid UTF-8 and trailing values. |
| + // A map of strings also rejects wrapper objects, arrays and nested key data. |
| + if json.Unmarshal(manifest, &encoded) != nil || len(encoded) == 0 || len(encoded) > 8 { |
| + return nil, ErrConfig |
| + } |
| + keys := make(map[string][]byte, len(encoded)) |
| + for id, encodedKey := range encoded { |
| + key, ok := homeConfigBase64(encodedKey, 65) |
| + if !ok { |
| + return nil, ErrConfig |
| + } |
| + keys[id] = key |
| + } |
| + if _, err := assertion.NewVerifier(cfg.Issuer, origin, keys); err != nil { |
| + return nil, ErrConfig |
| + } |
| + return &DecodedCredentials{Issuer: cfg.Issuer, ConsumerID: cfg.ConsumerID, Keys: keys, DEK: dek}, nil |
| +} |
| + |
| +func homeConsumerID(v string) bool { |
| + if len(v) == 0 || len(v) > 64 { |
| + return false |
| + } |
| + for _, b := range []byte(v) { |
| + if !(b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || b == '_' || b == '-') { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +func homeConfigBase64(encoded string, limit int) ([]byte, bool) { |
| + if encoded == "" || len(encoded) > base64.StdEncoding.EncodedLen(limit) { |
| + return nil, false |
| + } |
| + decoded, err := base64.StdEncoding.Strict().DecodeString(encoded) |
| + if err != nil || len(decoded) > limit || base64.StdEncoding.EncodeToString(decoded) != encoded { |
| + return nil, false |
| + } |
| + return decoded, true |
| +} |
| + |
| +// LoadEnv returns nil only when every setting is absent or empty. A partial |
| +// configuration refuses boot rather than falling back to local sign-in. |
| +func LoadEnv(prefix, origin string) (*DecodedCredentials, error) { |
| + return DecodeCredentials(CredentialsFromEnv(prefix), origin) |
| +} |
| + |
| +func CredentialsFromEnv(prefix string) *Credentials { |
| + c := Credentials{Issuer: os.Getenv(prefix + "_HOME_ISSUER"), ConsumerID: os.Getenv(prefix + "_HOME_CONSUMER_ID"), KeysBase64: os.Getenv(prefix + "_HOME_KEYS_BASE64"), DEKBase64: os.Getenv(prefix + "_HOME_DEK_BASE64")} |
| + if c.Issuer == "" && c.ConsumerID == "" && c.KeysBase64 == "" && c.DEKBase64 == "" { |
| + return nil |
| + } |
| + return &c |
| +} |
diff --git a/connect/config_test.go b/connect/config_test.go| new file mode 100644 |
| index 0000000..59ab078 |
| --- /dev/null |
| +++ b/connect/config_test.go |
| @@ -0,0 +1,191 @@ |
| +package connect |
| + |
| +import ( |
| + "bytes" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "fmt" |
| + "strings" |
| + "testing" |
| + |
| + assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| +) |
| + |
| +const configOrigin = "https://blue.docs.oficina.works" |
| + |
| +func homeConfigFixture(t *testing.T) (Credentials, string, []byte) { |
| + t.Helper() |
| + key, err := assertioncrypto.Generate() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + public := key.SignPub() |
| + sum := sha256.Sum256(public) |
| + id := hex.EncodeToString(sum[:]) |
| + manifest, err := json.Marshal(map[string]string{id: base64.StdEncoding.EncodeToString(public)}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return Credentials{Issuer: "https://id.oficina.works", ConsumerID: "blue-docs_1", KeysBase64: base64.StdEncoding.EncodeToString(manifest), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{17}, 32))}, id, public |
| +} |
| +func requireCredentialsRefused(t *testing.T, cfg *Credentials, origin string) { |
| + t.Helper() |
| + settings, err := DecodeCredentials(cfg, origin) |
| + if err != ErrConfig || settings != nil { |
| + t.Fatalf("configuration acceptance: settings present=%v, error=%v", settings != nil, err) |
| + } |
| + if err.Error() != "home connect: invalid configuration" { |
| + t.Fatal("configuration error leaked a supplied value") |
| + } |
| +} |
| + |
| +func TestCredentialsStandaloneAndValidPublicManifest(t *testing.T) { |
| + settings, err := DecodeCredentials(nil, "") |
| + if err != nil || settings != nil { |
| + t.Fatal("nil configuration changed standalone behavior") |
| + } |
| + cfg, id, pub := homeConfigFixture(t) |
| + settings, err = DecodeCredentials(&cfg, configOrigin) |
| + if err != nil || settings == nil { |
| + t.Fatal("actual Home key manifest refused", err) |
| + } |
| + if settings.Issuer != cfg.Issuer || settings.ConsumerID != cfg.ConsumerID || !bytes.Equal(settings.Keys[id], pub) || !bytes.Equal(settings.DEK, bytes.Repeat([]byte{17}, 32)) { |
| + t.Fatal("decoded configuration changed values") |
| + } |
| + // Encoded JSON may contain ordinary JSON whitespace. The outer base64 must |
| + // remain canonical; requiring canonical JSON would reject Home's own shape. |
| + raw, _ := base64.StdEncoding.DecodeString(cfg.KeysBase64) |
| + cfg.KeysBase64 = base64.StdEncoding.EncodeToString(append(append([]byte(" \n"), raw...), []byte("\t ")...)) |
| + if _, err := DecodeCredentials(&cfg, configOrigin); err != nil { |
| + t.Fatal("JSON whitespace refused", err) |
| + } |
| +} |
| + |
| +func TestCredentialsRejectsPartialConfiguration(t *testing.T) { |
| + cfg, _, _ := homeConfigFixture(t) |
| + for _, field := range []string{"all", "issuer", "consumer", "keys", "dek"} { |
| + t.Run(field, func(t *testing.T) { |
| + broken := cfg |
| + switch field { |
| + case "all": |
| + broken = Credentials{} |
| + case "issuer": |
| + broken.Issuer = "" |
| + case "consumer": |
| + broken.ConsumerID = "" |
| + case "keys": |
| + broken.KeysBase64 = "" |
| + case "dek": |
| + broken.DEKBase64 = "" |
| + } |
| + requireCredentialsRefused(t, &broken, configOrigin) |
| + }) |
| + } |
| +} |
| + |
| +func TestCredentialsUsesExactCanonicalAssertionOrigins(t *testing.T) { |
| + cfg, _, _ := homeConfigFixture(t) |
| + for _, bad := range []string{"", "http://id.oficina.works", " https://id.oficina.works", "https://id.oficina.works ", "https://id.oficina.works/", "https://id.oficina.works/path", "https://id.oficina.works?query=private", "https://id.oficina.works#private", "https://user:secret@id.oficina.works", "https://0x", "https://ID.oficina.works", "https://id.oficina.works:443", "https://id.oficina.works\n"} { |
| + t.Run(bad, func(t *testing.T) { |
| + broken := cfg |
| + broken.Issuer = bad |
| + requireCredentialsRefused(t, &broken, configOrigin) |
| + requireCredentialsRefused(t, &cfg, bad) |
| + }) |
| + } |
| + requireCredentialsRefused(t, &cfg, cfg.Issuer) |
| +} |
| + |
| +func TestCredentialsConsumerIDGrammar(t *testing.T) { |
| + cfg, _, _ := homeConfigFixture(t) |
| + for _, id := range []string{"", strings.Repeat("x", 65), "docs/one", "docs.one", "docs one", " docs", "docs\n", "dócs", "private?value"} { |
| + cfg.ConsumerID = id |
| + requireCredentialsRefused(t, &cfg, configOrigin) |
| + } |
| + cfg.ConsumerID = strings.Repeat("A", 64) |
| + if _, err := DecodeCredentials(&cfg, configOrigin); err != nil { |
| + t.Fatal("64-byte valid consumer refused", err) |
| + } |
| +} |
| + |
| +func TestCredentialsRejectsNoncanonicalOrWrongSizeDEK(t *testing.T) { |
| + cfg, _, _ := homeConfigFixture(t) |
| + for _, value := range []string{"private-invalid-secret", cfg.DEKBase64 + "\n", " " + cfg.DEKBase64, strings.TrimRight(cfg.DEKBase64, "="), cfg.DEKBase64[:42] + "B=", base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{17}, 31)), base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{17}, 33)), strings.Repeat("A", 100000)} { |
| + broken := cfg |
| + broken.DEKBase64 = value |
| + requireCredentialsRefused(t, &broken, configOrigin) |
| + } |
| +} |
| + |
| +func TestCredentialsRejectsAmbiguousKeyManifests(t *testing.T) { |
| + cfg, id, pub := homeConfigFixture(t) |
| + value := base64.StdEncoding.EncodeToString(pub) |
| + quotedID, _ := json.Marshal(id) |
| + quotedValue, _ := json.Marshal(value) |
| + pair := string(quotedID) + ":" + string(quotedValue) |
| + for _, raw := range []string{"", `null`, `{}`, `[]`, `"private-value"`, `{"keys":{}}`, `{"k":null}`, `{"k":1}`, `{"k":[]}`, "{" + pair + "," + pair + "}", "{" + pair + "}{}", "{" + pair + "} private-trailing", `{"private key":"` + value + `"}`, `{"":"` + value + `"}`, `{"` + strings.Repeat("x", 65) + `":"` + value + `"}`, "{\"k\":\"\xff\"}"} { |
| + t.Run(fmt.Sprintf("shape-%x", sha256.Sum256([]byte(raw))), func(t *testing.T) { |
| + broken := cfg |
| + broken.KeysBase64 = base64.StdEncoding.EncodeToString([]byte(raw)) |
| + requireCredentialsRefused(t, &broken, configOrigin) |
| + }) |
| + } |
| + // Equivalent escaped names are duplicates too, not distinct trusted keys. |
| + broken := cfg |
| + broken.KeysBase64 = base64.StdEncoding.EncodeToString([]byte(`{"k":"` + value + `","\u006b":"` + value + `"}`)) |
| + requireCredentialsRefused(t, &broken, configOrigin) |
| + for _, value := range []string{cfg.KeysBase64 + "\n", " " + cfg.KeysBase64, "private-base64", strings.Repeat("A", base64.StdEncoding.EncodedLen(4096)+4), base64.StdEncoding.EncodeToString([]byte(strings.Repeat(" ", 4097)))} { |
| + broken := cfg |
| + broken.KeysBase64 = value |
| + requireCredentialsRefused(t, &broken, configOrigin) |
| + } |
| +} |
| + |
| +func TestCredentialsKeyBoundsAndP256Validation(t *testing.T) { |
| + cfg, _, pub := homeConfigFixture(t) |
| + for _, encodedKey := range []string{"", "private-invalid-public-key", base64.StdEncoding.EncodeToString(pub) + "\n", base64.StdEncoding.EncodeToString(pub[:64]), base64.StdEncoding.EncodeToString(append(pub, 0)), base64.StdEncoding.EncodeToString(make([]byte, 65)), strings.Repeat("A", 10000)} { |
| + raw, _ := json.Marshal(map[string]string{"key": encodedKey}) |
| + broken := cfg |
| + broken.KeysBase64 = base64.StdEncoding.EncodeToString(raw) |
| + requireCredentialsRefused(t, &broken, configOrigin) |
| + } |
| + keys := map[string]string{} |
| + for i := 0; i < 8; i++ { |
| + keys[fmt.Sprintf("key-%d", i)] = base64.StdEncoding.EncodeToString(pub) |
| + } |
| + raw, _ := json.Marshal(keys) |
| + cfg.KeysBase64 = base64.StdEncoding.EncodeToString(raw) |
| + if _, err := DecodeCredentials(&cfg, configOrigin); err != nil { |
| + t.Fatal("eight valid keys refused", err) |
| + } |
| + keys["key-nine"] = base64.StdEncoding.EncodeToString(pub) |
| + raw, _ = json.Marshal(keys) |
| + cfg.KeysBase64 = base64.StdEncoding.EncodeToString(raw) |
| + requireCredentialsRefused(t, &cfg, configOrigin) |
| +} |
| + |
| +func TestEnvironmentCredentialsRefusePartialAndPreserveLiteralValues(t *testing.T) { |
| + for _, suffix := range []string{"ISSUER", "CONSUMER_ID", "KEYS_BASE64", "DEK_BASE64"} { |
| + t.Setenv("CONNECT_TEST_HOME_"+suffix, "") |
| + } |
| + if CredentialsFromEnv("CONNECT_TEST") != nil { |
| + t.Fatal("empty env enabled Home") |
| + } |
| + t.Setenv("CONNECT_TEST_HOME_ISSUER", " https://id.example.invalid") |
| + if c := CredentialsFromEnv("CONNECT_TEST"); c == nil || c.Issuer != " https://id.example.invalid" { |
| + t.Fatal("raw configuration normalized") |
| + } |
| + if _, err := LoadEnv("CONNECT_TEST", configOrigin); err != ErrConfig { |
| + t.Fatal("partial env accepted", err) |
| + } |
| + valid, _, _ := homeConfigFixture(t) |
| + t.Setenv("CONNECT_TEST_HOME_ISSUER", valid.Issuer) |
| + t.Setenv("CONNECT_TEST_HOME_CONSUMER_ID", valid.ConsumerID) |
| + t.Setenv("CONNECT_TEST_HOME_KEYS_BASE64", valid.KeysBase64) |
| + t.Setenv("CONNECT_TEST_HOME_DEK_BASE64", valid.DEKBase64) |
| + if _, err := LoadEnv("CONNECT_TEST", configOrigin); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
diff --git a/connect/go.mod b/connect/go.mod| new file mode 100644 |
| index 0000000..de1fbd8 |
| --- /dev/null |
| +++ b/connect/go.mod |
| @@ -0,0 +1,23 @@ |
| +module amadan.net/oficina/home/connect |
| + |
| +go 1.25.0 |
| + |
| +require ( |
| + amadan.net/oficina/estilo.git v0.0.0-20260909234655-dec3908b237c |
| + amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| + amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd |
| + github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 |
| + modernc.org/sqlite v1.55.0 |
| +) |
| + |
| +require ( |
| + github.com/dustin/go-humanize v1.0.1 // 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/connect/go.sum b/connect/go.sum| new file mode 100644 |
| index 0000000..db96826 |
| --- /dev/null |
| +++ b/connect/go.sum |
| @@ -0,0 +1,59 @@ |
| +amadan.net/oficina/estilo.git v0.0.0-20260909234655-dec3908b237c h1:LgE7WLm6zMG/qZScoAHb4X2gguzorctB4XGjPYavXts= |
| +amadan.net/oficina/estilo.git v0.0.0-20260909234655-dec3908b237c/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY= |
| +amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM= |
| +amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k= |
| +amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc= |
| +amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| +github.com/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/connect/link_handlers.go b/connect/link_handlers.go| new file mode 100644 |
| index 0000000..bdbd5f1 |
| --- /dev/null |
| +++ b/connect/link_handlers.go |
| @@ -0,0 +1,114 @@ |
| +package connect |
| + |
| +import ( |
| + "errors" |
| + "net/http" |
| + |
| + "amadan.net/oficina/home/connect/linkstore" |
| + "amadan.net/oficina/home/handoff" |
| +) |
| + |
| +// Link routes belong behind the app's CSRF, session, identity and admission |
| +// middleware. They recheck the actual cookie at each sensitive transition. |
| +func (b *Bridge) LinkPage(w http.ResponseWriter, r *http.Request) { |
| + if !b.guard(w, r, http.MethodGet, "/account/home") { |
| + return |
| + } |
| + session, err := b.actualSession(r) |
| + if err != nil || session == nil { |
| + b.fail(w, r, http.StatusForbidden) |
| + return |
| + } |
| + person, err := b.existingPerson(r, *session) |
| + if err != nil { |
| + b.fail(w, r, http.StatusForbidden) |
| + return |
| + } |
| + b.render(w, r, "start", person.Label, http.StatusOK) |
| +} |
| +func (b *Bridge) LinkStart(w http.ResponseWriter, r *http.Request) { |
| + if !b.guard(w, r, http.MethodPost, "/account/home") { |
| + return |
| + } |
| + for _, name := range []string{homeInitCookie, homeProofCookie} { |
| + if _, _, ok := homeCookie(r, name); !ok { |
| + b.fail(w, r, http.StatusBadRequest) |
| + return |
| + } |
| + } |
| + local, _, err := b.localBinding(r) |
| + // The reviewed local reauthentication continuation is wired separately. |
| + // Stale local authentication cannot begin or reuse a linking initiation. |
| + if err != nil { |
| + b.fail(w, r, http.StatusForbidden) |
| + return |
| + } |
| + path := "/" |
| + if b.returnPath != nil { |
| + if candidate := b.returnPath(r); handoff.SafePath(candidate) { |
| + path = candidate |
| + } |
| + } |
| + secret, err := b.links.Start(r.Context(), local, path, b.now()) |
| + if err != nil { |
| + b.linkFailure(w, r, err) |
| + return |
| + } |
| + setHomeCookie(w, homeInitCookie, secret, 300) |
| + setHomeCookie(w, homeProofCookie, "", -1) |
| + http.Redirect(w, r, "/auth/start?return_to=%2Faccount%2Fhome%2Fconfirm", http.StatusSeeOther) |
| +} |
| +func (b *Bridge) ConfirmPage(w http.ResponseWriter, r *http.Request) { |
| + if !b.guard(w, r, http.MethodGet, "/account/home/confirm") { |
| + return |
| + } |
| + secret, present, ok := homeCookie(r, homeProofCookie) |
| + if !ok || !present { |
| + b.fail(w, r, http.StatusBadRequest) |
| + return |
| + } |
| + local, person, err := b.localBinding(r) |
| + if err != nil { |
| + b.fail(w, r, http.StatusForbidden) |
| + return |
| + } |
| + if _, err := b.links.Pending(r.Context(), secret, local, b.now()); err != nil { |
| + if errors.Is(err, linkstore.ErrRefused) { |
| + setHomeCookie(w, homeProofCookie, "", -1) |
| + } |
| + b.linkFailure(w, r, err) |
| + return |
| + } |
| + b.render(w, r, "confirm", person.Label, http.StatusOK) |
| +} |
| +func (b *Bridge) ConfirmPost(w http.ResponseWriter, r *http.Request) { |
| + if !b.guard(w, r, http.MethodPost, "/account/home/confirm") { |
| + return |
| + } |
| + secret, present, ok := homeCookie(r, homeProofCookie) |
| + if !ok || !present { |
| + b.fail(w, r, http.StatusBadRequest) |
| + return |
| + } |
| + local, _, err := b.localBinding(r) |
| + if err != nil { |
| + b.fail(w, r, http.StatusForbidden) |
| + return |
| + } |
| + pending, err := b.links.Confirm(r.Context(), secret, local, b.now()) |
| + if err == nil || errors.Is(err, linkstore.ErrRefused) { |
| + setHomeCookie(w, homeProofCookie, "", -1) |
| + } |
| + if err != nil { |
| + b.linkFailure(w, r, err) |
| + return |
| + } |
| + http.Redirect(w, r, pending.ReturnPath, http.StatusSeeOther) |
| +} |
| +func (b *Bridge) linkFailure(w http.ResponseWriter, r *http.Request, err error) { |
| + status := http.StatusServiceUnavailable |
| + if errors.Is(err, linkstore.ErrRefused) { |
| + status = http.StatusForbidden |
| + } |
| + b.fail(w, r, status) |
| +} |
diff --git a/connect/linkstore/flows.go b/connect/linkstore/flows.go| new file mode 100644 |
| index 0000000..567fa3c |
| --- /dev/null |
| +++ b/connect/linkstore/flows.go |
| @@ -0,0 +1,326 @@ |
| +package linkstore |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/hmac" |
| + "database/sql" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "time" |
| + |
| + "amadan.net/oficina/home/handoff" |
| +) |
| + |
| +type flow struct { |
| + PersonBinding string `json:"person_binding"` |
| + Version int `json:"v"` |
| + PersonID int64 `json:"person"` |
| + SessionHash string `json:"session"` |
| + LocalAuth int64 `json:"local_auth"` |
| + Issuer string `json:"issuer"` |
| + NonceHash string `json:"nonce,omitempty"` |
| + Subject string `json:"subject,omitempty"` |
| + HomeAuth int64 `json:"home_auth,omitempty"` |
| + ReturnPath string `json:"return"` |
| + Expires int64 `json:"expires"` |
| +} |
| + |
| +func (s *Store) purpose(stage, id string) string { |
| + return "identity-link/v1/" + stage + "/" + id + "/" + s.origin |
| +} |
| +func (s *Store) put(ctx context.Context, tx *sql.Tx, stage, id string, f flow) error { |
| + raw, err := json.Marshal(f) |
| + if err != nil { |
| + return ErrStorage |
| + } |
| + sealed, err := s.seal.Seal(s.purpose(stage, id), raw) |
| + if err != nil { |
| + return ErrStorage |
| + } |
| + _, err = tx.ExecContext(ctx, "INSERT INTO home_link_flows (id_hash, stage, expires_at, payload) VALUES (?, ?, ?, ?)", id, stage, f.Expires, sealed) |
| + if err != nil { |
| + return ErrStorage |
| + } |
| + return nil |
| +} |
| + |
| +// load deletes a presented row before interpreting it when consume is true. |
| +// Callers commit refusals, so a rejected conflict or mixed-up browser cannot |
| +// reuse its proof. Storage failures roll back and never install a mapping. |
| +func (s *Store) load(ctx context.Context, tx *sql.Tx, stage, id string, high int64, consume bool) (flow, error) { |
| + var raw []byte |
| + var expires int64 |
| + err := tx.QueryRowContext(ctx, "SELECT expires_at, payload FROM home_link_flows WHERE id_hash = ? AND stage = ?", id, stage).Scan(&expires, &raw) |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return flow{}, ErrRefused |
| + } |
| + if err != nil { |
| + return flow{}, ErrStorage |
| + } |
| + if consume { |
| + if _, err := tx.ExecContext(ctx, "DELETE FROM home_link_flows WHERE id_hash = ? AND stage = ?", id, stage); err != nil { |
| + return flow{}, ErrStorage |
| + } |
| + } |
| + if len(raw) > 8192 || expires <= high { |
| + return flow{}, ErrRefused |
| + } |
| + opened, err := s.seal.Open(s.purpose(stage, id), raw) |
| + if err != nil { |
| + return flow{}, ErrRefused |
| + } |
| + var f flow |
| + dec := json.NewDecoder(bytes.NewReader(opened)) |
| + dec.DisallowUnknownFields() |
| + if dec.Decode(&f) != nil || dec.Decode(new(any)) != io.EOF { |
| + return flow{}, ErrRefused |
| + } |
| + if f.Version != 1 || f.Expires != expires || f.Issuer != s.issuer || !handoff.SafePath(f.ReturnPath) { |
| + return flow{}, ErrRefused |
| + } |
| + return f, nil |
| +} |
| +func (s *Store) matches(f flow, local Local, now, high int64) bool { |
| + return validLocal(local, now, high) && f.PersonID == local.PersonID && f.SessionHash == hash(local.SessionToken) && f.LocalAuth == local.AuthTime.Unix() && fresh(f.LocalAuth, now, high) |
| +} |
| +func (s *Store) currentBinding(ctx context.Context, tx *sql.Tx, id int64) (string, error) { |
| + label, err := s.lookup(ctx, tx, id) |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return "", nil |
| + } |
| + if err != nil { |
| + return "", ErrStorage |
| + } |
| + if label == "" { |
| + return "", nil |
| + } |
| + return s.personBinding(id, label), nil |
| +} |
| + |
| +// Start is called only by an explicit same-origin local-account link POST. |
| +// The returned secret belongs in a host-only Secure/HttpOnly/Lax cookie. |
| +func (s *Store) Start(ctx context.Context, local Local, returnPath string, now time.Time) (string, error) { |
| + tx, high, err := s.begin(ctx, now) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer tx.Rollback() |
| + if err = sweep(ctx, tx, high); err != nil { |
| + return "", err |
| + } |
| + if !validLocal(local, now.Unix(), high) { |
| + return "", refuse(tx) |
| + } |
| + binding, err := s.currentBinding(ctx, tx, local.PersonID) |
| + if err != nil { |
| + return "", err |
| + } |
| + if binding == "" { |
| + return "", refuse(tx) |
| + } |
| + var count int |
| + if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM home_link_flows").Scan(&count); err != nil { |
| + return "", ErrStorage |
| + } |
| + if count >= maxFlows { |
| + return "", refuse(tx) |
| + } |
| + secret, err := newSecret() |
| + if err != nil { |
| + return "", err |
| + } |
| + expires := min(now.Unix()+maxAge, local.AuthTime.Unix()+maxAge) |
| + if expires <= high { |
| + return "", refuse(tx) |
| + } |
| + f := flow{PersonBinding: binding, Version: 1, PersonID: local.PersonID, SessionHash: hash(local.SessionToken), LocalAuth: local.AuthTime.Unix(), Issuer: s.issuer, ReturnPath: safePath(returnPath), Expires: expires} |
| + if err := s.put(ctx, tx, "init", hash(secret), f); err != nil { |
| + return "", err |
| + } |
| + if err := commit(tx); err != nil { |
| + return "", err |
| + } |
| + return secret, nil |
| +} |
| + |
| +// BindNonce is the shared consumer's start hook. It accepts one generated |
| +// nonce exactly once, before that consumer sets its cookie or redirects. |
| +func (s *Store) BindNonce(ctx context.Context, secret string, local Local, nonce string, now time.Time) error { |
| + id, ok := secretHash(secret) |
| + if !ok { |
| + return ErrRefused |
| + } |
| + tx, high, err := s.begin(ctx, now) |
| + if err != nil { |
| + return err |
| + } |
| + defer tx.Rollback() |
| + f, err := s.load(ctx, tx, "init", id, high, true) |
| + if errors.Is(err, ErrRefused) { |
| + return refuse(tx) |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + if !s.matches(f, local, now.Unix(), high) || !handoff.ValidNonce(nonce) || f.NonceHash != "" { |
| + return refuse(tx) |
| + } |
| + binding, err := s.currentBinding(ctx, tx, local.PersonID) |
| + if err != nil { |
| + return err |
| + } |
| + if binding == "" || !hmac.Equal([]byte(binding), []byte(f.PersonBinding)) { |
| + return refuse(tx) |
| + } |
| + f.NonceHash = hash(nonce) |
| + if err := s.put(ctx, tx, "init", id, f); err != nil { |
| + return err |
| + } |
| + return commit(tx) |
| +} |
| + |
| +// Complete consumes the initiation after the shared consumer has consumed and |
| +// verified the Home assertion. It keeps the local Docs session intact. |
| +func (s *Store) Complete(ctx context.Context, secret string, local Local, home Home, now time.Time) (string, error) { |
| + id, ok := secretHash(secret) |
| + if !ok { |
| + return "", ErrRefused |
| + } |
| + tx, high, err := s.begin(ctx, now) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer tx.Rollback() |
| + f, err := s.load(ctx, tx, "init", id, high, true) |
| + if errors.Is(err, ErrRefused) { |
| + return "", refuse(tx) |
| + } |
| + if err != nil { |
| + return "", err |
| + } |
| + if !s.matches(f, local, now.Unix(), high) || !handoff.ValidNonce(home.Nonce) || f.NonceHash != hash(home.Nonce) || home.Issuer != s.issuer || !validSubject(home.Subject) || !fresh(home.AuthTime.Unix(), now.Unix(), high) { |
| + return "", refuse(tx) |
| + } |
| + binding, err := s.currentBinding(ctx, tx, local.PersonID) |
| + if err != nil { |
| + return "", err |
| + } |
| + if binding == "" || !hmac.Equal([]byte(binding), []byte(f.PersonBinding)) { |
| + return "", refuse(tx) |
| + } |
| + pending, err := newSecret() |
| + if err != nil { |
| + return "", err |
| + } |
| + f.Subject, f.HomeAuth = home.Subject, home.AuthTime.Unix() |
| + f.Expires = min(f.Expires, f.HomeAuth+maxAge) |
| + if f.Expires <= high { |
| + return "", refuse(tx) |
| + } |
| + if err := s.put(ctx, tx, "pending", hash(pending), f); err != nil { |
| + return "", err |
| + } |
| + if err := commit(tx); err != nil { |
| + return "", err |
| + } |
| + return pending, nil |
| +} |
| + |
| +func validPending(f flow, now, high int64) bool { |
| + return f.NonceHash != "" && validSubject(f.Subject) && fresh(f.HomeAuth, now, high) |
| +} |
| +func pendingView(f flow) Pending { |
| + return Pending{PersonID: f.PersonID, ReturnPath: f.ReturnPath, HomeAuthTime: time.Unix(f.HomeAuth, 0)} |
| +} |
| + |
| +// Pending reads a still-valid continuation for its current local session. |
| +// Invalid presented state is consumed; reading valid state creates no link. |
| +func (s *Store) Pending(ctx context.Context, secret string, local Local, now time.Time) (Pending, error) { |
| + id, ok := secretHash(secret) |
| + if !ok { |
| + return Pending{}, ErrRefused |
| + } |
| + tx, high, err := s.begin(ctx, now) |
| + if err != nil { |
| + return Pending{}, err |
| + } |
| + defer tx.Rollback() |
| + f, err := s.load(ctx, tx, "pending", id, high, true) |
| + if errors.Is(err, ErrRefused) { |
| + return Pending{}, refuse(tx) |
| + } |
| + if err != nil { |
| + return Pending{}, err |
| + } |
| + if !s.matches(f, local, now.Unix(), high) || !validPending(f, now.Unix(), high) { |
| + return Pending{}, refuse(tx) |
| + } |
| + binding, err := s.currentBinding(ctx, tx, local.PersonID) |
| + if err != nil { |
| + return Pending{}, err |
| + } |
| + if binding == "" || !hmac.Equal([]byte(binding), []byte(f.PersonBinding)) { |
| + return Pending{}, refuse(tx) |
| + } |
| + if err := s.put(ctx, tx, "pending", id, f); err != nil { |
| + return Pending{}, err |
| + } |
| + if err := commit(tx); err != nil { |
| + return Pending{}, err |
| + } |
| + return pendingView(f), nil |
| +} |
| + |
| +// Confirm requires an explicit same-origin POST. The proof deletion and unique |
| +// mapping write commit together. A separate proof for the same mapping is |
| +// idempotent; a reused cookie or conflicting mapping is consumed and refused. |
| +func (s *Store) Confirm(ctx context.Context, secret string, local Local, now time.Time) (Pending, error) { |
| + id, ok := secretHash(secret) |
| + if !ok { |
| + return Pending{}, ErrRefused |
| + } |
| + tx, high, err := s.begin(ctx, now) |
| + if err != nil { |
| + return Pending{}, err |
| + } |
| + defer tx.Rollback() |
| + f, err := s.load(ctx, tx, "pending", id, high, true) |
| + if errors.Is(err, ErrRefused) { |
| + return Pending{}, refuse(tx) |
| + } |
| + if err != nil { |
| + return Pending{}, err |
| + } |
| + if !s.matches(f, local, now.Unix(), high) || !validPending(f, now.Unix(), high) { |
| + return Pending{}, refuse(tx) |
| + } |
| + binding, err := s.currentBinding(ctx, tx, local.PersonID) |
| + if err != nil { |
| + return Pending{}, err |
| + } |
| + if binding == "" || !hmac.Equal([]byte(binding), []byte(f.PersonBinding)) { |
| + return Pending{}, refuse(tx) |
| + } |
| + _, err = tx.ExecContext(ctx, "INSERT INTO home_identity_links (issuer, subject, person_id, person_binding) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING", s.issuer, f.Subject, f.PersonID, f.PersonBinding) |
| + if err != nil { |
| + return Pending{}, ErrStorage |
| + } |
| + var person int64 |
| + var storedBinding string |
| + err = tx.QueryRowContext(ctx, "SELECT person_id, person_binding FROM home_identity_links WHERE issuer = ? AND subject = ?", s.issuer, f.Subject).Scan(&person, &storedBinding) |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return Pending{}, refuse(tx) |
| + } |
| + if err != nil { |
| + return Pending{}, ErrStorage |
| + } |
| + if person != f.PersonID || !hmac.Equal([]byte(storedBinding), []byte(f.PersonBinding)) { |
| + return Pending{}, refuse(tx) |
| + } |
| + if err := commit(tx); err != nil { |
| + return Pending{}, err |
| + } |
| + return pendingView(f), nil |
| +} |
diff --git a/connect/linkstore/migrations/0001_links.sql b/connect/linkstore/migrations/0001_links.sql| new file mode 100644 |
| index 0000000..7b2194c |
| --- /dev/null |
| +++ b/connect/linkstore/migrations/0001_links.sql |
| @@ -0,0 +1,19 @@ |
| +CREATE TABLE home_identity_links ( |
| + issuer TEXT NOT NULL, |
| + subject TEXT NOT NULL, |
| + person_id INTEGER NOT NULL, |
| + PRIMARY KEY (issuer, subject), |
| + UNIQUE (issuer, person_id) |
| +); |
| +CREATE TABLE home_link_flows ( |
| + id_hash TEXT PRIMARY KEY NOT NULL, |
| + stage TEXT NOT NULL CHECK (stage IN ('init', 'pending')), |
| + expires_at INTEGER NOT NULL, |
| + payload BLOB NOT NULL |
| +); |
| +CREATE INDEX home_link_flows_expiry ON home_link_flows(expires_at); |
| +CREATE TABLE home_link_clock ( |
| + id INTEGER PRIMARY KEY CHECK (id = 1), |
| + high_water INTEGER NOT NULL |
| +); |
| +INSERT INTO home_link_clock (id, high_water) VALUES (1, 0); |
diff --git a/connect/linkstore/migrations/0002_person_binding.sql b/connect/linkstore/migrations/0002_person_binding.sql| new file mode 100644 |
| index 0000000..e9c78cd |
| --- /dev/null |
| +++ b/connect/linkstore/migrations/0002_person_binding.sql |
| @@ -0,0 +1 @@ |
| +ALTER TABLE home_identity_links ADD COLUMN person_binding TEXT NOT NULL DEFAULT ''; |
diff --git a/connect/linkstore/store.go b/connect/linkstore/store.go| new file mode 100644 |
| index 0000000..544c1ff |
| --- /dev/null |
| +++ b/connect/linkstore/store.go |
| @@ -0,0 +1,236 @@ |
| +// Package linkstore binds an existing local person to an independently verified |
| +// Home identity. It creates no people, sessions, membership or document grants. |
| +package linkstore |
| + |
| +import ( |
| + "context" |
| + "crypto/hmac" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "database/sql" |
| + "embed" |
| + "encoding/base64" |
| + "encoding/binary" |
| + "encoding/hex" |
| + "errors" |
| + "net/url" |
| + "strings" |
| + "time" |
| + "unicode/utf8" |
| + |
| + "amadan.net/oficina/home/handoff" |
| + "amadan.net/oficina/home/handoff/seal" |
| +) |
| + |
| +//go:embed migrations/*.sql |
| +var migrations embed.FS |
| + |
| +// Migrations must be applied before construction. |
| +var Migrations = migrations |
| + |
| +const SchemaNamespace = "oficina_connect_link" |
| + |
| +var ( |
| + ErrRefused = errors.New("home link: refused") |
| + ErrStorage = errors.New("home link: storage unavailable") |
| +) |
| + |
| +const maxFlows = 10000 |
| +const maxAge = 300 |
| + |
| +// Local is supplied only after the caller verifies the actual local session |
| +// and its method-aware authentication time. Home assertions cannot supply it. |
| +// SessionToken is the actual cookie value, never a subject or a minted alias. |
| +type Local struct { |
| + PersonID int64 |
| + SessionToken string |
| + AuthTime time.Time |
| +} |
| + |
| +// Home contains already verified, consumed shared-handoff claims. The caller |
| +// must not construct it from query parameters or an unverified assertion. |
| +type Home struct { |
| + Issuer, Subject, Nonce string |
| + AuthTime time.Time |
| +} |
| + |
| +// Pending is safe server-side continuation state, not an authentication token. |
| +type Pending struct { |
| + PersonID int64 |
| + ReturnPath string |
| + HomeAuthTime time.Time |
| +} |
| + |
| +type Store struct { |
| + db *sql.DB |
| + seal *seal.Sealer |
| + origin, issuer string |
| + lookup LookupPerson |
| + bindingKey []byte |
| + namespace string |
| +} |
| + |
| +// New uses a dedicated handoff/storage DEK, never an application content key. |
| +// Apply Schema first, then call Sweep at boot before accepting requests. |
| +func New(db *sql.DB, dek []byte, origin, issuer, namespace string, lookup LookupPerson) (*Store, error) { |
| + if lookup == nil || db == nil || !validOrigin(origin) || !validOrigin(issuer) { |
| + return nil, ErrRefused |
| + } |
| + sealer, err := seal.New(dek, namespace) |
| + if err != nil { |
| + return nil, ErrRefused |
| + } |
| + return &Store{db: db, seal: sealer, origin: origin, issuer: issuer, lookup: lookup, bindingKey: append([]byte(nil), dek...), namespace: namespace}, nil |
| +} |
| + |
| +func validOrigin(v string) bool { |
| + u, err := url.Parse(v) |
| + return err == nil && len(v) <= 2048 && u.Scheme == "https" && u.Host != "" && u.Hostname() != "" && u.User == nil && u.Path == "" && u.RawQuery == "" && !u.ForceQuery && u.Fragment == "" && u.Opaque == "" && u.String() == v && !strings.ContainsAny(v, "\\\r\n\t ") |
| +} |
| + |
| +func hash(v string) string { sum := sha256.Sum256([]byte(v)); return hex.EncodeToString(sum[:]) } |
| +func secretHash(v string) (string, bool) { |
| + if len(v) != 43 { |
| + return "", false |
| + } |
| + b, err := base64.RawURLEncoding.Strict().DecodeString(v) |
| + return hash(v), err == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == v |
| +} |
| +func newSecret() (string, error) { |
| + b := make([]byte, 32) |
| + if _, err := rand.Read(b); err != nil { |
| + return "", ErrStorage |
| + } |
| + return base64.RawURLEncoding.EncodeToString(b), nil |
| +} |
| +func fresh(at int64, now, high int64) bool { return at > 0 && at <= now && high-at <= maxAge } |
| +func validLocal(local Local, now, high int64) bool { |
| + return local.PersonID > 0 && local.SessionToken != "" && len(local.SessionToken) <= 4096 && fresh(local.AuthTime.Unix(), now, high) |
| +} |
| +func validSubject(v string) bool { |
| + if v == "" || len(v) > 512 || !utf8.ValidString(v) { |
| + return false |
| + } |
| + for _, r := range v { |
| + if r <= 32 || r == 127 { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// begin obtains the write lock before any reads, including capacity checks. |
| +// A persisted clock prevents stale evidence becoming fresh after a restart or |
| +// a clock rollback. Match the shared handoff's 30-second rollback tolerance. |
| +func (s *Store) begin(ctx context.Context, now time.Time) (*sql.Tx, int64, error) { |
| + if now.Unix() <= 0 { |
| + return nil, 0, ErrRefused |
| + } |
| + tx, err := s.db.BeginTx(ctx, nil) |
| + if err != nil { |
| + return nil, 0, ErrStorage |
| + } |
| + if _, err = tx.ExecContext(ctx, "UPDATE home_link_clock SET high_water = MAX(high_water, ?) WHERE id = 1", now.Unix()); err != nil { |
| + tx.Rollback() |
| + return nil, 0, ErrStorage |
| + } |
| + var high int64 |
| + if err = tx.QueryRowContext(ctx, "SELECT high_water FROM home_link_clock WHERE id = 1").Scan(&high); err != nil { |
| + tx.Rollback() |
| + return nil, 0, ErrStorage |
| + } |
| + if now.Unix() < high-30 { |
| + tx.Rollback() |
| + return nil, 0, ErrRefused |
| + } |
| + return tx, high, nil |
| +} |
| +func refuse(tx *sql.Tx) error { |
| + if err := tx.Commit(); err != nil { |
| + return ErrStorage |
| + } |
| + return ErrRefused |
| +} |
| +func commit(tx *sql.Tx) error { |
| + if err := tx.Commit(); err != nil { |
| + return ErrStorage |
| + } |
| + return nil |
| +} |
| +func sweep(ctx context.Context, tx *sql.Tx, high int64) error { |
| + if _, err := tx.ExecContext(ctx, "DELETE FROM home_link_flows WHERE expires_at <= ?", high); err != nil { |
| + return ErrStorage |
| + } |
| + return nil |
| +} |
| +func (s *Store) Sweep(ctx context.Context, now time.Time) error { |
| + tx, high, err := s.begin(ctx, now) |
| + if err != nil { |
| + return err |
| + } |
| + defer tx.Rollback() |
| + if err := sweep(ctx, tx, high); err != nil { |
| + return err |
| + } |
| + return commit(tx) |
| +} |
| + |
| +// Resolve reads the current existing person, never EnsurePerson or a cached |
| +// address. A deleted or unlinked person is refused without creating anything. |
| +func (s *Store) Resolve(ctx context.Context, issuer, subject string) (int64, string, error) { |
| + if issuer != s.issuer || !validSubject(subject) { |
| + return 0, "", ErrRefused |
| + } |
| + var id int64 |
| + var address, binding string |
| + err := s.db.QueryRowContext(ctx, `SELECT person_id, person_binding FROM home_identity_links WHERE issuer = ? AND subject = ?`, issuer, subject).Scan(&id, &binding) |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return 0, "", ErrRefused |
| + } |
| + if err != nil { |
| + return 0, "", ErrStorage |
| + } |
| + address, err = s.lookup(ctx, s.db, id) |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return 0, "", ErrRefused |
| + } |
| + if err != nil { |
| + return 0, "", ErrStorage |
| + } |
| + if id <= 0 || address == "" || !hmac.Equal([]byte(binding), []byte(s.personBinding(id, address))) { |
| + return 0, "", ErrRefused |
| + } |
| + return id, address, nil |
| +} |
| + |
| +func safePath(v string) string { |
| + if handoff.SafePath(v) { |
| + return v |
| + } |
| + return "/" |
| +} |
| + |
| +// Queryer permits lookups inside the same transaction as link writes. |
| +type Queryer interface { |
| + QueryRowContext(context.Context, string, ...any) *sql.Row |
| +} |
| + |
| +// LookupPerson returns the canonical verified local address, never a display name. |
| +// IDs must never be reused; deletion must remove links in the same transaction. It must never |
| +// create one, and must use q rather than opening a separate transaction. |
| +// Missing or currently inadmissible people return sql.ErrNoRows. |
| +type LookupPerson func(ctx context.Context, q Queryer, id int64) (string, error) |
| + |
| +// personBinding prevents a stale numeric link from resolving a different address. |
| +// Length-prefixed fields keep the namespace, origin, ID and address unambiguous. |
| +func (s *Store) personBinding(id int64, address string) string { |
| + m := hmac.New(sha256.New, s.bindingKey) |
| + for _, field := range []string{"home-connect/person-binding/v1", s.namespace, s.origin, s.issuer} { |
| + _ = binary.Write(m, binary.BigEndian, uint64(len(field))) |
| + _, _ = m.Write([]byte(field)) |
| + } |
| + _ = binary.Write(m, binary.BigEndian, id) |
| + _ = binary.Write(m, binary.BigEndian, uint64(len(address))) |
| + _, _ = m.Write([]byte(address)) |
| + return hex.EncodeToString(m.Sum(nil)) |
| +} |
diff --git a/connect/linkstore/store_test.go b/connect/linkstore/store_test.go| new file mode 100644 |
| index 0000000..f965b76 |
| --- /dev/null |
| +++ b/connect/linkstore/store_test.go |
| @@ -0,0 +1,647 @@ |
| +package linkstore |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "database/sql" |
| + "encoding/base64" |
| + "errors" |
| + "net/url" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + _ "modernc.org/sqlite" |
| +) |
| + |
| +var ctx = context.Background() |
| +var testNow = time.Unix(1788865200, 0) |
| + |
| +const origin = "https://test.docs.oficina.works" |
| +const issuer = "https://id.oficina.works" |
| + |
| +func fixture(t *testing.T) (*Store, string) { |
| + t.Helper() |
| + path := filepath.Join(t.TempDir(), "docs.db") |
| + d, err := sql.Open("sqlite", (&url.URL{Scheme: "file", Path: path}).String()+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + _, err = d.Exec(`CREATE TABLE people (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE); INSERT INTO people VALUES (1,'one@example.com'),(2,'two@example.com'); CREATE TABLE old_content (owner_id INTEGER, bytes BLOB); INSERT INTO old_content VALUES (1,x'00010203'),(2,x'04050607')`) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, name := range []string{"0001_links.sql", "0002_person_binding.sql"} { |
| + migration, err := migrations.ReadFile("migrations/" + name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := d.Exec(string(migration)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + s, err := New(d, bytes.Repeat([]byte{42}, 32), origin, issuer, "oficina/docs", testLookup) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.Sweep(ctx, testNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + return s, path |
| +} |
| +func local(id int64) Local { |
| + return Local{PersonID: id, SessionToken: strings.Repeat(string(rune('a'+id)), 43), AuthTime: testNow.Add(-time.Minute)} |
| +} |
| +func nonce(n byte) string { return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{n}, 16)) } |
| +func start(t *testing.T, s *Store, l Local, n string) string { |
| + t.Helper() |
| + secret, err := s.Start(ctx, l, "/d/private-document?view=reading", testNow) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.BindNonce(ctx, secret, l, n, testNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + return secret |
| +} |
| +func complete(t *testing.T, s *Store, l Local, subject string, n byte) string { |
| + t.Helper() |
| + secret := start(t, s, l, nonce(n)) |
| + pending, err := s.Complete(ctx, secret, l, Home{Issuer: issuer, Subject: subject, Nonce: nonce(n), AuthTime: testNow.Add(-30 * time.Second)}, testNow) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return pending |
| +} |
| +func refused(t *testing.T, err error) { |
| + t.Helper() |
| + if !errors.Is(err, ErrRefused) { |
| + t.Fatalf("error = %v, want generic refusal", err) |
| + } |
| +} |
| +func flowCount(t *testing.T, s *Store) int { |
| + t.Helper() |
| + var count int |
| + if err := s.db.QueryRow("SELECT COUNT(*) FROM home_link_flows").Scan(&count); err != nil { |
| + t.Fatal(err) |
| + } |
| + return count |
| +} |
| + |
| +func TestExplicitLinkPreservesPeopleAndContent(t *testing.T) { |
| + s, _ := fixture(t) |
| + for _, id := range []int64{1, 2} { |
| + l := local(id) |
| + subject := "opaque-home-" + string(rune('a'+id)) |
| + cookie := complete(t, s, l, subject, byte(id)) |
| + p, err := s.Pending(ctx, cookie, l, testNow) |
| + if err != nil || p.PersonID != id || p.ReturnPath != "/d/private-document?view=reading" { |
| + t.Fatalf("pending=%+v error=%v", p, err) |
| + } |
| + _, _, err = s.Resolve(ctx, issuer, subject) |
| + refused(t, err) |
| + got, err := s.Confirm(ctx, cookie, l, testNow) |
| + if err != nil || got.PersonID != id { |
| + t.Fatalf("confirm=%+v error=%v", got, err) |
| + } |
| + linked, address, err := s.Resolve(ctx, issuer, subject) |
| + if err != nil || linked != id || address == "" { |
| + t.Fatalf("resolve=%d,%q,%v", linked, address, err) |
| + } |
| + _, err = s.Confirm(ctx, cookie, l, testNow) |
| + refused(t, err) |
| + } |
| + var count int |
| + if err := s.db.QueryRow("SELECT COUNT(*) FROM people").Scan(&count); err != nil || count != 2 { |
| + t.Fatalf("people changed: %d %v", count, err) |
| + } |
| + var content string |
| + if err := s.db.QueryRow("SELECT group_concat(hex(bytes), ',') FROM old_content ORDER BY owner_id").Scan(&content); err != nil || content != "00010203,04050607" { |
| + t.Fatalf("legacy content changed: %q %v", content, err) |
| + } |
| + if _, err := s.db.Exec("UPDATE people SET email = 'new@example.com' WHERE id = 1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, _, err := s.Resolve(ctx, issuer, "opaque-home-b") |
| + refused(t, err) |
| + _, _, err = s.Resolve(ctx, "https://other.example", "opaque-home-b") |
| + refused(t, err) |
| + if flowCount(t, s) != 0 { |
| + t.Fatal("consumed rows remain") |
| + } |
| +} |
| + |
| +func TestInitiationAndCompletionBindingsBurnFailures(t *testing.T) { |
| + for _, which := range []string{"missing-init", "unbound-nonce", "nonce", "person", "session", "local-auth", "issuer", "home-stale", "home-future", "local-stale", "local-future"} { |
| + t.Run(which, func(t *testing.T) { |
| + s, _ := fixture(t) |
| + l := local(1) |
| + n := nonce(1) |
| + secret, err := s.Start(ctx, l, "/", testNow) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if which != "unbound-nonce" { |
| + if err := s.BindNonce(ctx, secret, l, n, testNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + home := Home{Issuer: issuer, Subject: "opaque", Nonce: n, AuthTime: testNow} |
| + switch which { |
| + case "missing-init": |
| + secret = base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{80}, 32)) |
| + case "nonce": |
| + home.Nonce = nonce(2) |
| + case "person": |
| + l.PersonID = 2 |
| + case "session": |
| + l.SessionToken = "different-actual-cookie" |
| + case "local-auth": |
| + l.AuthTime = testNow |
| + case "issuer": |
| + home.Issuer = "https://evil.example" |
| + case "home-stale": |
| + home.AuthTime = testNow.Add(-301 * time.Second) |
| + case "home-future": |
| + home.AuthTime = testNow.Add(time.Second) |
| + case "local-stale": |
| + l.AuthTime = testNow.Add(-301 * time.Second) |
| + case "local-future": |
| + l.AuthTime = testNow.Add(time.Second) |
| + } |
| + got, err := s.Complete(ctx, secret, l, home, testNow) |
| + refused(t, err) |
| + if got != "" { |
| + t.Fatal("rejected completion returned cookie") |
| + } |
| + _, err = s.Complete(ctx, secret, local(1), Home{Issuer: issuer, Subject: "opaque", Nonce: n, AuthTime: testNow}, testNow) |
| + refused(t, err) |
| + if which != "missing-init" && flowCount(t, s) != 0 { |
| + t.Fatal("failed initiation was reusable") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestBindNonceOnceAndExactLocalSession(t *testing.T) { |
| + for _, which := range []string{"duplicate", "person", "session", "invalid-nonce"} { |
| + t.Run(which, func(t *testing.T) { |
| + s, _ := fixture(t) |
| + l := local(1) |
| + secret, err := s.Start(ctx, l, "/", testNow) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + n := nonce(1) |
| + if which == "duplicate" { |
| + if err := s.BindNonce(ctx, secret, l, n, testNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if which == "person" { |
| + l.PersonID = 2 |
| + } |
| + if which == "session" { |
| + l.SessionToken = "changed" |
| + } |
| + if which == "invalid-nonce" { |
| + n += "=" |
| + } |
| + refused(t, s.BindNonce(ctx, secret, l, n, testNow)) |
| + refused(t, s.BindNonce(ctx, secret, local(1), nonce(2), testNow)) |
| + }) |
| + } |
| +} |
| + |
| +func TestConfirmationFreshnessAndLocalBinding(t *testing.T) { |
| + for _, which := range []string{"person", "session", "auth-time", "expired", "future-after-rollback"} { |
| + t.Run(which, func(t *testing.T) { |
| + s, _ := fixture(t) |
| + l := local(1) |
| + cookie := complete(t, s, l, "opaque", 1) |
| + now := testNow |
| + switch which { |
| + case "person": |
| + l.PersonID = 2 |
| + case "session": |
| + l.SessionToken = "new-cookie" |
| + case "auth-time": |
| + l.AuthTime = testNow |
| + case "expired": |
| + now = now.Add(241 * time.Second) |
| + case "future-after-rollback": |
| + // Complete a proof with a genuinely current Home gesture, then roll back |
| + // within the accepted clock tolerance. It must not earn future credit. |
| + init := start(t, s, l, nonce(2)) |
| + var err error |
| + cookie, err = s.Complete(ctx, init, l, Home{Issuer: issuer, Subject: "opaque", Nonce: nonce(2), AuthTime: testNow}, testNow) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + now = now.Add(-time.Second) |
| + } |
| + _, err := s.Confirm(ctx, cookie, l, now) |
| + refused(t, err) |
| + _, err = s.Confirm(ctx, cookie, local(1), now) |
| + refused(t, err) |
| + _, _, err = s.Resolve(ctx, issuer, "opaque") |
| + refused(t, err) |
| + }) |
| + } |
| +} |
| + |
| +func TestConflictConsumedWithoutReplacingEitherMapping(t *testing.T) { |
| + s, _ := fixture(t) |
| + l1, l2 := local(1), local(2) |
| + first := complete(t, s, l1, "subject-one", 1) |
| + if _, err := s.Confirm(ctx, first, l1, testNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + identical := complete(t, s, l1, "subject-one", 2) |
| + if _, err := s.Confirm(ctx, identical, l1, testNow); err != nil { |
| + t.Fatal("separate identical proof should be idempotent", err) |
| + } |
| + for _, tc := range []struct { |
| + local Local |
| + sub string |
| + }{{l2, "subject-one"}, {l1, "subject-two"}} { |
| + cookie := complete(t, s, tc.local, tc.sub, 3) |
| + _, err := s.Confirm(ctx, cookie, tc.local, testNow) |
| + refused(t, err) |
| + // Remove the competing mapping only in the fixture: an unconsumed failed |
| + // proof would now succeed, making this a direct replay regression test. |
| + if _, err := s.db.Exec("DELETE FROM home_identity_links"); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, err = s.Confirm(ctx, cookie, tc.local, testNow) |
| + refused(t, err) |
| + if _, err := s.db.Exec("INSERT INTO home_identity_links VALUES (?,?,?,?)", issuer, "subject-one", 1, s.personBinding(1, "one@example.com")); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + id, _, err := s.Resolve(ctx, issuer, "subject-one") |
| + if err != nil || id != 1 { |
| + t.Fatalf("mapping replaced: %d %v", id, err) |
| + } |
| +} |
| + |
| +func TestConcurrentConfirmationAndCapacity(t *testing.T) { |
| + s, _ := fixture(t) |
| + l := local(1) |
| + cookie := complete(t, s, l, "subject", 1) |
| + var winners atomic.Int32 |
| + var wg sync.WaitGroup |
| + for i := 0; i < 12; i++ { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + _, err := s.Confirm(ctx, cookie, l, testNow) |
| + if err == nil { |
| + winners.Add(1) |
| + } else if !errors.Is(err, ErrRefused) { |
| + t.Errorf("unexpected concurrent failure: %v", err) |
| + } |
| + }() |
| + } |
| + wg.Wait() |
| + if winners.Load() != 1 { |
| + t.Fatalf("winners=%d", winners.Load()) |
| + } |
| + _, err := s.db.Exec(`WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x < 9999) INSERT INTO home_link_flows SELECT printf('%064d',x),'init',?,x'00' FROM n`, testNow.Unix()+300) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + winners.Store(0) |
| + for i := 0; i < 8; i++ { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + _, err := s.Start(ctx, l, "/", testNow) |
| + if err == nil { |
| + winners.Add(1) |
| + } else if !errors.Is(err, ErrRefused) { |
| + t.Errorf("unexpected capacity failure: %v", err) |
| + } |
| + }() |
| + } |
| + wg.Wait() |
| + if winners.Load() != 1 || flowCount(t, s) != 10000 { |
| + t.Fatalf("capacity winners=%d count=%d", winners.Load(), flowCount(t, s)) |
| + } |
| + if err := s.Sweep(ctx, testNow.Add(301*time.Second)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if flowCount(t, s) != 0 { |
| + t.Fatal("expired rows survived sweep") |
| + } |
| +} |
| + |
| +func TestSealedPendingAndTamperRefusal(t *testing.T) { |
| + for _, which := range []string{"ciphertext", "row", "origin", "expiry", "key"} { |
| + t.Run(which, func(t *testing.T) { |
| + s, _ := fixture(t) |
| + l := local(1) |
| + cookie := complete(t, s, l, "private-subject", 1) |
| + switch which { |
| + case "ciphertext": |
| + if _, err := s.db.Exec("UPDATE home_link_flows SET payload = zeroblob(length(payload))"); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "row": |
| + replacement, _ := newSecret() |
| + if _, err := s.db.Exec("UPDATE home_link_flows SET id_hash = ?", hash(replacement)); err != nil { |
| + t.Fatal(err) |
| + } |
| + cookie = replacement |
| + case "origin": |
| + s.origin = "https://other.docs.oficina.works" |
| + case "expiry": |
| + if _, err := s.db.Exec("UPDATE home_link_flows SET expires_at = expires_at + 300"); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "key": |
| + var err error |
| + s, err = New(s.db, bytes.Repeat([]byte{43}, 32), origin, issuer, "oficina/docs", testLookup) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + _, err := s.Confirm(ctx, cookie, l, testNow) |
| + refused(t, err) |
| + if flowCount(t, s) != 0 { |
| + t.Fatal("tampered row remains reusable") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestNoPendingSecretsInSQLiteOrWAL(t *testing.T) { |
| + s, path := fixture(t) |
| + l := local(1) |
| + n := nonce(8) |
| + init := start(t, s, l, n) |
| + cookie, err := s.Complete(ctx, init, l, Home{Issuer: issuer, Subject: "private-subject-should-be-sealed", Nonce: n, AuthTime: testNow}, testNow) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, file := range []string{path, path + "-wal"} { |
| + raw, err := os.ReadFile(file) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, secret := range []string{init, cookie, l.SessionToken, n, "private-subject-should-be-sealed", "/d/private-document?view=reading"} { |
| + if bytes.Contains(raw, []byte(secret)) { |
| + t.Fatalf("plaintext in %s", filepath.Base(file)) |
| + } |
| + } |
| + } |
| + reopened, err := New(s.db, bytes.Repeat([]byte{42}, 32), origin, issuer, "oficina/docs", testLookup) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := reopened.Confirm(ctx, cookie, l, testNow); err != nil { |
| + t.Fatal("sealed pending must survive store restart", err) |
| + } |
| +} |
| + |
| +func TestClockHighWaterPreventsRevivedAuthentication(t *testing.T) { |
| + s, _ := fixture(t) |
| + l := local(1) |
| + cookie := complete(t, s, l, "subject", 1) |
| + if err := s.Sweep(ctx, testNow.Add(241*time.Second)); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, err := s.Confirm(ctx, cookie, l, testNow.Add(220*time.Second)) |
| + refused(t, err) |
| + if err := s.Sweep(ctx, testNow); !errors.Is(err, ErrRefused) { |
| + t.Fatalf("large clock rollback=%v", err) |
| + } |
| +} |
| + |
| +func TestSafePathsAndInvalidInputs(t *testing.T) { |
| + for _, path := range []string{"", "https://evil.example", "//evil.example", "/auth/finish", "/%2fhost", "/d/../auth/finish", "/d\\evil", "/d/x\n"} { |
| + t.Run(path, func(t *testing.T) { |
| + s, _ := fixture(t) |
| + l := local(1) |
| + init, err := s.Start(ctx, l, path, testNow) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.BindNonce(ctx, init, l, nonce(1), testNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + cookie, err := s.Complete(ctx, init, l, Home{Issuer: issuer, Subject: "subject", Nonce: nonce(1), AuthTime: testNow}, testNow) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + p, err := s.Pending(ctx, cookie, l, testNow) |
| + if err != nil || p.ReturnPath != "/" { |
| + t.Fatalf("unsafe return=%q %v", p.ReturnPath, err) |
| + } |
| + }) |
| + } |
| + s, _ := fixture(t) |
| + for _, l := range []Local{{}, {PersonID: 99, SessionToken: "actual", AuthTime: testNow}, {PersonID: 1, SessionToken: "actual", AuthTime: testNow.Add(time.Second)}, {PersonID: 1, SessionToken: "actual", AuthTime: testNow.Add(-301 * time.Second)}} { |
| + _, err := s.Start(ctx, l, "/", testNow) |
| + refused(t, err) |
| + } |
| + for _, cookie := range []string{"", "short", strings.Repeat("A", 43) + "=", strings.Repeat("A", 42) + "B"} { |
| + _, err := s.Confirm(ctx, cookie, local(1), testNow) |
| + refused(t, err) |
| + } |
| +} |
| + |
| +func TestMappingStorageFailureRollsBackProofConsumption(t *testing.T) { |
| + s, _ := fixture(t) |
| + l := local(1) |
| + cookie := complete(t, s, l, "subject", 1) |
| + if _, err := s.db.Exec(`CREATE TRIGGER block_link BEFORE INSERT ON home_identity_links BEGIN SELECT RAISE(ABORT, 'private internal storage detail'); END`); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, err := s.Confirm(ctx, cookie, l, testNow) |
| + if !errors.Is(err, ErrStorage) || strings.Contains(err.Error(), "private") { |
| + t.Fatalf("storage error = %v", err) |
| + } |
| + if flowCount(t, s) != 1 { |
| + t.Fatal("storage failure consumed proof without mapping") |
| + } |
| + if _, err := s.db.Exec("DROP TRIGGER block_link"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Confirm(ctx, cookie, l, testNow); err != nil { |
| + t.Fatal("retry after storage recovery", err) |
| + } |
| +} |
| + |
| +func TestConcurrentConflictingSubjectsCannotReplacePersonLink(t *testing.T) { |
| + s, _ := fixture(t) |
| + l := local(1) |
| + a, b := complete(t, s, l, "subject-a", 1), complete(t, s, l, "subject-b", 2) |
| + var winners atomic.Int32 |
| + var wg sync.WaitGroup |
| + for _, cookie := range []string{a, b} { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + _, err := s.Confirm(ctx, cookie, l, testNow) |
| + if err == nil { |
| + winners.Add(1) |
| + } else if !errors.Is(err, ErrRefused) { |
| + t.Errorf("conflicting confirmation: %v", err) |
| + } |
| + }() |
| + } |
| + wg.Wait() |
| + if winners.Load() != 1 || flowCount(t, s) != 0 { |
| + t.Fatalf("conflict winners=%d remaining=%d", winners.Load(), flowCount(t, s)) |
| + } |
| + for _, cookie := range []string{a, b} { |
| + _, err := s.Confirm(ctx, cookie, l, testNow) |
| + refused(t, err) |
| + } |
| +} |
| + |
| +func TestPendingReadConsumesChangedSession(t *testing.T) { |
| + s, _ := fixture(t) |
| + l := local(1) |
| + cookie := complete(t, s, l, "subject", 1) |
| + changed := l |
| + changed.SessionToken = "rotated-cookie" |
| + _, err := s.Pending(ctx, cookie, changed, testNow) |
| + refused(t, err) |
| + _, err = s.Confirm(ctx, cookie, l, testNow) |
| + refused(t, err) |
| +} |
| + |
| +func TestFreshnessBoundaryProducesNoExpiredNewRows(t *testing.T) { |
| + s, _ := fixture(t) |
| + l := local(1) |
| + l.AuthTime = testNow.Add(-300 * time.Second) |
| + _, err := s.Start(ctx, l, "/", testNow) |
| + refused(t, err) |
| + l = local(1) |
| + init := start(t, s, l, nonce(1)) |
| + _, err = s.Complete(ctx, init, l, Home{Issuer: issuer, Subject: "subject", Nonce: nonce(1), AuthTime: testNow.Add(-300 * time.Second)}, testNow) |
| + refused(t, err) |
| + if flowCount(t, s) != 0 { |
| + t.Fatal("expired state minted at exact five-minute boundary") |
| + } |
| +} |
| + |
| +func testLookup(ctx context.Context, q Queryer, id int64) (string, error) { |
| + var label string |
| + err := q.QueryRowContext(ctx, "SELECT email FROM people WHERE id=? AND email<>''", id).Scan(&label) |
| + return label, err |
| +} |
| + |
| +func TestLookupUsesLinkTransactionAndRechecksAdmission(t *testing.T) { |
| + s, _ := fixture(t) |
| + calls := 0 |
| + allowed := true |
| + s.lookup = func(ctx context.Context, q Queryer, id int64) (string, error) { |
| + if _, ok := q.(*sql.Tx); !ok { |
| + t.Fatal("link proof lookup escaped transaction") |
| + } |
| + calls++ |
| + if !allowed { |
| + return "", sql.ErrNoRows |
| + } |
| + return testLookup(ctx, q, id) |
| + } |
| + l := local(1) |
| + proof := complete(t, s, l, "subject-one", 41) |
| + allowed = false |
| + _, err := s.Confirm(ctx, proof, l, testNow) |
| + refused(t, err) |
| + allowed = true |
| + _, err = s.Confirm(ctx, proof, l, testNow) |
| + refused(t, err) |
| + var count int |
| + if err = s.db.QueryRow("SELECT COUNT(*) FROM home_identity_links").Scan(&count); err != nil || count != 0 { |
| + t.Fatal("revoked local account linked", count, err) |
| + } |
| + if calls < 3 { |
| + t.Fatal("missing current account rechecks", calls) |
| + } |
| +} |
| + |
| +func TestResolveRechecksAppAdmissionAndDoesNotRevealLookupErrors(t *testing.T) { |
| + s, _ := fixture(t) |
| + l := local(1) |
| + proof := complete(t, s, l, "subject-one", 42) |
| + if _, err := s.Confirm(ctx, proof, l, testNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + s.lookup = func(context.Context, Queryer, int64) (string, error) { return "", sql.ErrNoRows } |
| + _, _, err := s.Resolve(ctx, issuer, "subject-one") |
| + refused(t, err) |
| + s.lookup = func(context.Context, Queryer, int64) (string, error) { |
| + return "", errors.New("private-address@example.invalid") |
| + } |
| + _, _, err = s.Resolve(ctx, issuer, "subject-one") |
| + if err != ErrStorage || strings.Contains(err.Error(), "private-address") { |
| + t.Fatal("lookup error escaped", err) |
| + } |
| +} |
| + |
| +func TestPersonBindingRefusesReusedIDAtEveryStage(t *testing.T) { |
| + for _, stage := range []string{"bind", "complete", "pending", "confirm", "resolve"} { |
| + t.Run(stage, func(t *testing.T) { |
| + s, _ := fixture(t) |
| + l := local(1) |
| + secret, err := s.Start(ctx, l, "/", testNow) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + nonce := nonce(42) |
| + if stage != "bind" { |
| + if err := s.BindNonce(ctx, secret, l, nonce, testNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if stage != "bind" && stage != "complete" { |
| + secret, err = s.Complete(ctx, secret, l, Home{Issuer: issuer, Subject: "person", Nonce: nonce, AuthTime: testNow}, testNow) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if stage == "resolve" { |
| + if _, err := s.Confirm(ctx, secret, l, testNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if _, err := s.db.Exec("DELETE FROM people WHERE id=1; INSERT INTO people VALUES(1,'replacement@example.com')"); err != nil { |
| + t.Fatal(err) |
| + } |
| + switch stage { |
| + case "bind": |
| + err = s.BindNonce(ctx, secret, l, nonce, testNow) |
| + case "complete": |
| + _, err = s.Complete(ctx, secret, l, Home{Issuer: issuer, Subject: "person", Nonce: nonce, AuthTime: testNow}, testNow) |
| + case "pending": |
| + _, err = s.Pending(ctx, secret, l, testNow) |
| + case "confirm": |
| + _, err = s.Confirm(ctx, secret, l, testNow) |
| + case "resolve": |
| + _, _, err = s.Resolve(ctx, issuer, "person") |
| + } |
| + refused(t, err) |
| + if stage != "resolve" && flowCount(t, s) != 0 { |
| + t.Fatal("rejected proof was not consumed") |
| + } |
| + }) |
| + } |
| +} |
| +func TestUnboundLegacyMappingFailsClosed(t *testing.T) { |
| + s, _ := fixture(t) |
| + if _, err := s.db.Exec("INSERT INTO home_identity_links(issuer,subject,person_id) VALUES(?,?,?)", issuer, "legacy", 1); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, _, err := s.Resolve(ctx, issuer, "legacy") |
| + refused(t, err) |
| +} |
diff --git a/connect/navigation.go b/connect/navigation.go| new file mode 100644 |
| index 0000000..c3adc89 |
| --- /dev/null |
| +++ b/connect/navigation.go |
| @@ -0,0 +1,164 @@ |
| +package connect |
| + |
| +import ( |
| + "bytes" |
| + "encoding/base64" |
| + "encoding/json" |
| + "math" |
| + "net/http" |
| + "net/url" |
| + "time" |
| + |
| + "amadan.net/oficina/home/handoff" |
| +) |
| + |
| +const localLinkNavigationCookie = "__Host-oficina-local-link-navigation" |
| +const localLinkNavigationLifetime = int64(600) |
| +const localLinkNavigationLimit = 1024 |
| +const localLinkCompletePath = "/auth/local/complete" |
| + |
| +type localLinkMarker struct { |
| + Version int `json:"v"` |
| + Action string `json:"action"` |
| + Issued int64 `json:"issued"` |
| + Expires int64 `json:"expires"` |
| +} |
| + |
| +type localLinkNavigation struct { |
| + landingPath string |
| + sealer handoff.Sealer |
| + sessionCookie string |
| + readSession func(*http.Request) (*Session, error) |
| + namespace string |
| + fail func(http.ResponseWriter, *http.Request, int) |
| + origin, host string |
| + now func() time.Time |
| +} |
| + |
| +func newLocalLinkNavigation(sealer handoff.Sealer, c RoutesConfig) *localLinkNavigation { |
| + u, err := url.Parse(c.Origin) |
| + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || sealer == nil || c.ReadSession == nil { |
| + return nil |
| + } |
| + landing := c.LocalLandingPath |
| + if landing == "" { |
| + landing = "/" |
| + } |
| + if !handoff.SafePath(landing) { |
| + return nil |
| + } |
| + return &localLinkNavigation{landingPath: landing, sealer: sealer, sessionCookie: c.SessionCookie, readSession: c.ReadSession, namespace: c.Namespace, origin: c.Origin, host: u.Host, now: time.Now, fail: func(w http.ResponseWriter, r *http.Request, status int) { c.Render(w, r, "failed", "", status) }} |
| +} |
| + |
| +func localNavigationCookie(w http.ResponseWriter, name, value string, age int) { |
| + http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: age}) |
| +} |
| + |
| +func (n *localLinkNavigation) purpose() string { |
| + return n.namespace + ".local-link-navigation.v1:" + n.origin |
| +} |
| + |
| +func (n *localLinkNavigation) guard(w http.ResponseWriter, r *http.Request, path string) bool { |
| + w.Header().Set("Cache-Control", "no-store") |
| + if r.Method != http.MethodGet { |
| + w.Header().Set("Allow", http.MethodGet) |
| + n.fail(w, r, http.StatusMethodNotAllowed) |
| + return false |
| + } |
| + if r.Host != n.host || r.URL.Path != path || r.URL.RawQuery != "" { |
| + http.Redirect(w, r, "/", http.StatusSeeOther) |
| + return false |
| + } |
| + return true |
| +} |
| + |
| +// Begin abandons browser pointers to an old link. Rotation and freshness also |
| +// invalidate its binding; clearing cookies does not consume database rows. |
| +// A cross-site top-level GET may abandon a flow, but cannot create a link. |
| +func (n *localLinkNavigation) Begin(w http.ResponseWriter, r *http.Request) { |
| + localNavigationCookie(w, localLinkNavigationCookie, "", -1) |
| + if !n.guard(w, r, "/account/home") { |
| + return |
| + } |
| + localNavigationCookie(w, homeInitCookie, "", -1) |
| + localNavigationCookie(w, homeProofCookie, "", -1) |
| + now := n.now().Unix() |
| + if now <= 0 || now > math.MaxInt64-localLinkNavigationLifetime { |
| + http.Redirect(w, r, "/", http.StatusSeeOther) |
| + return |
| + } |
| + payload, _ := json.Marshal(localLinkMarker{Version: 1, Action: "link-start", Issued: now, Expires: now + localLinkNavigationLifetime}) |
| + sealed, err := n.sealer.Seal(n.purpose(), payload) |
| + if err != nil { |
| + n.fail(w, r, http.StatusServiceUnavailable) |
| + return |
| + } |
| + encoded := base64.RawURLEncoding.EncodeToString(sealed) |
| + if len(encoded) > localLinkNavigationLimit { |
| + n.fail(w, r, http.StatusServiceUnavailable) |
| + return |
| + } |
| + localNavigationCookie(w, localLinkNavigationCookie, encoded, int(localLinkNavigationLifetime)) |
| + http.Redirect(w, r, "/signin/local", http.StatusSeeOther) |
| +} |
| + |
| +func (n *localLinkNavigation) marker(r *http.Request, now int64) bool { |
| + value := "" |
| + count := 0 |
| + for _, c := range r.Cookies() { |
| + if c.Name == localLinkNavigationCookie { |
| + count++ |
| + value = c.Value |
| + } |
| + } |
| + if count != 1 || value == "" || len(value) > localLinkNavigationLimit { |
| + return false |
| + } |
| + sealed, err := base64.RawURLEncoding.Strict().DecodeString(value) |
| + if err != nil || base64.RawURLEncoding.EncodeToString(sealed) != value { |
| + return false |
| + } |
| + payload, err := n.sealer.Open(n.purpose(), sealed) |
| + if err != nil || len(payload) > localLinkNavigationLimit { |
| + return false |
| + } |
| + var marker localLinkMarker |
| + if json.Unmarshal(payload, &marker) != nil { |
| + return false |
| + } |
| + // Only this helper writes the payload. Exact re-encoding excludes duplicate |
| + // and unknown fields, alternate numbers, and any trailing JSON value. |
| + canonical, _ := json.Marshal(marker) |
| + if !bytes.Equal(payload, canonical) { |
| + return false |
| + } |
| + return marker.Version == 1 && marker.Action == "link-start" && marker.Issued > 0 && marker.Issued <= now && now < marker.Expires && marker.Expires > marker.Issued && marker.Expires-marker.Issued <= localLinkNavigationLifetime |
| +} |
| + |
| +// Complete reads the newly presented session after local verification. It |
| +// cannot initiate or confirm linking, and never treats a Home mint as local |
| +// proof. A copied marker can at most repeat navigation to the same page. |
| +func (n *localLinkNavigation) Complete(w http.ResponseWriter, r *http.Request) { |
| + localNavigationCookie(w, localLinkNavigationCookie, "", -1) |
| + if !n.guard(w, r, localLinkCompletePath) { |
| + return |
| + } |
| + now := n.now() |
| + target := n.landingPath |
| + if n.marker(r, now.Unix()) { |
| + count := 0 |
| + for _, c := range r.Cookies() { |
| + if c.Name == n.sessionCookie { |
| + count++ |
| + } |
| + } |
| + if count == 1 { |
| + if session, err := n.readSession(r); err == nil && session != nil { |
| + if _, fresh := FreshLocal(*session, now); fresh { |
| + target = "/account/home" |
| + } |
| + } |
| + } |
| + } |
| + http.Redirect(w, r, target, http.StatusSeeOther) |
| +} |
diff --git a/connect/navigation_negative_test.go b/connect/navigation_negative_test.go| new file mode 100644 |
| index 0000000..05d2e4b |
| --- /dev/null |
| +++ b/connect/navigation_negative_test.go |
| @@ -0,0 +1,126 @@ |
| +package connect |
| + |
| +import ( |
| + "encoding/base64" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func navigationRequest(path string) *http.Request { |
| + return httptest.NewRequest("GET", navigationOrigin+path, nil) |
| +} |
| +func navigationMarker(t *testing.T, n *localLinkNavigation) *http.Cookie { |
| + t.Helper() |
| + w := httptest.NewRecorder() |
| + n.Begin(w, navigationRequest("/account/home")) |
| + for _, c := range w.Result().Cookies() { |
| + if c.Name == localLinkNavigationCookie && c.MaxAge > 0 { |
| + return c |
| + } |
| + } |
| + t.Fatal("marker missing") |
| + return nil |
| +} |
| +func assertNavigation(t *testing.T, n *localLinkNavigation, r *http.Request, want string) { |
| + t.Helper() |
| + rec := httptest.NewRecorder() |
| + n.Complete(rec, r) |
| + if rec.Code != 303 || rec.Header().Get("Location") != want || rec.Header().Get("Cache-Control") != "no-store" { |
| + t.Fatalf("navigation status=%d target=%q wanted%q", rec.Code, rec.Header().Get("Location"), want) |
| + } |
| + cleared := false |
| + for _, c := range rec.Result().Cookies() { |
| + if c.Name == localLinkNavigationCookie && c.MaxAge < 0 { |
| + cleared = true |
| + } |
| + } |
| + if !cleared { |
| + t.Fatal("marker not cleared") |
| + } |
| +} |
| + |
| +func TestLocalNavigationRejectsInvalidMarkersAndOtherHosts(t *testing.T) { |
| + local, n := navigationFixture(t) |
| + now := time.Now().Truncate(time.Second) |
| + local.At = now |
| + n.now = func() time.Time { return now } |
| + cookie := &http.Cookie{Name: n.sessionCookie, Value: "verified"} |
| + valid := navigationMarker(t, n).Value |
| + positive := navigationRequest(localLinkCompletePath) |
| + positive.AddCookie(cookie) |
| + positive.AddCookie(&http.Cookie{Name: localLinkNavigationCookie, Value: valid}) |
| + assertNavigation(t, n, positive, "/account/home") |
| + plain := func(m localLinkMarker) string { b, _ := json.Marshal(m); return string(b) } |
| + base := localLinkMarker{1, "link-start", now.Unix(), now.Unix() + 600} |
| + badJSON := []string{ |
| + "{}", `{"v":1,"v":1,"action":"link-start","issued":1,"expires":2}`, |
| + plain(base) + " {}", strings.TrimSuffix(plain(base), "}") + `,"unknown":true}`, |
| + } |
| + for _, m := range []localLinkMarker{{2, "link-start", now.Unix(), now.Unix() + 600}, {1, "other", now.Unix(), now.Unix() + 600}, {1, "link-start", 0, now.Unix() + 600}, {1, "link-start", now.Unix() + 1, now.Unix() + 600}, {1, "link-start", now.Unix() - 600, now.Unix()}, {1, "link-start", now.Unix(), now.Unix() + 601}} { |
| + badJSON = append(badJSON, plain(m)) |
| + } |
| + badValues := []string{"", strings.Repeat("a", 1025), valid + "=", "%" + valid, valid[:len(valid)-2]} |
| + for _, p := range badJSON { |
| + sealed, err := n.sealer.Seal(n.purpose(), []byte(p)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + badValues = append(badValues, base64.RawURLEncoding.EncodeToString(sealed)) |
| + } |
| + otherPurpose, err := n.sealer.Seal(n.namespace+".local-link-navigation.v1:https://other.example", []byte(plain(base))) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + badValues = append(badValues, base64.RawURLEncoding.EncodeToString(otherPurpose)) |
| + for _, value := range badValues { |
| + r := navigationRequest(localLinkCompletePath) |
| + r.AddCookie(cookie) |
| + r.AddCookie(&http.Cookie{Name: localLinkNavigationCookie, Value: value}) |
| + assertNavigation(t, n, r, "/") |
| + } |
| + for _, change := range []func(*http.Request){func(r *http.Request) { r.Host = "other.example" }, func(r *http.Request) { r.URL.RawQuery = "return_to=https://evil.example" }, func(r *http.Request) { r.URL.Path = "/wrong" }} { |
| + r := navigationRequest(localLinkCompletePath) |
| + r.AddCookie(cookie) |
| + r.AddCookie(&http.Cookie{Name: localLinkNavigationCookie, Value: valid}) |
| + change(r) |
| + assertNavigation(t, n, r, "/") |
| + } |
| + // A valid marker expires independently of the browser cookie's MaxAge. |
| + n.now = func() time.Time { return now.Add(600 * time.Second) } |
| + r := navigationRequest(localLinkCompletePath) |
| + r.AddCookie(cookie) |
| + r.AddCookie(&http.Cookie{Name: localLinkNavigationCookie, Value: valid}) |
| + assertNavigation(t, n, r, "/") |
| +} |
| + |
| +type navigationBrokenSealer struct{} |
| + |
| +func (navigationBrokenSealer) Seal(string, []byte) ([]byte, error) { |
| + return nil, errors.New("private failure") |
| +} |
| +func (navigationBrokenSealer) Open(string, []byte) ([]byte, error) { |
| + return nil, errors.New("private failure") |
| +} |
| +func TestLocalNavigationStorageFailureAndMethod(t *testing.T) { |
| + _, n := navigationFixture(t) |
| + n.sealer = navigationBrokenSealer{} |
| + rec := httptest.NewRecorder() |
| + n.Begin(rec, navigationRequest("/account/home")) |
| + if rec.Code != 503 || strings.Contains(rec.Body.String(), "private") || rec.Header().Get("Location") != "" { |
| + t.Fatal("storage failure published/leaked navigation") |
| + } |
| + for _, fn := range []http.HandlerFunc{n.Begin, n.Complete} { |
| + rec = httptest.NewRecorder() |
| + r := navigationRequest(localLinkCompletePath) |
| + r.Method = "POST" |
| + fn(rec, r) |
| + if rec.Code != 405 || rec.Header().Get("Allow") != "GET" || rec.Header().Get("Cache-Control") != "no-store" { |
| + t.Fatal("wrong method accepted") |
| + } |
| + } |
| +} |
diff --git a/connect/navigation_return.go b/connect/navigation_return.go| new file mode 100644 |
| index 0000000..5db36e9 |
| --- /dev/null |
| +++ b/connect/navigation_return.go |
| @@ -0,0 +1,79 @@ |
| +package connect |
| + |
| +import ( |
| + "bytes" |
| + "encoding/base64" |
| + "encoding/json" |
| + "math" |
| + "net/http" |
| + |
| + "amadan.net/oficina/home/handoff" |
| +) |
| + |
| +const homeReturnCookie = "__Host-oficina-home-return" |
| +const homeReturnLimit = 4096 |
| + |
| +type homeReturnIntent struct { |
| + Path string `json:"path"` |
| + Issued int64 `json:"issued"` |
| + Expires int64 `json:"expires"` |
| +} |
| + |
| +// Resource navigation is separate from the fixed-action local reauth marker. |
| +// Neither cookie authorizes linking or access to the requested document. |
| +func (n *localLinkNavigation) rememberReturn(w http.ResponseWriter, path string) bool { |
| + if !handoff.SafePath(path) { |
| + path = "/" |
| + } |
| + now := n.now().Unix() |
| + if now <= 0 || now > math.MaxInt64-localLinkNavigationLifetime { |
| + return false |
| + } |
| + raw, err := json.Marshal(homeReturnIntent{path, now, now + localLinkNavigationLifetime}) |
| + if err != nil { |
| + return false |
| + } |
| + sealed, err := n.sealer.Seal(n.namespace+".home-return.v1:"+n.origin, raw) |
| + if err != nil { |
| + return false |
| + } |
| + value := base64.RawURLEncoding.EncodeToString(sealed) |
| + if len(value) > homeReturnLimit { |
| + return false |
| + } |
| + localNavigationCookie(w, homeReturnCookie, value, int(localLinkNavigationLifetime)) |
| + return true |
| +} |
| + |
| +func (n *localLinkNavigation) returnPath(r *http.Request) string { |
| + value := "" |
| + if cookieCount(r, homeReturnCookie) != 1 { |
| + return "/" |
| + } |
| + cookie, err := r.Cookie(homeReturnCookie) |
| + if err != nil { |
| + return "/" |
| + } |
| + value = cookie.Value |
| + if value == "" || len(value) > homeReturnLimit { |
| + return "/" |
| + } |
| + sealed, err := base64.RawURLEncoding.Strict().DecodeString(value) |
| + if err != nil || base64.RawURLEncoding.EncodeToString(sealed) != value { |
| + return "/" |
| + } |
| + raw, err := n.sealer.Open(n.namespace+".home-return.v1:"+n.origin, sealed) |
| + if err != nil || len(raw) > homeReturnLimit { |
| + return "/" |
| + } |
| + var intent homeReturnIntent |
| + if json.Unmarshal(raw, &intent) != nil { |
| + return "/" |
| + } |
| + canonical, _ := json.Marshal(intent) |
| + now := n.now().Unix() |
| + if !bytes.Equal(raw, canonical) || intent.Issued <= 0 || intent.Issued > now || intent.Expires <= now || intent.Expires <= intent.Issued || intent.Expires-intent.Issued > localLinkNavigationLifetime || !handoff.SafePath(intent.Path) { |
| + return "/" |
| + } |
| + return intent.Path |
| +} |
diff --git a/connect/navigation_return_test.go b/connect/navigation_return_test.go| new file mode 100644 |
| index 0000000..70a2ea3 |
| --- /dev/null |
| +++ b/connect/navigation_return_test.go |
| @@ -0,0 +1,82 @@ |
| +package connect |
| + |
| +import ( |
| + "bytes" |
| + "encoding/base64" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestHomeResourceReturnSurvivesSetupAndRefusesTamper(t *testing.T) { |
| + _, n := navigationFixture(t) |
| + now := time.Unix(1900000000, 0) |
| + n.now = func() time.Time { return now } |
| + const target = "/d/private-resource?view=compact" |
| + w := httptest.NewRecorder() |
| + if !n.rememberReturn(w, target) { |
| + t.Fatal("return intent refused") |
| + } |
| + cookies := w.Result().Cookies() |
| + if len(cookies) != 1 || !cookies[0].Secure || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteLaxMode || cookies[0].MaxAge != 600 { |
| + t.Fatal("unsafe navigation cookie") |
| + } |
| + c := cookies[0] |
| + r := httptest.NewRequest("GET", navigationOrigin+"/account/home", nil) |
| + r.AddCookie(c) |
| + if got := n.returnPath(r); got != target { |
| + t.Fatal("resource lost across setup", got) |
| + } |
| + raw, _ := base64.RawURLEncoding.DecodeString(c.Value) |
| + if bytes.Contains(raw, []byte("private-resource")) { |
| + t.Fatal("resource stored as readable cookie bytes") |
| + } |
| + raw[len(raw)-1] ^= 1 |
| + bad := *c |
| + bad.Value = base64.RawURLEncoding.EncodeToString(raw) |
| + tampered := httptest.NewRequest("GET", navigationOrigin+"/account/home", nil) |
| + tampered.AddCookie(&bad) |
| + if n.returnPath(tampered) != "/" { |
| + t.Fatal("tampered return accepted") |
| + } |
| + other := *n |
| + other.origin = "https://another.docs.example.com" |
| + if other.returnPath(r) != "/" { |
| + t.Fatal("other team return accepted") |
| + } |
| + duplicate := r.Clone(r.Context()) |
| + duplicate.AddCookie(c) |
| + if n.returnPath(duplicate) != "/" { |
| + t.Fatal("ambiguous return accepted") |
| + } |
| + now = now.Add(600 * time.Second) |
| + if n.returnPath(r) != "/" { |
| + t.Fatal("expired return accepted") |
| + } |
| +} |
| + |
| +func TestHomeResourceReturnValidatesSealedPathAndLifetime(t *testing.T) { |
| + _, n := navigationFixture(t) |
| + now := int64(1900000000) |
| + n.now = func() time.Time { return time.Unix(now, 0) } |
| + for _, v := range []homeReturnIntent{ |
| + {"https://other.example", now, now + 600}, |
| + {"//other.example", now, now + 600}, |
| + {"/auth/start", now, now + 600}, |
| + {"/d/ok", now + 1, now + 600}, |
| + {"/d/ok", now, now + 601}, |
| + } { |
| + payload, _ := json.Marshal(v) |
| + sealed, err := n.sealer.Seal(n.namespace+".home-return.v1:"+n.origin, payload) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + r := httptest.NewRequest("GET", navigationOrigin+"/account/home", nil) |
| + r.AddCookie(&http.Cookie{Name: homeReturnCookie, Value: base64.RawURLEncoding.EncodeToString(sealed)}) |
| + if n.returnPath(r) != "/" { |
| + t.Fatal("unsafe sealed navigation accepted") |
| + } |
| + } |
| +} |
diff --git a/connect/navigation_test.go b/connect/navigation_test.go| new file mode 100644 |
| index 0000000..5153217 |
| --- /dev/null |
| +++ b/connect/navigation_test.go |
| @@ -0,0 +1,132 @@ |
| +package connect |
| + |
| +import ( |
| + "amadan.net/oficina/home/handoff/seal" |
| + "bytes" |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| + "time" |
| +) |
| + |
| +const navigationOrigin = "https://slides.example.com" |
| + |
| +func navigationFixture(t *testing.T) (*Session, *localLinkNavigation) { |
| + t.Helper() |
| + s := &Session{Subject: "existing@example.com", Method: "magiclink", At: time.Now()} |
| + sealer, err := seal.New(bytes.Repeat([]byte{19}, 32), "oficina/slides") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + n := newLocalLinkNavigation(sealer, RoutesConfig{Namespace: "oficina/slides", Origin: navigationOrigin, SessionCookie: "__Host-rastrillo_session", ReadSession: func(r *http.Request) (*Session, error) { |
| + c, err := r.Cookie("__Host-rastrillo_session") |
| + if err != nil || c.Value != "verified" { |
| + return nil, nil |
| + } |
| + return s, nil |
| + }, Render: func(w http.ResponseWriter, r *http.Request, state, label string, status int) { w.WriteHeader(status) }}) |
| + if n == nil { |
| + t.Fatal("navigation rejected") |
| + } |
| + return s, n |
| +} |
| +func TestNavigationRequiresActualFreshLocalSession(t *testing.T) { |
| + for _, tc := range []struct { |
| + method string |
| + age time.Duration |
| + auth bool |
| + want string |
| + }{{"magiclink", 0, false, "/account/home"}, {"magiclink", 6 * time.Minute, false, "/"}, {"keymail", 0, false, "/"}, {"keymail", 0, true, "/account/home"}, {HomeMethod, 0, true, "/"}} { |
| + t.Run(tc.method+tc.age.String()+tc.want, func(t *testing.T) { |
| + s, n := navigationFixture(t) |
| + now := time.Now() |
| + n.now = func() time.Time { return now } |
| + s.Method = tc.method |
| + s.At = now.Add(-tc.age) |
| + if tc.auth { |
| + s.AuthTime = s.At |
| + } |
| + w := httptest.NewRecorder() |
| + n.Begin(w, httptest.NewRequest("GET", navigationOrigin+"/account/home", nil)) |
| + if w.Code != 303 || w.Header().Get("Location") != "/signin/local" { |
| + t.Fatal(w.Code, w.Header()) |
| + } |
| + r := httptest.NewRequest("GET", navigationOrigin+localLinkCompletePath, nil) |
| + for _, c := range w.Result().Cookies() { |
| + if c.Name == localLinkNavigationCookie && c.MaxAge > 0 { |
| + r.AddCookie(c) |
| + } |
| + } |
| + r.AddCookie(&http.Cookie{Name: n.sessionCookie, Value: "verified"}) |
| + done := httptest.NewRecorder() |
| + n.Complete(done, r) |
| + if done.Header().Get("Location") != tc.want { |
| + t.Fatal(done.Header()) |
| + } |
| + r.AddCookie(&http.Cookie{Name: n.sessionCookie, Value: "verified"}) |
| + duplicate := httptest.NewRecorder() |
| + n.Complete(duplicate, r) |
| + if duplicate.Header().Get("Location") != "/" { |
| + t.Fatal("duplicate session accepted") |
| + } |
| + }) |
| + } |
| +} |
| +func TestNavigationPurposeIsolationAndFixedAction(t *testing.T) { |
| + _, n := navigationFixture(t) |
| + w := httptest.NewRecorder() |
| + n.Begin(w, httptest.NewRequest("GET", navigationOrigin+"/account/home", nil)) |
| + r := httptest.NewRequest("GET", navigationOrigin+localLinkCompletePath, nil) |
| + for _, c := range w.Result().Cookies() { |
| + if c.MaxAge > 0 { |
| + r.AddCookie(c) |
| + } |
| + } |
| + if !n.marker(r, n.now().Unix()) { |
| + t.Fatal("valid marker refused") |
| + } |
| + other := *n |
| + other.namespace = "oficina/meet" |
| + if other.marker(r, n.now().Unix()) { |
| + t.Fatal("cross-app marker accepted") |
| + } |
| + for _, target := range []string{navigationOrigin + "/account/home?return_to=https://bad.test", "https://bad.test/account/home"} { |
| + bad := httptest.NewRecorder() |
| + n.Begin(bad, httptest.NewRequest("GET", target, nil)) |
| + for _, c := range bad.Result().Cookies() { |
| + if c.MaxAge > 0 { |
| + t.Fatal("wrong host/query minted navigation") |
| + } |
| + } |
| + } |
| +} |
| + |
| +func TestNavigationLocalLandingPathIsSafeAndDoesNotOverrideLink(t *testing.T) { |
| + _, n := navigationFixture(t) |
| + config := RoutesConfig{Namespace: n.namespace, Origin: n.origin, SessionCookie: n.sessionCookie, ReadSession: n.readSession, Render: func(w http.ResponseWriter, r *http.Request, state, label string, status int) { w.WriteHeader(status) }} |
| + for _, target := range []string{"https://other.example/", "//other.example/", "/\\other.example/"} { |
| + config.LocalLandingPath = target |
| + if newLocalLinkNavigation(n.sealer, config) != nil { |
| + t.Fatal("unsafe local landing accepted", target) |
| + } |
| + } |
| + config.LocalLandingPath = "/continue" |
| + land := newLocalLinkNavigation(n.sealer, config) |
| + if land == nil { |
| + t.Fatal("safe local landing refused") |
| + } |
| + r := navigationRequest(localLinkCompletePath) |
| + w := httptest.NewRecorder() |
| + land.Complete(w, r) |
| + if w.Header().Get("Location") != "/continue" { |
| + t.Fatal("local landing ignored") |
| + } |
| + marker := navigationMarker(t, land) |
| + r.AddCookie(marker) |
| + r.AddCookie(&http.Cookie{Name: land.sessionCookie, Value: "verified"}) |
| + w = httptest.NewRecorder() |
| + land.Complete(w, r) |
| + if w.Header().Get("Location") != "/account/home" { |
| + t.Fatal("landing replaced explicit link navigation") |
| + } |
| +} |
diff --git a/connect/oauth.go b/connect/oauth.go| new file mode 100644 |
| index 0000000..c00cc0b |
| --- /dev/null |
| +++ b/connect/oauth.go |
| @@ -0,0 +1,92 @@ |
| +package connect |
| + |
| +import ( |
| + "amadan.net/oficina/estilo.git/homeconnect" |
| + "bytes" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| +) |
| + |
| +// OAuthContinuation ends the local POST before navigating to Keymail. Safari |
| +// otherwise applies form-action to the entire external redirect chain. |
| +// Wrap only the trusted local authentication plugin's Begin handler. |
| +func OAuthContinuation(origin string, next http.HandlerFunc, render func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + if next == nil || render == nil { |
| + http.Error(w, "Sign-in unavailable.", http.StatusServiceUnavailable) |
| + return |
| + } |
| + result := &oauthResponse{header: make(http.Header)} |
| + next(result, r) |
| + destination := result.header.Get("Location") |
| + external := destination != "" && (!strings.HasPrefix(destination, "/") || strings.HasPrefix(destination, "//")) |
| + if result.status == http.StatusSeeOther && external { |
| + if !keymailDestination(destination, origin) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + http.Error(w, "Sign-in unavailable.", http.StatusBadGateway) |
| + return |
| + } |
| + for _, cookie := range result.header.Values("Set-Cookie") { |
| + w.Header().Add("Set-Cookie", cookie) |
| + } |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.Header().Set("Referrer-Policy", "no-referrer") |
| + render(w, r, destination) |
| + return |
| + } |
| + for name, values := range result.header { |
| + for _, value := range values { |
| + w.Header().Add(name, value) |
| + } |
| + } |
| + if result.status == 0 { |
| + result.status = http.StatusOK |
| + } |
| + w.WriteHeader(result.status) |
| + _, _ = w.Write(result.body.Bytes()) |
| + } |
| +} |
| + |
| +func keymailDestination(destination, origin string) bool { |
| + u, err := url.Parse(destination) |
| + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" || u.Path != "/oauth/authorize" || u.RawPath != "" { |
| + return false |
| + } |
| + q, err := url.ParseQuery(u.RawQuery) |
| + if err != nil { |
| + return false |
| + } |
| + for _, key := range []string{"client_id", "redirect_uri", "scope", "code_challenge_method", "state", "code_challenge"} { |
| + if len(q[key]) != 1 || q.Get(key) == "" { |
| + return false |
| + } |
| + } |
| + origin = strings.TrimSuffix(origin, "/") |
| + return q.Get("client_id") == origin && q.Get("redirect_uri") == origin+"/auth/callback" && q.Get("scope") == "identify" && q.Get("code_challenge_method") == "S256" |
| +} |
| + |
| +type oauthResponse struct { |
| + header http.Header |
| + status int |
| + body bytes.Buffer |
| +} |
| + |
| +func (w *oauthResponse) Header() http.Header { return w.header } |
| +func (w *oauthResponse) WriteHeader(status int) { |
| + if w.status == 0 { |
| + w.status = status |
| + } |
| +} |
| +func (w *oauthResponse) Write(p []byte) (int, error) { |
| + if w.status == 0 { |
| + w.WriteHeader(http.StatusOK) |
| + } |
| + return w.body.Write(p) |
| +} |
| + |
| +type OAuthData = homeconnect.OAuthData |
| + |
| +func OAuthPage(destination string) OAuthData { |
| + return OAuthData{URL: destination, Title: "Continue signing in", ActionLabel: "Continue to Keymail"} |
| +} |
diff --git a/connect/oauth_test.go b/connect/oauth_test.go| new file mode 100644 |
| index 0000000..7d88a07 |
| --- /dev/null |
| +++ b/connect/oauth_test.go |
| @@ -0,0 +1,48 @@ |
| +package connect |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func oauthTarget() string { |
| + return "https://keymail.example/oauth/authorize?" + url.Values{"client_id": {configOrigin}, "redirect_uri": {configOrigin + "/auth/callback"}, "scope": {"identify"}, "code_challenge_method": {"S256"}, "state": {"private-state"}, "code_challenge": {"challenge"}}.Encode() |
| +} |
| +func TestOAuthContinuationPreservesPolicyCookiesAndLocalResponses(t *testing.T) { |
| + for _, destination := range []string{oauthTarget(), "/signin?sent=1"} { |
| + w := httptest.NewRecorder() |
| + w.Header().Set("Content-Security-Policy", "form-action 'self'") |
| + rendered := "" |
| + h := OAuthContinuation(configOrigin, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Add("Set-Cookie", "pending=private; Path=/; Secure; HttpOnly") |
| + http.Redirect(w, r, destination, 303) |
| + }, func(w http.ResponseWriter, r *http.Request, target string) { rendered = target; w.WriteHeader(200) }) |
| + h(w, httptest.NewRequest("POST", configOrigin+"/signin", nil)) |
| + if w.Header().Get("Content-Security-Policy") != "form-action 'self'" || len(w.Result().Cookies()) != 1 { |
| + t.Fatal("policy or pending cookie changed") |
| + } |
| + if strings.HasPrefix(destination, "https:") { |
| + if w.Code != 200 || rendered != destination || w.Header().Get("Location") != "" || w.Header().Get("Cache-Control") != "no-store" || w.Header().Get("Referrer-Policy") != "no-referrer" { |
| + t.Fatal("OAuth POST did not terminate", w.Code, w.Header()) |
| + } |
| + } else if w.Code != 303 || rendered != "" || w.Header().Get("Location") != destination { |
| + t.Fatal("local flow changed") |
| + } |
| + } |
| +} |
| +func TestOAuthContinuationRefusesUnboundDestinationsWithoutLeakingState(t *testing.T) { |
| + for _, destination := range []string{"//keymail.example/oauth/authorize", strings.Replace(oauthTarget(), "https:", "http:", 1), strings.Replace(oauthTarget(), "scope=identify", "scope=other", 1), oauthTarget() + "&client_id=other", oauthTarget() + "#fragment", "https://user@keymail.example/oauth/authorize", "https://keymail.example/other", strings.Replace(oauthTarget(), "S256", "plain", 1)} { |
| + w := httptest.NewRecorder() |
| + h := OAuthContinuation(configOrigin, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Set-Cookie", "private-cookie=private") |
| + http.Redirect(w, r, destination, 303) |
| + }, func(http.ResponseWriter, *http.Request, string) { t.Fatal("invalid redirect rendered") }) |
| + h(w, httptest.NewRequest("POST", configOrigin+"/signin", nil)) |
| + if w.Code != 502 || w.Header().Get("Location") != "" || len(w.Result().Cookies()) != 0 || strings.Contains(w.Body.String(), "private") { |
| + t.Fatal("unsafe destination accepted or leaked", w.Code) |
| + } |
| + } |
| +} |
diff --git a/connect/routes.go b/connect/routes.go| new file mode 100644 |
| index 0000000..86baacf |
| --- /dev/null |
| +++ b/connect/routes.go |
| @@ -0,0 +1,163 @@ |
| +package connect |
| + |
| +import ( |
| + "crypto/sha256" |
| + "errors" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + |
| + "amadan.net/oficina/home/handoff" |
| + "amadan.net/oficina/home/handoff/seal" |
| +) |
| + |
| +const LocalCompletePath = localLinkCompletePath |
| + |
| +type RoutesConfig struct { |
| + LocalLandingPath string |
| + Namespace, Origin, Issuer, SessionCookie string |
| + FormPolicy string |
| + DEK []byte |
| + ReadSession func(*http.Request) (*Session, error) |
| + SignOut func(http.ResponseWriter, *http.Request) |
| + LocalSignin http.HandlerFunc |
| + Protect func(http.Handler) http.Handler |
| + Render func(http.ResponseWriter, *http.Request, string, string, int) |
| +} |
| + |
| +type Routes struct { |
| + bridge *Bridge |
| + navigation *localLinkNavigation |
| + cfg RoutesConfig |
| +} |
| + |
| +func NewRoutes(b *Bridge, c RoutesConfig) (*Routes, error) { |
| + if (c.LocalLandingPath != "" && !handoff.SafePath(c.LocalLandingPath)) || b == nil || c.ReadSession == nil || c.SignOut == nil || c.LocalSignin == nil || c.Protect == nil || c.Render == nil || c.Origin != b.origin || c.Issuer != b.issuer || c.SessionCookie != b.sessionCookie || c.Namespace != b.namespace || sha256.Sum256(c.DEK) != b.keyHash { |
| + return nil, errors.New("home connect: invalid routes") |
| + } |
| + s, err := seal.New(c.DEK, c.Namespace) |
| + if err != nil { |
| + return nil, err |
| + } |
| + n := newLocalLinkNavigation(s, c) |
| + if n == nil { |
| + return nil, errors.New("home connect: invalid navigation") |
| + } |
| + n.now = b.now |
| + b.returnPath = n.returnPath |
| + return &Routes{bridge: b, navigation: n, cfg: c}, nil |
| +} |
| + |
| +func cookieCount(r *http.Request, name string) int { |
| + n := 0 |
| + for _, c := range r.Cookies() { |
| + if c.Name == name { |
| + n++ |
| + } |
| + } |
| + return n |
| +} |
| + |
| +// Only the signed assertion callback bypasses the application's CSRF guard. |
| +func (h *Routes) Middleware(next http.Handler) http.Handler { |
| + checked := h.cfg.Protect(next) |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + policy := h.cfg.FormPolicy |
| + if policy == "" { |
| + policy = FormPolicy(h.cfg.Issuer) |
| + } |
| + w.Header().Set("Content-Security-Policy", policy) |
| + if r.Host != strings.TrimPrefix(h.cfg.Origin, "https://") { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if r.Method == http.MethodPost && r.URL.Path == handoff.CallbackPath { |
| + next.ServeHTTP(w, r) |
| + return |
| + } |
| + if cookieCount(r, h.cfg.SessionCookie) > 1 { |
| + h.bridge.fail(w, r, http.StatusBadRequest) |
| + return |
| + } |
| + checked.ServeHTTP(w, r) |
| + }) |
| +} |
| + |
| +func FormPolicy(issuer string) string { |
| + return "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self' " + issuer |
| +} |
| + |
| +func (h *Routes) LocalGuard(next http.HandlerFunc) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + if cookieCount(r, h.cfg.SessionCookie) > 1 || cookieCount(r, "__Host-rastrillo_pending") > 1 { |
| + h.bridge.fail(w, r, http.StatusBadRequest) |
| + return |
| + } |
| + next(w, r) |
| + } |
| +} |
| + |
| +func (h *Routes) Signin(w http.ResponseWriter, r *http.Request) { |
| + q, err := url.ParseQuery(r.URL.RawQuery) |
| + if err != nil { |
| + h.bridge.fail(w, r, http.StatusBadRequest) |
| + return |
| + } |
| + if q.Has("sent") || q.Has("err") || q.Has("force") { |
| + h.cfg.LocalSignin(w, r) |
| + return |
| + } |
| + path := "/" |
| + if values, ok := q["return_to"]; ok && len(values) == 1 && handoff.SafePath(values[0]) { |
| + path = values[0] |
| + } |
| + if !h.navigation.rememberReturn(w, path) { |
| + h.bridge.fail(w, r, http.StatusServiceUnavailable) |
| + return |
| + } |
| + w.Header().Set("Cache-Control", "no-store") |
| + http.Redirect(w, r, handoff.StartPath+"?"+url.Values{"return_to": {path}}.Encode(), http.StatusSeeOther) |
| +} |
| + |
| +func (h *Routes) SignedOut(w http.ResponseWriter, r *http.Request) { |
| + h.cfg.Render(w, r, "signedout", "", http.StatusOK) |
| +} |
| +func (h *Routes) LocalComplete(w http.ResponseWriter, r *http.Request) { h.navigation.Complete(w, r) } |
| +func (h *Routes) fresh(r *http.Request) bool { |
| + if cookieCount(r, h.cfg.SessionCookie) != 1 { |
| + return false |
| + } |
| + s, err := h.cfg.ReadSession(r) |
| + if err != nil || s == nil { |
| + return false |
| + } |
| + _, ok := FreshLocal(*s, h.bridge.now()) |
| + return ok |
| +} |
| +func (h *Routes) Account(w http.ResponseWriter, r *http.Request) { |
| + if !h.fresh(r) { |
| + h.navigation.Begin(w, r) |
| + return |
| + } |
| + h.bridge.LinkPage(w, r) |
| +} |
| +func (h *Routes) LinkStart(w http.ResponseWriter, r *http.Request) { |
| + if !h.fresh(r) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + http.Redirect(w, r, "/account/home", http.StatusSeeOther) |
| + return |
| + } |
| + h.bridge.LinkStart(w, r) |
| +} |
| +func (h *Routes) ConfirmPost(w http.ResponseWriter, r *http.Request) { |
| + localNavigationCookie(w, homeReturnCookie, "", -1) |
| + h.bridge.ConfirmPost(w, r) |
| +} |
| +func (h *Routes) Signout(w http.ResponseWriter, r *http.Request) { |
| + h.cfg.SignOut(w, r) |
| + for _, name := range []string{homeInitCookie, homeProofCookie, localLinkNavigationCookie, homeReturnCookie, "__Host-oficina-assertion-pending", "__Host-oficina-assertion-inbox"} { |
| + localNavigationCookie(w, name, "", -1) |
| + } |
| + w.Header().Set("Cache-Control", "no-store") |
| + http.Redirect(w, r, "/signed-out", http.StatusSeeOther) |
| +} |
diff --git a/connect/routes_test.go b/connect/routes_test.go| new file mode 100644 |
| index 0000000..cbfbef7 |
| --- /dev/null |
| +++ b/connect/routes_test.go |
| @@ -0,0 +1,102 @@ |
| +package connect |
| + |
| +import ( |
| + "amadan.net/oficina/home/handoff" |
| + "html/template" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestRouteGuardExemptsOnlyCallbackAndPreservesPolicy(t *testing.T) { |
| + b := &Bridge{render: func(w http.ResponseWriter, r *http.Request, state, label string, status int) { w.WriteHeader(status) }} |
| + h := &Routes{bridge: b, cfg: RoutesConfig{Origin: "https://slides.example.com", Issuer: "https://home.example.com", SessionCookie: "__Host-session", FormPolicy: "default-src 'none'; form-action 'self' https://home.example.com", Protect: func(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.Method == "POST" { |
| + w.WriteHeader(403) |
| + return |
| + } |
| + next.ServeHTTP(w, r) |
| + }) |
| + }}} |
| + called := 0 |
| + handler := h.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called++; w.WriteHeader(204) })) |
| + for _, tc := range []struct { |
| + method, origin, path string |
| + dup bool |
| + want int |
| + }{{"POST", h.cfg.Origin, handoff.CallbackPath, false, 204}, {"POST", h.cfg.Origin, handoff.CallbackPath + "/", false, 403}, {"POST", h.cfg.Origin, "/account/home", false, 403}, {"GET", "https://wrong.example.com", handoff.CallbackPath, false, 404}, {"GET", h.cfg.Origin, "/", true, 400}, {"GET", h.cfg.Origin, "/", false, 204}} { |
| + r := httptest.NewRequest(tc.method, tc.origin+tc.path, nil) |
| + if tc.dup { |
| + r.AddCookie(&http.Cookie{Name: h.cfg.SessionCookie, Value: "a"}) |
| + r.AddCookie(&http.Cookie{Name: h.cfg.SessionCookie, Value: "b"}) |
| + } |
| + w := httptest.NewRecorder() |
| + handler.ServeHTTP(w, r) |
| + if w.Code != tc.want { |
| + t.Fatalf("%s %s: %d", tc.method, tc.path, w.Code) |
| + } |
| + if w.Header().Get("Content-Security-Policy") != h.cfg.FormPolicy { |
| + t.Fatal("app policy replaced") |
| + } |
| + } |
| + if called != 2 { |
| + t.Fatal("guard bypass", called) |
| + } |
| + localCalls := 0 |
| + local := h.LocalGuard(func(w http.ResponseWriter, r *http.Request) { localCalls++ }) |
| + r := httptest.NewRequest("GET", h.cfg.Origin+"/auth/verify", nil) |
| + r.AddCookie(&http.Cookie{Name: "__Host-rastrillo_pending", Value: "a"}) |
| + r.AddCookie(&http.Cookie{Name: "__Host-rastrillo_pending", Value: "b"}) |
| + w := httptest.NewRecorder() |
| + local(w, r) |
| + if w.Code != 400 || localCalls != 0 { |
| + t.Fatal("ambiguous local pending reached plugin") |
| + } |
| +} |
| +func TestSignoutEndsLocallyAndClearsHandoffPointers(t *testing.T) { |
| + revoked := false |
| + h := &Routes{cfg: RoutesConfig{SignOut: func(http.ResponseWriter, *http.Request) { revoked = true }}} |
| + w := httptest.NewRecorder() |
| + h.Signout(w, httptest.NewRequest("POST", "https://slides.example.com/signout", nil)) |
| + if !revoked || w.Code != 303 || w.Header().Get("Location") != "/signed-out" { |
| + t.Fatal("signout restarted Home exchange") |
| + } |
| + if len(w.Result().Cookies()) != 6 { |
| + t.Fatal("handoff pointers left behind") |
| + } |
| + for _, c := range w.Result().Cookies() { |
| + if c.MaxAge != -1 || !c.Secure || !c.HttpOnly { |
| + t.Fatal("cookie not cleared safely") |
| + } |
| + } |
| +} |
| + |
| +func TestStaleSessionStillRefusesAndOffersExplicitLocalRecovery(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + templates := template.Must(template.ParseFS(Templates(), "templates/*.html")) |
| + f.b.render = func(w http.ResponseWriter, r *http.Request, state, label string, status int) { |
| + w.WriteHeader(status) |
| + if err := templates.ExecuteTemplate(w, "home-connect", Page(state, label)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + token, err := f.sessions.Mint(Session{Subject: "retired-session", Method: "magiclink"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + delete(f.sessions.values, token) |
| + r := bridgeRequest("GET", "/auth/start", []*http.Cookie{{Name: f.sessions.cookie, Value: token}}) |
| + w := httptest.NewRecorder() |
| + f.b.Start(w, r) |
| + if w.Code != 403 || w.Header().Get("Location") != "" { |
| + t.Fatal("stale cookie guard relaxed", w.Code) |
| + } |
| + if !strings.Contains(w.Body.String(), `href="/signin/local"`) || !strings.Contains(w.Body.String(), English["local_action"]) { |
| + t.Fatal("no explicit local recovery", w.Body.String()) |
| + } |
| + if len(w.Result().Cookies()) != 0 || len(f.sessions.values) != 0 { |
| + t.Fatal("failure changed session state") |
| + } |
| +} |
diff --git a/connect/view.go b/connect/view.go| new file mode 100644 |
| index 0000000..ba0eda2 |
| --- /dev/null |
| +++ b/connect/view.go |
| @@ -0,0 +1,55 @@ |
| +package connect |
| + |
| +import ( |
| + "amadan.net/oficina/estilo.git/homeconnect" |
| + "embed" |
| +) |
| + |
| +func Templates() embed.FS { return homeconnect.Templates() } |
| + |
| +type PageData = homeconnect.PageData |
| + |
| +func Page(state, label string, translate ...func(string) string) PageData { |
| + t := func(k string) string { return English[k] } |
| + if len(translate) > 0 && translate[0] != nil { |
| + t = translate[0] |
| + } |
| + p := PageData{Address: label} |
| + switch state { |
| + case "start": |
| + p.Action = "/account/home" |
| + p.PostAction = true |
| + p.ActionLabel = t("start_action") |
| + case "confirm": |
| + p.Action = "/account/home/confirm" |
| + p.PostAction = true |
| + p.ActionLabel = t("confirm_action") |
| + case "unknown": |
| + p.Action = "/signin/local" |
| + p.ActionLabel = t("local_action") |
| + case "signedout": |
| + p.Action = "/signin" |
| + p.ActionLabel = t("signedout_action") |
| + case "success": |
| + p.Action = "/" |
| + p.ActionLabel = t("documents_action") |
| + default: |
| + state = "failed" |
| + p.Action = "/signin/local" |
| + p.ActionLabel = t("local_action") |
| + } |
| + p.Title = t(state + "_title") |
| + p.Body = t(state + "_body") |
| + if state == "start" || state == "confirm" { |
| + p.CancelLabel = t("cancel_action") |
| + } |
| + return p |
| +} |
| + |
| +var English = map[string]string{ |
| + "signedout_title": "Signed out", "signedout_body": "You can sign in again with Home.", "signedout_action": "Sign in with Home", |
| + "start_title": "Connect your account to Home", "start_body": "Use Home to sign in to this account. Your content and sharing settings stay as they are.", "start_action": "Continue to Home", "cancel_action": "Back", |
| + "confirm_title": "Confirm account connection", "confirm_body": "Connect the account below to the Home account you just signed into.", "confirm_action": "Connect accounts", |
| + "unknown_title": "Home account not connected", "unknown_body": "Sign in to your existing account, then connect Home from the account menu.", "local_action": "Sign in to your existing account", |
| + "failed_title": "Could not connect accounts", "failed_body": "Return to the app and start again.", "success_title": "Accounts connected", "success_body": "You can now use Home to sign in to this account.", "documents_action": "Back to the app", "menu_action": "Connect Home account", |
| +} |