oficina / home Public

Extract framework neutral Home account connection module

Paul Campbell pushed by paul@keymail.dev f14c491a9e70f44f22a120ff09806ea2fc2e3ed8 parent 66c6c56
21 files changed, +3179 −0
  • Makefile +4 −0
  • connect/README.md +58 −0
  • connect/api.go +68 −0
  • connect/bridge.go +285 −0
  • connect/bridge_test.go +487 −0
  • connect/config.go +106 −0
  • connect/config_test.go +191 −0
  • connect/go.mod +22 −0
  • connect/go.sum +57 −0
  • connect/link_handlers.go +114 −0
  • connect/linkstore/flows.go +313 −0
  • connect/linkstore/migrations/0001_links.sql +19 −0
  • connect/linkstore/store.go +217 −0
  • connect/linkstore/store_test.go +590 −0
  • connect/navigation.go +156 −0
  • connect/navigation_return.go +79 −0
  • connect/navigation_return_test.go +82 −0
  • connect/navigation_test.go +102 −0
  • connect/routes.go +162 −0
  • connect/templates/home_link.html +9 −0
  • connect/view.go +58 −0

CI cancelled — superseded by a newer push — run details

diff --git a/Makefile b/Makefile
index 7b90e7a..48b4644 100644
--- a/Makefile
+++ b/Makefile
@@ -24,6 +24,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 +41,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 +63,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..f64b797
--- /dev/null
+++ b/connect/README.md
@@ -0,0 +1,58 @@
+# 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.
+
+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.
+
+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`. 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`.
+
+## 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.
+
+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..8868425
--- /dev/null
+++ b/connect/api.go
@@ -0,0 +1,68 @@
+// 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"
+
+type Session struct {
+ Subject, Method string
+ AuthTime, At time.Time
+}
+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..5be62a3
--- /dev/null
+++ b/connect/go.mod
@@ -0,0 +1,22 @@
+module amadan.net/oficina/home/connect
+
+go 1.25.0
+
+require (
+ 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..4cab028
--- /dev/null
+++ b/connect/go.sum
@@ -0,0 +1,57 @@
+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..91e579b
--- /dev/null
+++ b/connect/linkstore/flows.go
@@ -0,0 +1,313 @@
+package linkstore
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "io"
+ "time"
+
+ "amadan.net/oficina/home/handoff"
+)
+
+type flow struct {
+ 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) personExists(ctx context.Context, tx *sql.Tx, id int64) (bool, error) {
+ label, err := s.lookup(ctx, tx, id)
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, nil
+ }
+ if err != nil {
+ return false, ErrStorage
+ }
+ return 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)
+ }
+ exists, err := s.personExists(ctx, tx, local.PersonID)
+ if err != nil {
+ return "", err
+ }
+ if !exists {
+ 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{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)
+ }
+ 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)
+ }
+ exists, err := s.personExists(ctx, tx, local.PersonID)
+ if err != nil {
+ return "", err
+ }
+ if !exists {
+ 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)
+ }
+ exists, err := s.personExists(ctx, tx, local.PersonID)
+ if err != nil {
+ return Pending{}, err
+ }
+ if !exists {
+ 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)
+ }
+ exists, err := s.personExists(ctx, tx, local.PersonID)
+ if err != nil {
+ return Pending{}, err
+ }
+ if !exists {
+ return Pending{}, refuse(tx)
+ }
+ _, err = tx.ExecContext(ctx, "INSERT INTO home_identity_links (issuer, subject, person_id) VALUES (?, ?, ?) ON CONFLICT DO NOTHING", s.issuer, f.Subject, f.PersonID)
+ if err != nil {
+ return Pending{}, ErrStorage
+ }
+ var person int64
+ err = tx.QueryRowContext(ctx, "SELECT person_id FROM home_identity_links WHERE issuer = ? AND subject = ?", s.issuer, f.Subject).Scan(&person)
+ if errors.Is(err, sql.ErrNoRows) {
+ return Pending{}, refuse(tx)
+ }
+ if err != nil {
+ return Pending{}, ErrStorage
+ }
+ if person != f.PersonID {
+ 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/store.go b/connect/linkstore/store.go
new file mode 100644
index 0000000..edd5593
--- /dev/null
+++ b/connect/linkstore/store.go
@@ -0,0 +1,217 @@
+// 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/rand"
+ "crypto/sha256"
+ "database/sql"
+ "embed"
+ "encoding/base64"
+ "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
+}
+
+// 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}, 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 string
+ err := s.db.QueryRowContext(ctx, `SELECT person_id FROM home_identity_links WHERE issuer = ? AND subject = ?`, issuer, subject).Scan(&id)
+ 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 == "" {
+ 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 a label for an existing local person. 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)
diff --git a/connect/linkstore/store_test.go b/connect/linkstore/store_test.go
new file mode 100644
index 0000000..ca1c5fe
--- /dev/null
+++ b/connect/linkstore/store_test.go
@@ -0,0 +1,590 @@
+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)
+ }
+ migration, err := migrations.ReadFile("migrations/0001_links.sql")
+ 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)
+ }
+ _, address, err := s.Resolve(ctx, issuer, "opaque-home-b")
+ if err != nil || address != "new@example.com" {
+ t.Fatalf("stale address %q %v", address, 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); 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)
+ }
+}
diff --git a/connect/navigation.go b/connect/navigation.go
new file mode 100644
index 0000000..23e4319
--- /dev/null
+++ b/connect/navigation.go
@@ -0,0 +1,156 @@
+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 {
+ 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
+ }
+ return &localLinkNavigation{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 := "/"
+ 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_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..0ea147a
--- /dev/null
+++ b/connect/navigation_test.go
@@ -0,0 +1,102 @@
+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")
+ }
+ }
+ }
+}
diff --git a/connect/routes.go b/connect/routes.go
new file mode 100644
index 0000000..8c689aa
--- /dev/null
+++ b/connect/routes.go
@@ -0,0 +1,162 @@
+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 {
+ 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 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/templates/home_link.html b/connect/templates/home_link.html
new file mode 100644
index 0000000..43c9fae
--- /dev/null
+++ b/connect/templates/home_link.html
@@ -0,0 +1,9 @@
+{{define "home-connect"}}
+<header rst-page-header><div rst-page-header-titles><h1>{{.Title}}</h1></div></header>
+<div rst-box>
+ <p>{{.Body}}</p>{{if .Address}}<p style="overflow-wrap: anywhere"><strong>{{.Address}}</strong></p>{{end}}
+ {{if .PostAction}}<form method="post" action="{{.Action}}"><button rst-btn="primary" type="submit">{{.ActionLabel}}</button></form>
+ {{else}}<a rst-btn="primary" href="{{.Action}}">{{.ActionLabel}}</a>{{end}}
+ {{if .CancelLabel}}<a href="/">{{.CancelLabel}}</a>{{end}}
+</div>
+{{end}}
diff --git a/connect/view.go b/connect/view.go
new file mode 100644
index 0000000..5ea1f5f
--- /dev/null
+++ b/connect/view.go
@@ -0,0 +1,58 @@
+package connect
+
+import "embed"
+
+//go:embed templates/*.html
+var templateFS embed.FS
+
+func Templates() embed.FS { return templateFS }
+
+type PageData struct {
+ Title, Body, Address, Action, ActionLabel, CancelLabel string
+ PostAction bool
+}
+
+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 = "/account/home"
+ p.ActionLabel = t("menu_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",
+}