oficina / home Public

Merge branch 'feat/home-fresh-assertion' into main

claude agent, operated by paul@keymail.dev 39478506a3b5338f7dd8446753caab062d49ef0c parent 0dc863cparent 47301e8
5 files changed, +258 −27
  • implementation/fresh-assertion.md +20 −0
  • internal/home/assertion_fresh_test.go +166 −0
  • internal/home/assertion_intent.go +38 −0
  • internal/home/exchange.go +32 −26
  • internal/home/signin.go +2 −1

CI passed — run details

diff --git a/implementation/fresh-assertion.md b/implementation/fresh-assertion.md
new file mode 100644
index 0000000..ae38981
--- /dev/null
+++ b/implementation/fresh-assertion.md
@@ -0,0 +1,20 @@
+# Fresh identity assertions
+
+Status: design approved by Claude Fable 5 at 4b184e7 on 2026-09-08; implementation by Codex, for the user's accepted common app entry work. Code and deployed evidence require their own review. Prior Home exchange eaefda9 is enabled only on the existing paired preview; production is unchanged. Home design §10 and §14 require actual reauthentication before roster changes. Docs linking must prove fresh possession of both identities while retaining existing people.id and grants.
+
+Add one optional authorize parameter: max_age=300. Absence retains normal SSO, including an old but valid credential session and its unchanged original authentication time. Any other value, repeated parameter or unknown parameter is refused. Five minutes matches the intended sensitive-operation freshness window; arbitrary max-age parsing and zero-age semantics are deferred.
+
+Include MaxAge (0 or 300) and optional original Subject in the sealed ten-minute assertion intent. When max_age is requested and a credential session already exists, bind Subject to that session's opaque person ref. The issuer's session lookup is authoritative; no subject comes from a query or form. A new unauthenticated flow may bind to the person who subsequently signs in. At resume, require the normal complete credential session, validate intent, refuse a different bound subject, and check sessions.Fresh at the requested age before signing. A stale session redirects to the existing credential sign-in screen with the closed /authorize/resume continuation. Existing second-factor gates still run on both first-factor paths.
+
+For this bounded step-up, a valid fresh-intent cookie causes handleSigninBegin to select signin.MethodMagicLink. This uses the existing email confirmation and optional passkey/recovery factor. It avoids accepting a standing upstream Keymail inbox session as a new gesture. The decision must be server-side: the existing form action does not retain query flags, so a force=1 query alone is insufficient. Invalid/expired/duplicate intent cannot request or satisfy freshness. No new copy or inline scripts are needed. This extra mail ceremony applies only when the user reaches sign-in during an explicit fresh flow; ordinary SSO and ordinary Keymail sign-in are unchanged.
+
+Do not advance AuthTime while issuing an assertion. A freshly minted session whose original AuthTime is old remains stale. Time checks use sessions.Fresh plus an explicit verified-time <= now check. Source inspection of Rastrillo0.23 sessions.Fresh found that it only checks now.Sub(verified) <= maxAge, so it accepts future times by itself. The issuer must not credit them. On successful reauthentication, the new assertion carries the actual verified identity's AuthTime, falling back to the session At only when AuthTime is absent, as in the approved existing implementation.
+
+No consumer authorization is inferred from max_age. It is a request to the issuer, not a proof for the receiver. A sensitive consumer must independently reject an assertion or session whose actual AuthTime exceeds its five-minute window; an attacker removing max_age must not make account linking or roster writes accept stale authentication. Shared consumer query forwarding and its consumer-side freshness enforcement will be a separate implementation stage, followed by Docs account linking. Do not claim this issuer-only stage enables those actions.
+
+Extract one sealed-intent decoder used by resume and signin begin. Preserve all existing bounds, purpose, canonical base64, duplicate-cookie checks and fixed error responses. Its addition must not allow directory role to run credential handlers or render account-authored strings on credential origin.
+
+Acceptance: normal exchange retains old AuthTime; max_age with stale session produces no assertion and preserves return; fresh session succeeds without unnecessary mail; malformed/duplicate age is refused; original subject cannot switch during step-up; fresh-intent begin forces actual email despite a Keymail-classifying test stub; full email+optional recovery continuation yields an assertion with genuine fresh AuthTime; tampered/expired/duplicate intent does not count as step-up; local privacy, origin, script and whole unit/race gates remain green. Meaningful mutation must demonstrate freshness test fails when the freshness guard is removed. Independent Fable code review and actual CI precede an isolated preview deployment; live emailed authentication remains distinct from local mail-capture proof.
+
+
+Implementation decisions after review: resolve the standing session explicitly with a.sessions.From at authorize because this router has no sessions.Middleware. A completed Keymail first factor plus recovery-code redemption qualifies as fresh within five minutes: recovery is a current credential and requires the gated first factor, while a half-session alone never qualifies. This is covered separately from stale-session forced mail. MaxAge=0 never forces mail. During rollback an old binary rejects a new nonzero-age sealed intent as an unknown field; the user must restart that in-flight exchange. No persistent data format changes.
diff --git a/internal/home/assertion_fresh_test.go b/internal/home/assertion_fresh_test.go
new file mode 100644
index 0000000..4be5d9c
--- /dev/null
+++ b/internal/home/assertion_fresh_test.go
@@ -0,0 +1,166 @@
+package home
+
+import (
+ "amadan.net/oficina/home/handoff"
+ "amadan.net/oficina/home/internal/home/sealtest"
+ "amadan.net/rastrillo/rastrillo/assertion"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "github.com/keymaildev/signin"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+func (p exchangePair) startFresh(t *testing.T, session *http.Cookie) (*http.Cookie, *http.Cookie) {
+ t.Helper()
+ start := exchangeRequest(p.directory, "GET", directoryOrigin+handoff.StartPath+"?return_to=%2F%3Fteam%3Dblue", "", nil)
+ if start.Code != 303 {
+ t.Fatal("start", start.Code)
+ }
+ authorize := exchangeRequest(p.credential.App, "GET", start.Header().Get("Location")+"&max_age=300", "", nil, session)
+ if authorize.Code != 303 {
+ t.Fatal("authorize", authorize.Code)
+ }
+ return namedCookie(t, start, "__Host-oficina-assertion-pending"), namedCookie(t, authorize, assertionIntentCookie)
+}
+func backdateCredential(t *testing.T, p exchangePair, at time.Time) {
+ t.Helper()
+ if _, err := p.credential.db.Writer().Exec(`UPDATE sessions SET auth_time=?`, at.UTC().Format(time.RFC3339)); err != nil {
+ t.Fatal(err)
+ }
+}
+func TestFreshAssertionReauthenticatesStaleSession(t *testing.T) {
+ p := newExchangePair(t)
+ oldSession := signInFully(t, p.credential, sealtest.CanaryAddress)
+ backdateCredential(t, p, time.Now().Add(-time.Hour))
+ pending, intent := p.startFresh(t, oldSession)
+ resume := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, intent, oldSession)
+ if resume.Code != 303 || resume.Header().Get("Location") != "/signin?return_to=%2Fauthorize%2Fresume" || strings.Contains(resume.Body.String(), `name="assertion"`) {
+ t.Fatal("stale session did not require real reauthentication", resume.Code)
+ }
+ form := exchangeRequest(p.credential.App, "GET", Origin+resume.Header().Get("Location"), "", nil, intent, oldSession)
+ navigation := namedCookie(t, form, returnCookie)
+ p.credential.flow.Classifier = &signin.Classifier{HTTP: &http.Client{Timeout: classifierTimeout}}
+ newKeymailStub(t, p.credential, sealtest.CanaryAddress)
+ if !p.credential.flow.Classifier.Classify(context.Background(), sealtest.CanaryAddress).Keymail {
+ t.Fatal("fixture does not advertise Keymail")
+ }
+ begin := exchangeRequest(p.credential.App, "POST", Origin+"/signin", Origin, url.Values{"address": {sealtest.CanaryAddress}}, intent, oldSession, navigation)
+ if begin.Code != 303 || begin.Header().Get("Location") != "/signin/sent" {
+ t.Fatal("fresh flow reused upstream sign-in instead of emailing", begin.Code)
+ }
+ verified := exchangeRequest(p.credential.App, "POST", Origin+linkPath, Origin, url.Values{"token": {capturedLink(t, p.credential)}}, intent, oldSession, navigation)
+ if verified.Code != 303 || verified.Header().Get("Location") != "/authorize/resume" {
+ t.Fatal("lost reauthentication continuation", verified.Code)
+ }
+ token, _ := p.mint(t, intent, sessionCookie(verified))
+ verifier, err := assertion.NewVerifier(Origin, directoryOrigin, p.credential.runtime.keys)
+ if err != nil {
+ t.Fatal(err)
+ }
+ claims, err := verifier.Verify(token, time.Now())
+ if err != nil || time.Now().Unix()-claims.AuthTime > 10 {
+ t.Fatal("assertion did not carry fresh verified time", err)
+ }
+ p.finish(t, token, pending)
+ sealtest.AssertNoPlaintext(t, p.credential.dbPath)
+ sealtest.AssertNoPlaintext(t, p.directoryPath)
+}
+func TestFreshAssertionUsesFreshSessionAndBindsOriginalPerson(t *testing.T) {
+ p := newExchangePair(t)
+ first := signInFully(t, p.credential, sealtest.CanaryAddress)
+ _, intent := p.startFresh(t, first)
+ p.mint(t, intent, first)
+ _, intent = p.startFresh(t, first)
+ second := signInFully(t, p.credential, "different@example.invalid")
+ r := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, intent, second)
+ if r.Code != 400 {
+ t.Fatal("different person satisfied original identity step-up", r.Code)
+ }
+}
+func TestFreshAssertionRefusesFutureAuthentication(t *testing.T) {
+ p := newExchangePair(t)
+ session := signInFully(t, p.credential, sealtest.CanaryAddress)
+ backdateCredential(t, p, time.Now().Add(time.Hour))
+ _, intent := p.startFresh(t, session)
+ r := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, intent, session)
+ if r.Code != 303 || !strings.HasPrefix(r.Header().Get("Location"), "/signin?") {
+ t.Fatal("future authentication was credited", r.Code)
+ }
+}
+func TestFreshAssertionStrictAge(t *testing.T) {
+ p := newExchangePair(t)
+ nonce, _ := assertion.RandomID()
+ for _, suffix := range []string{"&max_age=0", "&max_age=301", "&max_age=0300", "&max_age=300&max_age=300", "&max_age=300&unexpected=1", "&max_age="} {
+ r := exchangeRequest(p.credential.App, "GET", Origin+"/authorize?consumer=directory&nonce="+nonce+suffix, "", nil)
+ if r.Code != 400 || len(r.Result().Cookies()) != 0 {
+ t.Fatal("invalid age accepted", suffix, r.Code)
+ }
+ }
+}
+func TestOrdinaryIntentKeepsKeymailAndInvalidIntentCannotRequestFreshness(t *testing.T) {
+ for _, mode := range []string{"ordinary", "tampered", "duplicate", "expired"} {
+ t.Run(mode, func(t *testing.T) {
+ p := newExchangePair(t)
+ newKeymailStub(t, p.credential, sealtest.CanaryAddress)
+ _, intent := p.start(t)
+ if mode != "ordinary" {
+ _, intent = p.startFresh(t, nil)
+ }
+ cookies := []*http.Cookie{intent}
+ switch mode {
+ case "tampered":
+ intent.Value = "tampered"
+ case "duplicate":
+ cookies = append(cookies, intent)
+ case "expired":
+ nonce, _ := assertion.RandomID()
+ raw, _ := json.Marshal(assertionIntent{Consumer: "directory", Nonce: nonce, Expires: time.Now().Unix() - 1, MaxAge: 300})
+ sealed, err := p.credential.secrets.Sealer.Seal("assertion.intent", raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ intent.Value = base64.RawURLEncoding.EncodeToString(sealed)
+ }
+ r := exchangeRequest(p.credential.App, "POST", Origin+"/signin", Origin, url.Values{"address": {sealtest.CanaryAddress}}, cookies...)
+ if r.Code != 303 || r.Header().Get("Location") == "/signin/sent" {
+ t.Fatal("ordinary or invalid intent forced mail", r.Code)
+ }
+ })
+ }
+}
+
+func TestFreshAssertionAcceptsCompletedKeymailRecovery(t *testing.T) {
+ p := newExchangePair(t)
+ const address = "person@keymail.invalid"
+ newKeymailStub(t, p.credential, address)
+ initialPending, initialState := beginKeymail(t, p.credential, address)
+ session := sessionCookie(callback(t, p.credential, "code-1", initialState, initialPending))
+ if session == nil {
+ t.Fatal("initial Keymail sign-in failed")
+ }
+ enrol(t, p.credential, session)
+ codes, err := p.credential.passkeys.RegenerateRecoveryCodes(subjectOf(t, p.credential, session))
+ if err != nil {
+ t.Fatal(err)
+ }
+ kp, state := beginKeymail(t, p.credential, address)
+ gated := callback(t, p.credential, "code-2", state, kp)
+ if sessionCookie(gated) != nil || gated.Header().Get("Location") != confirmPath {
+ t.Fatal("Keymail skipped second factor")
+ }
+ _, intent := p.startFresh(t, nil)
+ before := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, append(gated.Result().Cookies(), intent)...)
+ if before.Code != 303 {
+ t.Fatal("half-session satisfied freshness")
+ }
+ recovery := exchangeRequest(p.credential.App, "POST", Origin+"/passkey/signin/recovery", Origin, url.Values{"code": {codes[0]}}, gated.Result().Cookies()...)
+ if sessionCookie(recovery) == nil {
+ t.Fatal("recovery did not complete", recovery.Code)
+ }
+ p.mint(t, intent, sessionCookie(recovery))
+}
diff --git a/internal/home/assertion_intent.go b/internal/home/assertion_intent.go
new file mode 100644
index 0000000..f2eaab3
--- /dev/null
+++ b/internal/home/assertion_intent.go
@@ -0,0 +1,38 @@
+package home
+
+import (
+ "amadan.net/oficina/home/handoff"
+ "encoding/base64"
+ json "github.com/go-json-experiment/json"
+ "net/http"
+ "time"
+)
+
+// readAssertionIntent shares the same sealed, bounded intent between the
+// credential handoff and the first-factor choice during reauthentication.
+func (a *App) readAssertionIntent(r *http.Request, now time.Time) (assertionIntent, bool) {
+ value := ""
+ count := 0
+ for _, cookie := range r.Cookies() {
+ if cookie.Name == assertionIntentCookie {
+ count++
+ value = cookie.Value
+ }
+ }
+ if count != 1 || len(value) > 2048 {
+ return assertionIntent{}, false
+ }
+ sealed, err := base64.RawURLEncoding.Strict().DecodeString(value)
+ if err != nil || base64.RawURLEncoding.EncodeToString(sealed) != value {
+ return assertionIntent{}, false
+ }
+ plain, err := a.secrets.Sealer.Open("assertion.intent", sealed)
+ if err != nil {
+ return assertionIntent{}, false
+ }
+ var intent assertionIntent
+ if json.Unmarshal(plain, &intent, json.RejectUnknownMembers(true)) != nil || intent.Expires <= now.Unix() || intent.Expires > now.Unix()+600 || !handoff.ValidNonce(intent.Nonce) || (intent.MaxAge != 0 && intent.MaxAge != 300) || (intent.MaxAge == 0 && intent.Subject != "") {
+ return assertionIntent{}, false
+ }
+ return intent, true
+}
diff --git a/internal/home/exchange.go b/internal/home/exchange.go
index d75307a..e139590 100644
--- a/internal/home/exchange.go
+++ b/internal/home/exchange.go
@@ -18,16 +18,36 @@ type assertionIntent struct {
Consumer string `json:"consumer"`
Nonce string `json:"nonce"`
Expires int64 `json:"expires"`
+ MaxAge int `json:"max_age,omitzero"`
+ Subject string `json:"subject,omitzero"`
}
func (a *App) handleAuthorize(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
q, err := url.ParseQuery(r.URL.RawQuery)
- if err != nil || len(q) != 2 || len(q["consumer"]) != 1 || len(q["nonce"]) != 1 || !handoff.ValidNonce(q.Get("nonce")) || a.runtime.consumers[q.Get("consumer")] == "" {
+ maxAge := 0
+ if values, present := q["max_age"]; present {
+ if len(values) != 1 || values[0] != "300" {
+ a.signinFailed(w)
+ return
+ }
+ maxAge = 300
+ }
+ wantFields := 2
+ if maxAge != 0 {
+ wantFields++
+ }
+ if err != nil || len(q) != wantFields || len(q["consumer"]) != 1 || len(q["nonce"]) != 1 || !handoff.ValidNonce(q.Get("nonce")) || a.runtime.consumers[q.Get("consumer")] == "" {
a.signinFailed(w)
return
}
- b, err := json.Marshal(assertionIntent{q.Get("consumer"), q.Get("nonce"), time.Now().Unix() + 600})
+ intent := assertionIntent{Consumer: q.Get("consumer"), Nonce: q.Get("nonce"), Expires: time.Now().Unix() + 600, MaxAge: maxAge}
+ if maxAge != 0 {
+ if session, ok := a.sessions.From(r); ok {
+ intent.Subject = session.Subject
+ }
+ }
+ b, err := json.Marshal(intent)
if err != nil {
a.signinFailed(w)
return
@@ -48,31 +68,9 @@ func (a *App) handleAssertion(w http.ResponseWriter, r *http.Request) {
a.signinFailed(w)
return
}
- value := ""
- count := 0
- for _, cookie := range r.Cookies() {
- if cookie.Name == assertionIntentCookie {
- count++
- value = cookie.Value
- }
- }
- if count != 1 || len(value) > 2048 {
- a.signinFailed(w)
- return
- }
- sealed, err := base64.RawURLEncoding.Strict().DecodeString(value)
- if err != nil || base64.RawURLEncoding.EncodeToString(sealed) != value {
- a.signinFailed(w)
- return
- }
- plain, err := a.secrets.Sealer.Open("assertion.intent", sealed)
- if err != nil {
- a.signinFailed(w)
- return
- }
- var intent assertionIntent
now := time.Now()
- if json.Unmarshal(plain, &intent, json.RejectUnknownMembers(true)) != nil || intent.Expires <= now.Unix() || intent.Expires > now.Unix()+600 || !handoff.ValidNonce(intent.Nonce) {
+ intent, ok := a.readAssertionIntent(r, now)
+ if !ok {
a.signinFailed(w)
return
}
@@ -90,6 +88,14 @@ func (a *App) handleAssertion(w http.ResponseWriter, r *http.Request) {
if authTime.IsZero() {
authTime = session.At
}
+ if intent.Subject != "" && intent.Subject != session.Subject {
+ a.signinFailed(w)
+ return
+ }
+ if intent.MaxAge != 0 && (!sessions.Fresh(session, time.Duration(intent.MaxAge)*time.Second, now) || authTime.After(now)) {
+ http.Redirect(w, r, "/signin?return_to=%2Fauthorize%2Fresume", http.StatusSeeOther)
+ return
+ }
id, err := assertion.RandomID()
if err != nil {
a.signinFailed(w)
diff --git a/internal/home/signin.go b/internal/home/signin.go
index 83be359..e72329f 100644
--- a/internal/home/signin.go
+++ b/internal/home/signin.go
@@ -184,7 +184,8 @@ func (a *App) handleSigninBegin(w http.ResponseWriter, r *http.Request) {
// The force override carries a failed keymail round trip back to the
// magic link, so a failed approval is not a dead end.
var force signin.Method
- if r.URL.Query().Get("force") == "1" {
+ intent, freshIntent := a.readAssertionIntent(r, time.Now())
+ if r.URL.Query().Get("force") == "1" || (freshIntent && intent.MaxAge == 300) {
force = signin.MethodMagicLink
}