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 "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
16func (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
33func 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
89func 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
135func 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
174func 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
220func 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
261func 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