| 1 | package handoff |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/rand" |
| 7 | "database/sql" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "reflect" |
| 11 | "strings" |
| 12 | "sync" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "amadan.net/rastrillo/rastrillo/assertion" |
| 17 | "amadan.net/rastrillo/rastrillo/crypto" |
| 18 | _ "modernc.org/sqlite" |
| 19 | ) |
| 20 | |
| 21 | type testSealer struct{ key []byte } |
| 22 | |
| 23 | func (s testSealer) Seal(p string, b []byte) ([]byte, error) { |
| 24 | return crypto.SealSym(crypto.Derive(s.key, p), b) |
| 25 | } |
| 26 | func (s testSealer) Open(p string, b []byte) ([]byte, error) { |
| 27 | return crypto.OpenSym(crypto.Derive(s.key, p), b) |
| 28 | } |
| 29 | |
| 30 | type fixture struct { |
| 31 | s *Store |
| 32 | k *crypto.Keypair |
| 33 | now time.Time |
| 34 | dbpath string |
| 35 | db *sql.DB |
| 36 | } |
| 37 | |
| 38 | func fresh(t *testing.T) fixture { |
| 39 | t.Helper() |
| 40 | path := filepath.Join(t.TempDir(), "handoff.db") |
| 41 | db, e := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") |
| 42 | if e != nil { |
| 43 | t.Fatal(e) |
| 44 | } |
| 45 | db.SetMaxOpenConns(4) |
| 46 | t.Cleanup(func() { db.Close() }) |
| 47 | schema, e := Migrations.ReadFile("migrations/0001_exchange.sql") |
| 48 | if e != nil { |
| 49 | t.Fatal(e) |
| 50 | } |
| 51 | if _, e = db.Exec(string(schema)); e != nil { |
| 52 | t.Fatal(e) |
| 53 | } |
| 54 | key := make([]byte, 32) |
| 55 | rand.Read(key) |
| 56 | k, e := crypto.Generate() |
| 57 | if e != nil { |
| 58 | t.Fatal(e) |
| 59 | } |
| 60 | s, e := NewStore(db, testSealer{key}, "https://id.example.com", "https://app.example.com", map[string][]byte{"k1": k.SignPub()}) |
| 61 | if e != nil { |
| 62 | t.Fatal(e) |
| 63 | } |
| 64 | return fixture{s: s, k: k, now: time.Unix(1700000000, 0), dbpath: path, db: db} |
| 65 | } |
| 66 | func (f fixture) token(t *testing.T, nonce string) string { |
| 67 | t.Helper() |
| 68 | id, e := assertion.RandomID() |
| 69 | if e != nil { |
| 70 | t.Fatal(e) |
| 71 | } |
| 72 | s, e := assertion.Sign(f.k, assertion.Claims{Version: 1, Issuer: "https://id.example.com", Audience: "https://app.example.com", Subject: "person_123", KeyID: "k1", ID: id, Nonce: nonce, IssuedAt: f.now.Unix(), ExpiresAt: f.now.Unix() + 120, AuthTime: f.now.Unix() - 900}) |
| 73 | if e != nil { |
| 74 | t.Fatal(e) |
| 75 | } |
| 76 | return s |
| 77 | } |
| 78 | func (f fixture) pending(t *testing.T) (string, string) { |
| 79 | t.Helper() |
| 80 | secret, nonce, e := f.s.Start(context.Background(), "/documents/private-canary?team=blue", "", f.now) |
| 81 | if e != nil { |
| 82 | t.Fatal(e) |
| 83 | } |
| 84 | return secret, nonce |
| 85 | } |
| 86 | func (f fixture) received(t *testing.T, nonce string) (string, string) { |
| 87 | t.Helper() |
| 88 | token := f.token(t, nonce) |
| 89 | handle, e := f.s.Receive(context.Background(), token, f.now) |
| 90 | if e != nil { |
| 91 | t.Fatal(e) |
| 92 | } |
| 93 | return handle, token |
| 94 | } |
| 95 | func count(t *testing.T, db *sql.DB, table string) int { |
| 96 | t.Helper() |
| 97 | var n int |
| 98 | if e := db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n); e != nil { |
| 99 | t.Fatal(e) |
| 100 | } |
| 101 | return n |
| 102 | } |
| 103 | func failFinish(t *testing.T, f fixture, secret, handle, subject string, now time.Time, want error) { |
| 104 | t.Helper() |
| 105 | c, p, e := f.s.Finish(context.Background(), secret, handle, subject, now) |
| 106 | if e != want || p != "" || !reflect.DeepEqual(c, assertion.Claims{}) { |
| 107 | t.Fatalf("finish refused: %v wanted %v zero=%v", e, want, reflect.DeepEqual(c, assertion.Claims{})) |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | func TestExchangeAndReplay(t *testing.T) { |
| 112 | f := fresh(t) |
| 113 | secret, nonce := f.pending(t) |
| 114 | handle, token := f.received(t, nonce) |
| 115 | c, path, e := f.s.Finish(context.Background(), secret, handle, "", f.now) |
| 116 | if e != nil || c.Subject != "person_123" || c.AuthTime != f.now.Unix()-900 || path != "/documents/private-canary?team=blue" { |
| 117 | t.Fatalf("finish: %v", e) |
| 118 | } |
| 119 | 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 { |
| 120 | t.Fatal("consumption not atomic") |
| 121 | } |
| 122 | failFinish(t, f, secret, handle, "", f.now, ErrRefused) |
| 123 | if h, e := f.s.Receive(context.Background(), token, f.now); e != ErrRefused || h != "" { |
| 124 | t.Fatal("replayed callback accepted") |
| 125 | } |
| 126 | if count(t, f.db, "oficina_handoff_inbox") != 0 { |
| 127 | t.Fatal("replay allocated inbox") |
| 128 | } |
| 129 | } |
| 130 | func TestBrowserAndSubjectBinding(t *testing.T) { |
| 131 | f := fresh(t) |
| 132 | alice, an := f.pending(t) |
| 133 | bob, bn := f.pending(t) |
| 134 | ah, _ := f.received(t, an) |
| 135 | bh, _ := f.received(t, bn) |
| 136 | failFinish(t, f, alice, bh, "", f.now, ErrRefused) |
| 137 | failFinish(t, f, bob, ah, "", f.now, ErrRefused) |
| 138 | failFinish(t, f, alice, ah, "someone_else", f.now, ErrRefused) |
| 139 | if _, _, e := f.s.Finish(context.Background(), alice, ah, "person_123", f.now); e != nil { |
| 140 | t.Fatal("failed attempts consumed legitimate request", e) |
| 141 | } |
| 142 | } |
| 143 | func TestConcurrentFinishAndCallback(t *testing.T) { |
| 144 | f := fresh(t) |
| 145 | secret, nonce := f.pending(t) |
| 146 | token := f.token(t, nonce) |
| 147 | var mu sync.Mutex |
| 148 | var handles []string |
| 149 | var wg sync.WaitGroup |
| 150 | for range 12 { |
| 151 | wg.Add(1) |
| 152 | go func() { |
| 153 | defer wg.Done() |
| 154 | h, e := f.s.Receive(context.Background(), token, f.now) |
| 155 | if e == nil { |
| 156 | mu.Lock() |
| 157 | handles = append(handles, h) |
| 158 | mu.Unlock() |
| 159 | } else if e != ErrRefused { |
| 160 | t.Error(e) |
| 161 | } |
| 162 | }() |
| 163 | } |
| 164 | wg.Wait() |
| 165 | if len(handles) != 1 { |
| 166 | t.Fatalf("callback winners %d", len(handles)) |
| 167 | } |
| 168 | wins := 0 |
| 169 | for range 12 { |
| 170 | wg.Add(1) |
| 171 | go func() { |
| 172 | defer wg.Done() |
| 173 | _, _, e := f.s.Finish(context.Background(), secret, handles[0], "", f.now) |
| 174 | if e == nil { |
| 175 | mu.Lock() |
| 176 | wins++ |
| 177 | mu.Unlock() |
| 178 | } else if e != ErrRefused { |
| 179 | t.Error(e) |
| 180 | } |
| 181 | }() |
| 182 | } |
| 183 | wg.Wait() |
| 184 | if wins != 1 { |
| 185 | t.Fatalf("finish winners %d", wins) |
| 186 | } |
| 187 | } |
| 188 | func TestReplacementAndExpiry(t *testing.T) { |
| 189 | f := fresh(t) |
| 190 | old, nonce := f.pending(t) |
| 191 | handle, _ := f.received(t, nonce) |
| 192 | if _, _, e := f.s.Start(context.Background(), "/", old, f.now); e != nil { |
| 193 | t.Fatal(e) |
| 194 | } |
| 195 | failFinish(t, f, old, handle, "", f.now, ErrRefused) |
| 196 | secret, nonce := f.pending(t) |
| 197 | handle, _ = f.received(t, nonce) |
| 198 | failFinish(t, f, secret, handle, "", f.now.Add(120*time.Second), ErrRefused) |
| 199 | if e := f.s.Sweep(context.Background(), f.now.Add(601*time.Second)); e != nil { |
| 200 | t.Fatal(e) |
| 201 | } |
| 202 | if count(t, f.db, "oficina_handoff_pending") != 0 || count(t, f.db, "oficina_handoff_inbox") != 0 { |
| 203 | t.Fatal("expired rows survive") |
| 204 | } |
| 205 | } |
| 206 | func TestClockFenceAndRestart(t *testing.T) { |
| 207 | f := fresh(t) |
| 208 | secret, nonce := f.pending(t) |
| 209 | handle, token := f.received(t, nonce) |
| 210 | if _, _, e := f.s.Finish(context.Background(), secret, handle, "", f.now); e != nil { |
| 211 | t.Fatal(e) |
| 212 | } |
| 213 | if e := f.s.Sweep(context.Background(), f.now.Add(150*time.Second)); e != nil { |
| 214 | t.Fatal(e) |
| 215 | } |
| 216 | if count(t, f.db, "oficina_handoff_replay") != 1 { |
| 217 | t.Fatal("replay pruned at retention boundary") |
| 218 | } |
| 219 | if e := f.s.Sweep(context.Background(), f.now.Add(151*time.Second)); e != nil { |
| 220 | t.Fatal(e) |
| 221 | } |
| 222 | if count(t, f.db, "oficina_handoff_replay") != 0 { |
| 223 | t.Fatal("old replay not pruned") |
| 224 | } |
| 225 | if e := f.db.Close(); e != nil { |
| 226 | t.Fatal(e) |
| 227 | } |
| 228 | db, e := sql.Open("sqlite", f.dbpath) |
| 229 | if e != nil { |
| 230 | t.Fatal(e) |
| 231 | } |
| 232 | defer db.Close() |
| 233 | f.s.db = db |
| 234 | if e = f.s.Sweep(context.Background(), f.now.Add(121*time.Second)); e != nil { |
| 235 | t.Fatal("tolerated rollback refused", e) |
| 236 | } |
| 237 | if _, e = f.s.Receive(context.Background(), token, f.now.Add(121*time.Second)); e != ErrRefused { |
| 238 | t.Fatal("pruned token reappeared", e) |
| 239 | } |
| 240 | if e = f.s.Sweep(context.Background(), f.now.Add(120*time.Second)); e != ErrClock { |
| 241 | t.Fatal("large rollback not fenced", e) |
| 242 | } |
| 243 | if replayRetention < clockTolerance { |
| 244 | t.Fatal("clock rollback exceeds retained history") |
| 245 | } |
| 246 | } |
| 247 | func TestStorageFailureDoesNotReturnIdentity(t *testing.T) { |
| 248 | f := fresh(t) |
| 249 | secret, nonce := f.pending(t) |
| 250 | handle, _ := f.received(t, nonce) |
| 251 | if _, e := f.db.Exec(`CREATE TRIGGER break_delete BEFORE DELETE ON oficina_handoff_pending BEGIN SELECT RAISE(ABORT,'synthetic failure'); END`); e != nil { |
| 252 | t.Fatal(e) |
| 253 | } |
| 254 | failFinish(t, f, secret, handle, "", f.now, ErrStorage) |
| 255 | if count(t, f.db, "oficina_handoff_replay") != 0 || count(t, f.db, "oficina_handoff_pending") != 1 || count(t, f.db, "oficina_handoff_inbox") != 1 { |
| 256 | t.Fatal("partial transaction survived") |
| 257 | } |
| 258 | } |
| 259 | func TestPrivateValuesNeverReachDBOrWAL(t *testing.T) { |
| 260 | f := fresh(t) |
| 261 | secret, nonce := f.pending(t) |
| 262 | handle, token := f.received(t, nonce) |
| 263 | for _, path := range []string{f.dbpath, f.dbpath + "-wal"} { |
| 264 | b, e := os.ReadFile(path) |
| 265 | if e != nil { |
| 266 | t.Fatal(e) |
| 267 | } |
| 268 | for _, value := range []string{secret, nonce, handle, token, "private-canary", "?team=blue", "person_123"} { |
| 269 | if bytes.Contains(b, []byte(value)) { |
| 270 | t.Fatalf("plaintext leaked in %s", filepath.Base(path)) |
| 271 | } |
| 272 | } |
| 273 | } |
| 274 | } |
| 275 | func TestBounds(t *testing.T) { |
| 276 | f := fresh(t) |
| 277 | tx, e := f.db.Begin() |
| 278 | if e != nil { |
| 279 | t.Fatal(e) |
| 280 | } |
| 281 | for i := 0; i < rowLimit; i++ { |
| 282 | if _, e = tx.Exec(`INSERT INTO oficina_handoff_pending(secret_hash,nonce_hash,sealed_path,created_at,expires_at) VALUES(printf('%d',?),printf('%d',?),x'00',?,?)`, i, i, f.now.Unix()-1, f.now.Unix()+600); e != nil { |
| 283 | t.Fatal(e) |
| 284 | } |
| 285 | } |
| 286 | if e = tx.Commit(); e != nil { |
| 287 | t.Fatal(e) |
| 288 | } |
| 289 | _, nonce := f.pending(t) |
| 290 | if count(t, f.db, "oficina_handoff_pending") != rowLimit { |
| 291 | t.Fatal("pending cap not bounded") |
| 292 | } |
| 293 | tx, e = f.db.Begin() |
| 294 | if e != nil { |
| 295 | t.Fatal(e) |
| 296 | } |
| 297 | for i := 0; i < rowLimit; i++ { |
| 298 | if _, e = tx.Exec(`INSERT INTO oficina_handoff_inbox(handle_hash,jti_hash,sealed_token,created_at,expires_at) VALUES(printf('%d',?),printf('%d',?),x'00',?,?)`, i, i, f.now.Unix(), f.now.Unix()+120); e != nil { |
| 299 | t.Fatal(e) |
| 300 | } |
| 301 | } |
| 302 | if e = tx.Commit(); e != nil { |
| 303 | t.Fatal(e) |
| 304 | } |
| 305 | if h, e := f.s.Receive(context.Background(), f.token(t, nonce), f.now); e != ErrFull || h != "" { |
| 306 | t.Fatal("full inbox admitted", e) |
| 307 | } |
| 308 | } |
| 309 | func TestSafePathsAndNonce(t *testing.T) { |
| 310 | for _, s := range []string{"/", "/documents/a?team=1", "/sheets?q=hello%20world"} { |
| 311 | if !SafePath(s) { |
| 312 | t.Fatal(s) |
| 313 | } |
| 314 | } |
| 315 | for _, s := range []string{"", "//evil.example/", "https://evil.example/", "/\\evil", "/%2f%2fevil", "/%2e%2e/auth/start", "/auth/start", "/%61uth/start", "/signin", "/authorize/resume", "/a\n", "/a#fragment", strings.Repeat("a", 2049)} { |
| 316 | if SafePath(s) { |
| 317 | t.Fatal(s) |
| 318 | } |
| 319 | } |
| 320 | for _, s := range []string{"", "A", "AAAAAAAAAAAAAAAAAAAAAA\n", "AAAAAAAAAAAAAAAAAAAAAB"} { |
| 321 | if ValidNonce(s) { |
| 322 | t.Fatal("invalid nonce accepted") |
| 323 | } |
| 324 | } |
| 325 | } |
| 326 | |