oficina / home Public

Merge branch 'feat/link-consumer-binding' into main

claude agent, operated by paul@keymail.dev 4d3039c3f68b6e825e8b3ab3ed8e95898b10ec78 parent cc61976parent 02f9ac0
6 files changed, +391 −16
  • go.mod +1 −1
  • go.sum +2 −0
  • handoff/fresh_test.go +289 −0
  • handoff/http.go +26 −2
  • handoff/store.go +52 −13
  • implementation/link-consumer-binding.md +21 −0

CI passed — run details

diff --git a/go.mod b/go.mod
index 3342adf..476aac2 100644
--- a/go.mod
+++ b/go.mod
@@ -4,7 +4,7 @@ go 1.25.0
require (
amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335
- amadan.net/oficina/home/handoff v0.0.0-20260908100301-967d29f96288
+ amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
github.com/carlosframework/rastrillo v0.23.0
github.com/go-chi/chi/v5 v5.3.2
diff --git a/go.sum b/go.sum
index 9089c80..dc6b1f1 100644
--- a/go.sum
+++ b/go.sum
@@ -4,6 +4,8 @@ amadan.net/oficina/home/handoff v0.0.0-20260908081549-3c550c3491db h1:3aN3BlNC03
amadan.net/oficina/home/handoff v0.0.0-20260908081549-3c550c3491db/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k=
amadan.net/oficina/home/handoff v0.0.0-20260908100301-967d29f96288 h1:T1GQM8pChN+4+cpx7TUF7ogCczUaCw/D98YR8nH35NA=
amadan.net/oficina/home/handoff v0.0.0-20260908100301-967d29f96288/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k=
+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/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w=
diff --git a/handoff/fresh_test.go b/handoff/fresh_test.go
new file mode 100644
index 0000000..e0e089c
--- /dev/null
+++ b/handoff/fresh_test.go
@@ -0,0 +1,289 @@
+package handoff
+
+import (
+ "database/sql"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "sync"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo/assertion"
+)
+
+func (f fixture) timedToken(t *testing.T, nonce string, auth int64) string {
+ t.Helper()
+ id, e := assertion.RandomID()
+ if e != nil {
+ t.Fatal(e)
+ }
+ issued := f.now.Unix()
+ if auth > issued {
+ issued = auth
+ }
+ token, e := assertion.Sign(f.k, assertion.Claims{Version: 1, Issuer: f.s.issuer, Audience: f.s.audience, Subject: "person_123", KeyID: "k1", ID: id, Nonce: nonce, IssuedAt: issued, ExpiresAt: issued + 120, AuthTime: auth})
+ if e != nil {
+ t.Fatal(e)
+ }
+ return token
+}
+
+func TestFreshProofWindowAndConsumption(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ age, fence int64
+ accept bool
+ }{
+ {"now", 0, 0, true}, {"boundary", 300, 0, true}, {"old", 301, 0, false},
+ {"future", -1, 0, false}, {"rollback boundary", 280, 20, true}, {"rollback stale", 281, 20, false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ f := fresh(t)
+ secret, nonce, e := f.s.StartFresh(t.Context(), "/documents/private-canary", "", f.now)
+ if e != nil {
+ t.Fatal(e)
+ }
+ token := f.timedToken(t, nonce, f.now.Unix()-tc.age)
+ handle, e := f.s.Receive(t.Context(), token, f.now)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if e = f.s.Sweep(t.Context(), f.now.Add(time.Duration(tc.fence)*time.Second)); e != nil {
+ t.Fatal(e)
+ }
+ claims, path, e := f.s.Finish(t.Context(), secret, handle, "", f.now)
+ if tc.accept {
+ if e != nil || path != "/documents/private-canary" || claims.AuthTime != f.now.Unix()-tc.age {
+ t.Fatalf("fresh proof changed or refused: %v", e)
+ }
+ } else if e != ErrRefused || path != "" || claims.Subject != "" || claims.AuthTime != 0 {
+ t.Fatal("unusable freshness returned identity")
+ }
+ if count(t, f.db, "oficina_handoff_pending") != 0 || count(t, f.db, "oficina_handoff_inbox") != 0 || count(t, f.db, "oficina_handoff_replay") != 1 {
+ t.Fatal("fresh proof not consumed before refusal")
+ }
+ if e = f.db.Close(); e != nil {
+ t.Fatal(e)
+ }
+ db, e := sql.Open("sqlite", f.dbpath)
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer db.Close()
+ f.db = db
+ f.s, e = NewStore(db, f.s.sealer, f.s.issuer, f.s.audience, map[string][]byte{"k1": f.k.SignPub()})
+ if e != nil {
+ t.Fatal(e)
+ }
+ later := f.now.Add(30 * time.Second)
+ failFinish(t, f, secret, handle, "", later, ErrRefused)
+ if _, e = f.s.Receive(t.Context(), token, later); e != ErrRefused {
+ t.Fatal("proof revived after restart/wait", e)
+ }
+ })
+ }
+}
+
+func TestFreshIntentCompatibilityAndPreconsumptionRefusals(t *testing.T) {
+ f := fresh(t)
+ old, _, e := f.s.Start(t.Context(), "/ordinary", "", f.now)
+ if e != nil {
+ t.Fatal(e)
+ }
+ secret, nonce, e := f.s.StartFresh(t.Context(), "/sensitive", "", f.now)
+ if e != nil {
+ t.Fatal(e)
+ }
+ for _, tc := range []struct {
+ secret, want string
+ oldReadable bool
+ }{{old, "/ordinary", true}, {secret, freshPathPrefix + "/sensitive", false}} {
+ var b []byte
+ if e = f.db.QueryRow(`SELECT sealed_path FROM oficina_handoff_pending WHERE secret_hash=?`, digest(tc.secret)).Scan(&b); e != nil {
+ t.Fatal(e)
+ }
+ plain, e := f.s.sealer.Open("handoff.path."+digest(tc.secret), b)
+ if e != nil || string(plain) != tc.want || SafePath(string(plain)) != tc.oldReadable {
+ t.Fatal("old-reader compatibility lost")
+ }
+ }
+ handle, e := f.s.Receive(t.Context(), f.timedToken(t, nonce, f.now.Unix()-900), f.now)
+ if e != nil {
+ t.Fatal(e)
+ }
+ failFinish(t, f, secret, handle, "different_person", f.now, ErrRefused)
+ if count(t, f.db, "oficina_handoff_replay") != 0 || count(t, f.db, "oficina_handoff_inbox") != 1 {
+ t.Fatal("subject refusal consumed proof")
+ }
+ for _, bad := range []string{"oficina-handoff-fresh-v2:/sensitive", freshPathPrefix + "//foreign.example"} {
+ sealed, e := f.s.sealer.Seal("handoff.path."+digest(secret), []byte(bad))
+ if e != nil {
+ t.Fatal(e)
+ }
+ if _, e = f.db.Exec(`UPDATE oficina_handoff_pending SET sealed_path=? WHERE secret_hash=?`, sealed, digest(secret)); e != nil {
+ t.Fatal(e)
+ }
+ failFinish(t, f, secret, handle, "", f.now, ErrRefused)
+ if count(t, f.db, "oficina_handoff_replay") != 0 || count(t, f.db, "oficina_handoff_inbox") != 1 {
+ t.Fatal("path refusal consumed proof")
+ }
+ }
+}
+
+func TestSensitiveHTTPRejectsDowngrade(t *testing.T) {
+ f := fresh(t)
+ c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error {
+ t.Fatal("stale proof reached Complete")
+ return nil
+ })
+ c.cfg.RequireFreshAuth = true
+ var bound string
+ c.cfg.BindStart = func(r *http.Request, nonce string) error { bound = nonce; return nil }
+ start := httptest.NewRecorder()
+ c.Start(start, request("GET", StartPath+"?return_to=%2Fsensitive", ""))
+ if start.Code != 303 {
+ t.Fatal(start.Code)
+ }
+ target, e := url.Parse(start.Header().Get("Location"))
+ if e != nil {
+ t.Fatal(e)
+ }
+ if bound == "" || bound != target.Query().Get("nonce") || target.Query().Get("max_age") != "300" {
+ t.Fatal("freshness or nonce binding missing")
+ }
+ // An issuer request with max_age stripped can return an ordinary stale token.
+ callback := httptest.NewRecorder()
+ c.Callback(callback, request("POST", CallbackPath, url.Values{"assertion": {f.token(t, bound)}}.Encode()))
+ if callback.Code != 303 {
+ t.Fatal(callback.Code)
+ }
+ c.cfg.RequireFreshAuth = false
+ c.cfg.BindStart = nil
+ r := request("GET", FinishPath, "")
+ r.AddCookie(matchingCookie(t, start, pendingCookie))
+ r.AddCookie(matchingCookie(t, callback, inboxCookie))
+ finish := httptest.NewRecorder()
+ c.Finish(finish, r)
+ if finish.Code != 400 || count(t, f.db, "oficina_handoff_replay") != 1 {
+ t.Fatal("ordinary handler downgraded fresh intent")
+ }
+}
+
+func TestBindStartRejectsBeforePublishingAndCancelsOnlyNewRow(t *testing.T) {
+ for _, evict := range []bool{false, true} {
+ t.Run(map[bool]string{false: "present", true: "already evicted"}[evict], func(t *testing.T) {
+ f := fresh(t)
+ other, _ := f.pending(t)
+ c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error { return nil })
+ calls := 0
+ c.cfg.BindStart = func(r *http.Request, nonce string) error {
+ calls++
+ // This DB operation also proves the callback is outside the store transaction.
+ var n int
+ if e := f.db.QueryRow(`SELECT COUNT(*) FROM oficina_handoff_pending WHERE nonce_hash=?`, digest(nonce)).Scan(&n); e != nil || n != 1 {
+ t.Fatal("binding ran before pending commit", e)
+ }
+ if evict {
+ if _, e := f.db.Exec(`DELETE FROM oficina_handoff_pending WHERE nonce_hash=?`, digest(nonce)); e != nil {
+ t.Fatal(e)
+ }
+ }
+ return errors.New("private app rejection")
+ }
+ f.db.SetMaxOpenConns(1)
+ rec := httptest.NewRecorder()
+ c.Start(rec, request("GET", StartPath, ""))
+ if rec.Code != 400 || rec.Header().Get("Location") != "" || len(rec.Result().Cookies()) != 0 || calls != 1 {
+ t.Fatal("rejected initiation published")
+ }
+ if rec.Body.String() != "Unable to sign in. Start again.\n" || count(t, f.db, "oficina_handoff_pending") != 1 {
+ t.Fatal("rejection leaked detail or failed cleanup")
+ }
+ var n int
+ if e := f.db.QueryRow(`SELECT COUNT(*) FROM oficina_handoff_pending WHERE secret_hash=?`, digest(other)).Scan(&n); e != nil || n != 1 {
+ t.Fatal("cancel removed another browser")
+ }
+ r := request("GET", StartPath, "")
+ r.AddCookie(&http.Cookie{Name: pendingCookie, Value: other})
+ r.AddCookie(&http.Cookie{Name: pendingCookie, Value: other})
+ rec = httptest.NewRecorder()
+ c.Start(rec, r)
+ if rec.Code != 400 || calls != 1 {
+ t.Fatal("duplicate cookies reached binding")
+ }
+ })
+ }
+}
+
+func TestConcurrentAppInitiationHasOneBindingWinner(t *testing.T) {
+ f := fresh(t)
+ c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error { return nil })
+ var mu sync.Mutex
+ bound := ""
+ wins := 0
+ c.cfg.BindStart = func(r *http.Request, nonce string) error {
+ mu.Lock()
+ defer mu.Unlock()
+ if bound != "" {
+ return ErrRefused
+ }
+ bound = nonce
+ return nil
+ }
+ var wg sync.WaitGroup
+ for range 8 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ rec := httptest.NewRecorder()
+ c.Start(rec, request("GET", StartPath, ""))
+ if rec.Code == 303 {
+ mu.Lock()
+ wins++
+ mu.Unlock()
+ } else if rec.Code != 400 {
+ t.Errorf("unexpected status %d", rec.Code)
+ }
+ }()
+ }
+ wg.Wait()
+ if wins != 1 || count(t, f.db, "oficina_handoff_pending") != 1 {
+ t.Fatal("app binding race had multiple winners")
+ }
+ var n int
+ if e := f.db.QueryRow(`SELECT COUNT(*) FROM oficina_handoff_pending WHERE nonce_hash=?`, digest(bound)).Scan(&n); e != nil || n != 1 {
+ t.Fatal("winning nonce missing")
+ }
+}
+
+func TestBindingCancelFailureIsGenericAndBounded(t *testing.T) {
+ f := fresh(t)
+ c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error { return nil })
+ c.cfg.BindStart = func(r *http.Request, nonce string) error {
+ // Force only the compensating delete to fail, leaving its bounded row.
+ _, e := f.db.Exec(`CREATE TRIGGER refuse_cancel BEFORE DELETE ON oficina_handoff_pending BEGIN SELECT RAISE(ABORT,'private failure'); END`)
+ if e != nil {
+ t.Fatal(e)
+ }
+ return errors.New("private initiation")
+ }
+ rec := httptest.NewRecorder()
+ c.Start(rec, request("GET", StartPath, ""))
+ if rec.Code != 503 || len(rec.Result().Cookies()) != 0 || rec.Header().Get("Location") != "" || rec.Body.String() != "Unable to sign in. Start again.\n" {
+ t.Fatal("cancellation failure exposed or published")
+ }
+ if count(t, f.db, "oficina_handoff_pending") != 1 {
+ t.Fatal("missing orphan fixture")
+ }
+ if _, e := f.db.Exec(`DROP TRIGGER refuse_cancel`); e != nil {
+ t.Fatal(e)
+ }
+ if e := f.s.Sweep(t.Context(), f.now.Add(601*time.Second)); e != nil {
+ t.Fatal(e)
+ }
+ if count(t, f.db, "oficina_handoff_pending") != 0 {
+ t.Fatal("orphan survived expiry")
+ }
+}
diff --git a/handoff/http.go b/handoff/http.go
index 0eaaf54..fd0fe64 100644
--- a/handoff/http.go
+++ b/handoff/http.go
@@ -22,6 +22,12 @@ const (
type ConsumerConfig struct {
Store *Store
ConsumerID string
+ // RequireFreshAuth requests and enforces actual authentication within five minutes.
+ RequireFreshAuth bool
+ // BindStart binds the public nonce to an app initiation after pending storage
+ // commits, before any cookie or redirect. It must not make network calls or
+ // create grants. Rejection cancels this new pending request.
+ BindStart func(*http.Request, string) error
// Resolve before store transactions, using the app's session middleware.
CurrentSubject func(*http.Request) string
// Complete runs only after replay consumption is committed. It creates the
@@ -129,13 +135,31 @@ func (c *Consumer) Start(w http.ResponseWriter, r *http.Request) {
if !ok {
previous = ""
}
- secret, nonce, e := c.cfg.Store.Start(r.Context(), path, previous, c.cfg.Now())
+ start := c.cfg.Store.Start
+ if c.cfg.RequireFreshAuth {
+ start = c.cfg.Store.StartFresh
+ }
+ secret, nonce, e := start(r.Context(), path, previous, c.cfg.Now())
if e != nil {
c.fail(w, r, e)
return
}
+ if c.cfg.BindStart != nil {
+ if e = c.cfg.BindStart(r, nonce); e != nil {
+ failure := ErrRefused
+ if c.cfg.Store.cancel(r.Context(), secret) != nil {
+ failure = ErrStorage
+ }
+ c.fail(w, r, failure)
+ return
+ }
+ }
setCookie(w, pendingCookie, secret, int(pendingLifetime))
- target := c.cfg.Store.issuer + "/authorize?" + url.Values{"consumer": {c.cfg.ConsumerID}, "nonce": {nonce}}.Encode()
+ query := url.Values{"consumer": {c.cfg.ConsumerID}, "nonce": {nonce}}
+ if c.cfg.RequireFreshAuth {
+ query.Set("max_age", "300")
+ }
+ target := c.cfg.Store.issuer + "/authorize?" + query.Encode()
http.Redirect(w, r, target, http.StatusSeeOther)
}
diff --git a/handoff/store.go b/handoff/store.go
index 0d03bdf..65c2281 100644
--- a/handoff/store.go
+++ b/handoff/store.go
@@ -46,6 +46,8 @@ const inboxLifetime = int64(120)
// allow a token to be reused after the clock moves backwards.
const clockTolerance = int64(30)
const replayRetention = clockTolerance
+const freshAuthLifetime = int64(300)
+const freshPathPrefix = "oficina-handoff-fresh-v1:"
// Caps bound unauthenticated navigation and verified callback storage. Pending
// eviction only loses navigation; inbox eviction could disrupt a valid proof.
@@ -138,29 +140,29 @@ func SafePath(s string) bool {
// begin takes the SQLite write lock before reading state, so several database
// connections cannot race a capacity check or upgrade a stale read snapshot.
-func (s *Store) begin(ctx context.Context, now time.Time) (*sql.Tx, int64, error) {
+func (s *Store) begin(ctx context.Context, now time.Time) (*sql.Tx, int64, int64, error) {
n := now.Unix()
if n <= 0 || n > math.MaxInt64-pendingLifetime {
- return nil, 0, ErrClock
+ return nil, 0, 0, ErrClock
}
tx, e := s.db.BeginTx(ctx, nil)
if e != nil {
- return nil, 0, ErrStorage
+ return nil, 0, 0, ErrStorage
}
result, e := tx.ExecContext(ctx, `UPDATE oficina_handoff_clock SET observed=MAX(observed,?) WHERE id=1 AND observed<=?`, n, n+clockTolerance)
if e != nil {
tx.Rollback()
- return nil, 0, ErrStorage
+ return nil, 0, 0, ErrStorage
}
rows, e := result.RowsAffected()
if e != nil || rows != 1 {
tx.Rollback()
- return nil, 0, ErrClock
+ return nil, 0, 0, ErrClock
}
var fence int64
if e = tx.QueryRowContext(ctx, `SELECT observed FROM oficina_handoff_clock WHERE id=1`).Scan(&fence); e != nil {
tx.Rollback()
- return nil, 0, ErrStorage
+ return nil, 0, 0, ErrStorage
}
for _, q := range []struct {
sql string
@@ -172,14 +174,14 @@ func (s *Store) begin(ctx context.Context, now time.Time) (*sql.Tx, int64, error
} {
if _, e = tx.ExecContext(ctx, q.sql, q.n); e != nil {
tx.Rollback()
- return nil, 0, ErrStorage
+ return nil, 0, 0, ErrStorage
}
}
- return tx, n, nil
+ return tx, n, fence, nil
}
func (s *Store) Sweep(ctx context.Context, now time.Time) error {
- tx, _, e := s.begin(ctx, now)
+ tx, _, _, e := s.begin(ctx, now)
if e != nil {
return e
}
@@ -193,6 +195,16 @@ func (s *Store) Sweep(ctx context.Context, now time.Time) error {
// Start replaces this browser's previous pending request. Browser secrets and
// nonces are returned to the caller, but only their hashes enter the database.
func (s *Store) Start(ctx context.Context, path, previous string, now time.Time) (secret, nonce string, err error) {
+ return s.start(ctx, path, previous, now, false)
+}
+
+// StartFresh binds a five-minute authentication requirement into sealed intent.
+// Finish enforces it even when an ordinary consumer receives the callback.
+func (s *Store) StartFresh(ctx context.Context, path, previous string, now time.Time) (secret, nonce string, err error) {
+ return s.start(ctx, path, previous, now, true)
+}
+
+func (s *Store) start(ctx context.Context, path, previous string, now time.Time, fresh bool) (secret, nonce string, err error) {
if !SafePath(path) {
return "", "", ErrRefused
}
@@ -204,12 +216,15 @@ func (s *Store) Start(ctx context.Context, path, previous string, now time.Time)
if err != nil {
return "", "", ErrStorage
}
+ if fresh {
+ path = freshPathPrefix + path
+ }
hash := digest(secret)
sealed, e := s.sealer.Seal("handoff.path."+hash, []byte(path))
if e != nil {
return "", "", ErrStorage
}
- tx, n, e := s.begin(ctx, now)
+ tx, n, _, e := s.begin(ctx, now)
if e != nil {
return "", "", e
}
@@ -237,6 +252,18 @@ func (s *Store) Start(ctx context.Context, path, previous string, now time.Time)
return secret, nonce, nil
}
+// cancel removes only the unpublished pending request after an app rejects its
+// binding. Concurrent eviction may already have removed it; that is success.
+func (s *Store) cancel(ctx context.Context, secret string) error {
+ if !validHandle(secret) {
+ return ErrRefused
+ }
+ if _, e := s.db.ExecContext(ctx, `DELETE FROM oficina_handoff_pending WHERE secret_hash=?`, digest(secret)); e != nil {
+ return ErrStorage
+ }
+ return nil
+}
+
// Receive deliberately needs no cookie: a cross-site POST may carry none. A
// repeated assertion gets no new inbox row or cookie, even before Finish runs.
func (s *Store) Receive(ctx context.Context, token string, now time.Time) (string, error) {
@@ -254,7 +281,7 @@ func (s *Store) Receive(ctx context.Context, token string, now time.Time) (strin
if e != nil {
return "", ErrStorage
}
- tx, n, e := s.begin(ctx, now)
+ tx, n, _, e := s.begin(ctx, now)
if e != nil {
return "", e
}
@@ -299,7 +326,7 @@ func (s *Store) Finish(ctx context.Context, secret, handle, existingSubject stri
if !validHandle(secret) || !validHandle(handle) {
return zero, "", ErrRefused
}
- tx, n, e := s.begin(ctx, now)
+ tx, n, fence, e := s.begin(ctx, now)
if e != nil {
return zero, "", e
}
@@ -325,7 +352,14 @@ func (s *Store) Finish(ctx context.Context, secret, handle, existingSubject stri
return zero, "", ErrRefused
}
path, e := s.sealer.Open("handoff.path."+secretHash, sealedPath)
- if e != nil || !SafePath(string(path)) {
+ if e != nil {
+ return zero, "", ErrRefused
+ }
+ fresh := strings.HasPrefix(string(path), freshPathPrefix)
+ if fresh {
+ path = path[len(freshPathPrefix):]
+ }
+ if !SafePath(string(path)) {
return zero, "", ErrRefused
}
result, e := tx.ExecContext(ctx, `INSERT INTO oficina_handoff_replay(jti_hash,expires_at) VALUES(?,?) ON CONFLICT DO NOTHING`, jtiHash(claims), claims.ExpiresAt)
@@ -352,6 +386,11 @@ func (s *Store) Finish(ctx context.Context, secret, handle, existingSubject stri
if tx.Commit() != nil {
return zero, "", ErrStorage
}
+ // Burn an unusable fresh proof before refusing it: waiting or changing
+ // handler configuration must never make it usable later.
+ if fresh && (claims.AuthTime > n || fence-claims.AuthTime > freshAuthLifetime) {
+ return zero, "", ErrRefused
+ }
return claims, string(path), nil
}
func readError(e error) error {
diff --git a/implementation/link-consumer-binding.md b/implementation/link-consumer-binding.md
new file mode 100644
index 0000000..0405c5b
--- /dev/null
+++ b/implementation/link-consumer-binding.md
@@ -0,0 +1,21 @@
+# Bind sensitive handoffs to local account-link initiation
+
+Status: design approved by claude-fable-5 at c4fe2bc, 2026-09-08; implemented by Codex and awaiting independent code review. Required by the Fable-approved Docs linking design2e69825. Issuer max_age300 is already merged Home3947850 and deployed47301e8. The existing shared consumer verifies identity, nonce and replay, but does not request/enforce fresh authentication or expose a safe application nonce-binding hook.
+
+Add ConsumerConfig.RequireFreshAuth (default false) and BindStart func(*http.Request, string) error (default nil). The callback receives the generated public nonce, never the pending browser secret or assertion token. Call it after Store creates the pending row and before setting the pending cookie or redirecting to Home. Docs will atomically bind that nonce's hash to the previously initiated local person/session/origin record. A rejected binding cancels only the newly created pending row, returns a fixed failure, sets no new pending cookie and never redirects. If cancellation itself fails, return the existing generic storage failure; the orphan row remains subject to existing expiry/cap eviction. Binding must not be an external network operation or create an authorization grant. A successful binding followed by a lost response is an abandoned bounded flow, not a completed link.
+
+Add Store.StartFresh alongside unchanged Store.Start, using one internal implementation. StartFresh marks the authentication requirement inside the already sealed return-path payload, not in a browser query or untrusted caller-selected Finish flag. Use the fixed plaintext prefix oficina-handoff-fresh-v1: before the validated local path; normal Start keeps the exact old plaintext path. The prefix is not a valid SafePath. New Finish recognizes exactly that prefix, strips it and validates the resulting path. Unknown prefixes remain invalid. This needs no schema migration and protects the policy with the existing authenticated encryption. An old reader decrypts the fresh payload but refuses it as an invalid path, so rollback cannot silently downgrade a sensitive exchange. Ordinary old/new rows stay compatible.
+
+RequireFreshAuth selects StartFresh and adds max_age=300 to the registered issuer redirect. The age request is convenience only: Store.Finish enforces the sealed policy even if the query parameter was stripped or an ordinary Consumer instance handles Finish. After normal signature/issuer/audience/nonce/subject checks, replay insertion and deletion of pending/inbox commit as before. For a fresh payload, return identity only if actual claims.AuthTime is not later than the supplied wall-clock now and is within300 seconds of the persisted clock high-water value returned by begin. Otherwise return zero claims, empty path and ErrRefused AFTER consumption. The high-water comparison prevents a tolerated backward clock adjustment from reviving stale authentication. The wall-clock comparison prevents crediting future authentication. No timestamp is clamped or rewritten.
+
+The after-commit refusal is deliberate: an unusable proof cannot later become usable by waiting, changing handler configuration, replaying the old browser cookie or moving the clock. No application Complete callback runs on the refusal. Consumers still decide permissions, and Docs confirmation separately checks both current identity proofs and initiation binding. Fresh identity is not membership or authorization.
+
+The app must initiate linking with its same-origin fresh local-account POST before this shared Start hook can bind anything. Merely receiving a Home assertion or setting RequireFreshAuth cannot initiate a link. Docs' hook validates exact current local session/person/origin and a pending initiation with no previously bound nonce; conflicting/racing starts have one winner. The generic library does not store local person IDs or make that application decision.
+
+Acceptance: ordinary old-auth exchange unchanged; fresh mode sends exact300; stripping it and finishing through ordinary Consumer cannot accept stale proof; fresh proof succeeds with unchanged auth_time; future proof and stale proof fail once then remain consumed, including after restart/config downgrade; tolerated clock rollback does not revive stale authentication; successful hook observes the exact nonce in the outbound registered redirect; rejected hook produces no cookie/redirect and cleans only its new pending row; concurrent app-binding fixture has one winner; duplicate incoming cookies retain existing refusal before any hook. Fresh sealed payload is rejected by old SafePath behavior, ordinary payload remains byte-shape compatible. Privacy/WAL/log gates, race tests and meaningful freshness/hook-bypass mutations must pass, followed by independent Fable review and actual CI. No Docs linking or team-ready claim from this library stage alone.
+
+Implementation evidence: make test passes root unit tests, Home race tests and nested consumer unit/race tests. handoff/fresh_test.go covers exact freshness boundaries, future authentication, persisted high-water rollback, consumption across database reopen and time advance, ordinary payload compatibility and old-reader fresh refusal, pre-consumption subject/path refusal, HTTP configuration/query downgrade, exact nonce binding, cancellation with concurrent eviction, generic cancellation storage failure with eventual sweep, duplicate cookies and one concurrent application binding winner. Four Go overlay mutations each fail the intended test: future credit, wall-time age in place of high-water age, freshness checked before consumption, and skipped binding. Logs: /tmp/oficina-link-consumer-full.log and /tmp/oficina-link-consumer-mutations/results.json.
+
+A deliberate failure case: transit time plus up to30 seconds of previously observed clock lead can shorten the effective five-minute consumer window. A consumer clock behind Home can also see actual fresh authentication as future. Both refuse and consume that proof; the user starts again. No timestamp is clamped. Older readers refuse fresh payloads before consumption, leaving an inert pending row until expiry.
+
+Publication prerequisite remains: integrate the independently reviewed shared sealer before publishing the combined module for Docs. This branch does not yet update Home's module pin or deploy either consumer.