| 1 | package handoff |
| 2 | |
| 3 | import ( |
| 4 | "database/sql" |
| 5 | "errors" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "net/url" |
| 9 | "sync" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "amadan.net/rastrillo/rastrillo/assertion" |
| 14 | ) |
| 15 | |
| 16 | func (f fixture) timedToken(t *testing.T, nonce string, auth int64) string { |
| 17 | t.Helper() |
| 18 | id, e := assertion.RandomID() |
| 19 | if e != nil { |
| 20 | t.Fatal(e) |
| 21 | } |
| 22 | issued := f.now.Unix() |
| 23 | if auth > issued { |
| 24 | issued = auth |
| 25 | } |
| 26 | 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}) |
| 27 | if e != nil { |
| 28 | t.Fatal(e) |
| 29 | } |
| 30 | return token |
| 31 | } |
| 32 | |
| 33 | func TestFreshProofWindowAndConsumption(t *testing.T) { |
| 34 | for _, tc := range []struct { |
| 35 | name string |
| 36 | age, fence int64 |
| 37 | accept bool |
| 38 | }{ |
| 39 | {"now", 0, 0, true}, {"boundary", 300, 0, true}, {"old", 301, 0, false}, |
| 40 | {"future", -1, 0, false}, {"rollback boundary", 280, 20, true}, {"rollback stale", 281, 20, false}, |
| 41 | } { |
| 42 | t.Run(tc.name, func(t *testing.T) { |
| 43 | f := fresh(t) |
| 44 | secret, nonce, e := f.s.StartFresh(t.Context(), "/documents/private-canary", "", f.now) |
| 45 | if e != nil { |
| 46 | t.Fatal(e) |
| 47 | } |
| 48 | token := f.timedToken(t, nonce, f.now.Unix()-tc.age) |
| 49 | handle, e := f.s.Receive(t.Context(), token, f.now) |
| 50 | if e != nil { |
| 51 | t.Fatal(e) |
| 52 | } |
| 53 | if e = f.s.Sweep(t.Context(), f.now.Add(time.Duration(tc.fence)*time.Second)); e != nil { |
| 54 | t.Fatal(e) |
| 55 | } |
| 56 | claims, path, e := f.s.Finish(t.Context(), secret, handle, "", f.now) |
| 57 | if tc.accept { |
| 58 | if e != nil || path != "/documents/private-canary" || claims.AuthTime != f.now.Unix()-tc.age { |
| 59 | t.Fatalf("fresh proof changed or refused: %v", e) |
| 60 | } |
| 61 | } else if e != ErrRefused || path != "" || claims.Subject != "" || claims.AuthTime != 0 { |
| 62 | t.Fatal("unusable freshness returned identity") |
| 63 | } |
| 64 | 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 { |
| 65 | t.Fatal("fresh proof not consumed before refusal") |
| 66 | } |
| 67 | if e = f.db.Close(); e != nil { |
| 68 | t.Fatal(e) |
| 69 | } |
| 70 | db, e := sql.Open("sqlite", f.dbpath) |
| 71 | if e != nil { |
| 72 | t.Fatal(e) |
| 73 | } |
| 74 | defer db.Close() |
| 75 | f.db = db |
| 76 | f.s, e = NewStore(db, f.s.sealer, f.s.issuer, f.s.audience, map[string][]byte{"k1": f.k.SignPub()}) |
| 77 | if e != nil { |
| 78 | t.Fatal(e) |
| 79 | } |
| 80 | later := f.now.Add(30 * time.Second) |
| 81 | failFinish(t, f, secret, handle, "", later, ErrRefused) |
| 82 | if _, e = f.s.Receive(t.Context(), token, later); e != ErrRefused { |
| 83 | t.Fatal("proof revived after restart/wait", e) |
| 84 | } |
| 85 | }) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | func TestFreshIntentCompatibilityAndPreconsumptionRefusals(t *testing.T) { |
| 90 | f := fresh(t) |
| 91 | old, _, e := f.s.Start(t.Context(), "/ordinary", "", f.now) |
| 92 | if e != nil { |
| 93 | t.Fatal(e) |
| 94 | } |
| 95 | secret, nonce, e := f.s.StartFresh(t.Context(), "/sensitive", "", f.now) |
| 96 | if e != nil { |
| 97 | t.Fatal(e) |
| 98 | } |
| 99 | for _, tc := range []struct { |
| 100 | secret, want string |
| 101 | oldReadable bool |
| 102 | }{{old, "/ordinary", true}, {secret, freshPathPrefix + "/sensitive", false}} { |
| 103 | var b []byte |
| 104 | if e = f.db.QueryRow(`SELECT sealed_path FROM oficina_handoff_pending WHERE secret_hash=?`, digest(tc.secret)).Scan(&b); e != nil { |
| 105 | t.Fatal(e) |
| 106 | } |
| 107 | plain, e := f.s.sealer.Open("handoff.path."+digest(tc.secret), b) |
| 108 | if e != nil || string(plain) != tc.want || SafePath(string(plain)) != tc.oldReadable { |
| 109 | t.Fatal("old-reader compatibility lost") |
| 110 | } |
| 111 | } |
| 112 | handle, e := f.s.Receive(t.Context(), f.timedToken(t, nonce, f.now.Unix()-900), f.now) |
| 113 | if e != nil { |
| 114 | t.Fatal(e) |
| 115 | } |
| 116 | failFinish(t, f, secret, handle, "different_person", f.now, ErrRefused) |
| 117 | if count(t, f.db, "oficina_handoff_replay") != 0 || count(t, f.db, "oficina_handoff_inbox") != 1 { |
| 118 | t.Fatal("subject refusal consumed proof") |
| 119 | } |
| 120 | for _, bad := range []string{"oficina-handoff-fresh-v2:/sensitive", freshPathPrefix + "//foreign.example"} { |
| 121 | sealed, e := f.s.sealer.Seal("handoff.path."+digest(secret), []byte(bad)) |
| 122 | if e != nil { |
| 123 | t.Fatal(e) |
| 124 | } |
| 125 | if _, e = f.db.Exec(`UPDATE oficina_handoff_pending SET sealed_path=? WHERE secret_hash=?`, sealed, digest(secret)); e != nil { |
| 126 | t.Fatal(e) |
| 127 | } |
| 128 | failFinish(t, f, secret, handle, "", f.now, ErrRefused) |
| 129 | if count(t, f.db, "oficina_handoff_replay") != 0 || count(t, f.db, "oficina_handoff_inbox") != 1 { |
| 130 | t.Fatal("path refusal consumed proof") |
| 131 | } |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | func TestSensitiveHTTPRejectsDowngrade(t *testing.T) { |
| 136 | f := fresh(t) |
| 137 | c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error { |
| 138 | t.Fatal("stale proof reached Complete") |
| 139 | return nil |
| 140 | }) |
| 141 | c.cfg.RequireFreshAuth = true |
| 142 | var bound string |
| 143 | c.cfg.BindStart = func(r *http.Request, nonce string) error { bound = nonce; return nil } |
| 144 | start := httptest.NewRecorder() |
| 145 | c.Start(start, request("GET", StartPath+"?return_to=%2Fsensitive", "")) |
| 146 | if start.Code != 303 { |
| 147 | t.Fatal(start.Code) |
| 148 | } |
| 149 | target, e := url.Parse(start.Header().Get("Location")) |
| 150 | if e != nil { |
| 151 | t.Fatal(e) |
| 152 | } |
| 153 | if bound == "" || bound != target.Query().Get("nonce") || target.Query().Get("max_age") != "300" { |
| 154 | t.Fatal("freshness or nonce binding missing") |
| 155 | } |
| 156 | // An issuer request with max_age stripped can return an ordinary stale token. |
| 157 | callback := httptest.NewRecorder() |
| 158 | c.Callback(callback, request("POST", CallbackPath, url.Values{"assertion": {f.token(t, bound)}}.Encode())) |
| 159 | if callback.Code != 303 { |
| 160 | t.Fatal(callback.Code) |
| 161 | } |
| 162 | c.cfg.RequireFreshAuth = false |
| 163 | c.cfg.BindStart = nil |
| 164 | r := request("GET", FinishPath, "") |
| 165 | r.AddCookie(matchingCookie(t, start, pendingCookie)) |
| 166 | r.AddCookie(matchingCookie(t, callback, inboxCookie)) |
| 167 | finish := httptest.NewRecorder() |
| 168 | c.Finish(finish, r) |
| 169 | if finish.Code != 400 || count(t, f.db, "oficina_handoff_replay") != 1 { |
| 170 | t.Fatal("ordinary handler downgraded fresh intent") |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | func TestBindStartRejectsBeforePublishingAndCancelsOnlyNewRow(t *testing.T) { |
| 175 | for _, evict := range []bool{false, true} { |
| 176 | t.Run(map[bool]string{false: "present", true: "already evicted"}[evict], func(t *testing.T) { |
| 177 | f := fresh(t) |
| 178 | other, _ := f.pending(t) |
| 179 | c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error { return nil }) |
| 180 | calls := 0 |
| 181 | c.cfg.BindStart = func(r *http.Request, nonce string) error { |
| 182 | calls++ |
| 183 | // This DB operation also proves the callback is outside the store transaction. |
| 184 | var n int |
| 185 | if e := f.db.QueryRow(`SELECT COUNT(*) FROM oficina_handoff_pending WHERE nonce_hash=?`, digest(nonce)).Scan(&n); e != nil || n != 1 { |
| 186 | t.Fatal("binding ran before pending commit", e) |
| 187 | } |
| 188 | if evict { |
| 189 | if _, e := f.db.Exec(`DELETE FROM oficina_handoff_pending WHERE nonce_hash=?`, digest(nonce)); e != nil { |
| 190 | t.Fatal(e) |
| 191 | } |
| 192 | } |
| 193 | return errors.New("private app rejection") |
| 194 | } |
| 195 | f.db.SetMaxOpenConns(1) |
| 196 | rec := httptest.NewRecorder() |
| 197 | c.Start(rec, request("GET", StartPath, "")) |
| 198 | if rec.Code != 400 || rec.Header().Get("Location") != "" || len(rec.Result().Cookies()) != 0 || calls != 1 { |
| 199 | t.Fatal("rejected initiation published") |
| 200 | } |
| 201 | if rec.Body.String() != "Unable to sign in. Start again.\n" || count(t, f.db, "oficina_handoff_pending") != 1 { |
| 202 | t.Fatal("rejection leaked detail or failed cleanup") |
| 203 | } |
| 204 | var n int |
| 205 | if e := f.db.QueryRow(`SELECT COUNT(*) FROM oficina_handoff_pending WHERE secret_hash=?`, digest(other)).Scan(&n); e != nil || n != 1 { |
| 206 | t.Fatal("cancel removed another browser") |
| 207 | } |
| 208 | r := request("GET", StartPath, "") |
| 209 | r.AddCookie(&http.Cookie{Name: pendingCookie, Value: other}) |
| 210 | r.AddCookie(&http.Cookie{Name: pendingCookie, Value: other}) |
| 211 | rec = httptest.NewRecorder() |
| 212 | c.Start(rec, r) |
| 213 | if rec.Code != 400 || calls != 1 { |
| 214 | t.Fatal("duplicate cookies reached binding") |
| 215 | } |
| 216 | }) |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | func TestConcurrentAppInitiationHasOneBindingWinner(t *testing.T) { |
| 221 | f := fresh(t) |
| 222 | c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error { return nil }) |
| 223 | var mu sync.Mutex |
| 224 | bound := "" |
| 225 | wins := 0 |
| 226 | c.cfg.BindStart = func(r *http.Request, nonce string) error { |
| 227 | mu.Lock() |
| 228 | defer mu.Unlock() |
| 229 | if bound != "" { |
| 230 | return ErrRefused |
| 231 | } |
| 232 | bound = nonce |
| 233 | return nil |
| 234 | } |
| 235 | var wg sync.WaitGroup |
| 236 | for range 8 { |
| 237 | wg.Add(1) |
| 238 | go func() { |
| 239 | defer wg.Done() |
| 240 | rec := httptest.NewRecorder() |
| 241 | c.Start(rec, request("GET", StartPath, "")) |
| 242 | if rec.Code == 303 { |
| 243 | mu.Lock() |
| 244 | wins++ |
| 245 | mu.Unlock() |
| 246 | } else if rec.Code != 400 { |
| 247 | t.Errorf("unexpected status %d", rec.Code) |
| 248 | } |
| 249 | }() |
| 250 | } |
| 251 | wg.Wait() |
| 252 | if wins != 1 || count(t, f.db, "oficina_handoff_pending") != 1 { |
| 253 | t.Fatal("app binding race had multiple winners") |
| 254 | } |
| 255 | var n int |
| 256 | if e := f.db.QueryRow(`SELECT COUNT(*) FROM oficina_handoff_pending WHERE nonce_hash=?`, digest(bound)).Scan(&n); e != nil || n != 1 { |
| 257 | t.Fatal("winning nonce missing") |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | func TestBindingCancelFailureIsGenericAndBounded(t *testing.T) { |
| 262 | f := fresh(t) |
| 263 | c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error { return nil }) |
| 264 | c.cfg.BindStart = func(r *http.Request, nonce string) error { |
| 265 | // Force only the compensating delete to fail, leaving its bounded row. |
| 266 | _, e := f.db.Exec(`CREATE TRIGGER refuse_cancel BEFORE DELETE ON oficina_handoff_pending BEGIN SELECT RAISE(ABORT,'private failure'); END`) |
| 267 | if e != nil { |
| 268 | t.Fatal(e) |
| 269 | } |
| 270 | return errors.New("private initiation") |
| 271 | } |
| 272 | rec := httptest.NewRecorder() |
| 273 | c.Start(rec, request("GET", StartPath, "")) |
| 274 | if rec.Code != 503 || len(rec.Result().Cookies()) != 0 || rec.Header().Get("Location") != "" || rec.Body.String() != "Unable to sign in. Start again.\n" { |
| 275 | t.Fatal("cancellation failure exposed or published") |
| 276 | } |
| 277 | if count(t, f.db, "oficina_handoff_pending") != 1 { |
| 278 | t.Fatal("missing orphan fixture") |
| 279 | } |
| 280 | if _, e := f.db.Exec(`DROP TRIGGER refuse_cancel`); e != nil { |
| 281 | t.Fatal(e) |
| 282 | } |
| 283 | if e := f.s.Sweep(t.Context(), f.now.Add(601*time.Second)); e != nil { |
| 284 | t.Fatal(e) |
| 285 | } |
| 286 | if count(t, f.db, "oficina_handoff_pending") != 0 { |
| 287 | t.Fatal("orphan survived expiry") |
| 288 | } |
| 289 | } |
| 290 | |