oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1package handoff
2
3import (
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
21type testSealer struct{ key []byte }
22
23func (s testSealer) Seal(p string, b []byte) ([]byte, error) {
24 return crypto.SealSym(crypto.Derive(s.key, p), b)
25}
26func (s testSealer) Open(p string, b []byte) ([]byte, error) {
27 return crypto.OpenSym(crypto.Derive(s.key, p), b)
28}
29
30type fixture struct {
31 s *Store
32 k *crypto.Keypair
33 now time.Time
34 dbpath string
35 db *sql.DB
36}
37
38func 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}
66func (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}
78func (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}
86func (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}
95func 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}
103func 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
111func 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}
130func 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}
143func 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}
188func 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}
206func 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}
247func 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}
259func 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}
275func 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}
309func 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