oficina / home Public

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

Plain git — no account needed to clone.

Download

Download this file

1package home
2
3import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "encoding/base32"
8 "encoding/base64"
9 "errors"
10 "io"
11 "net/http"
12 "net/http/httptest"
13 "net/url"
14 "os"
15 "strconv"
16 "strings"
17 "sync"
18 "testing"
19 "time"
20
21 "amadan.net/oficina/home/internal/home/sealtest"
22 "amadan.net/oficina/home/roster"
23 "amadan.net/rastrillo/rastrillo"
24 family "amadan.net/rastrillo/rastrillo/crypto"
25 "github.com/go-json-experiment/json"
26)
27
28type rosterTransport func(*http.Request) (*http.Response, error)
29
30func (f rosterTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
31
32type rosterMemory struct {
33 mu sync.Mutex
34 b []byte
35 saves int
36}
37
38func (m *rosterMemory) Load(context.Context) ([]byte, error) {
39 m.mu.Lock()
40 defer m.mu.Unlock()
41 if m.b == nil {
42 return nil, roster.ErrNoEvidence
43 }
44 return append([]byte{}, m.b...), nil
45}
46func (m *rosterMemory) Save(_ context.Context, b []byte) error {
47 m.mu.Lock()
48 defer m.mu.Unlock()
49 m.b = append([]byte{}, b...)
50 m.saves++
51 return nil
52}
53
54type rosterClock struct {
55 mu sync.Mutex
56 at time.Time
57 elapsed time.Duration
58}
59
60func (c *rosterClock) Now() (time.Time, time.Duration) {
61 c.mu.Lock()
62 defer c.mu.Unlock()
63 return c.at, c.elapsed
64}
65func (c *rosterClock) advance(d time.Duration) {
66 c.mu.Lock()
67 defer c.mu.Unlock()
68 c.at = c.at.Add(d)
69 c.elapsed += d
70}
71
72type rosterIntegration struct {
73 f chooserFixture
74 key *family.Keypair
75 team int64
76 token, generation string
77 server *httptest.Server
78 transport http.RoundTripper
79}
80
81func rosterAtom(b byte) string {
82 return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{b}, 32))
83}
84func newRosterIntegration(t *testing.T, n int) *rosterIntegration {
85 t.Helper()
86 f := newChooserFixture(t)
87 key, _ := rosterPrivate(t)
88 cfg := *f.p.directory.runtime
89 cfg.roster = rosterRuntime{mode: "serve", signer: key, keyID: rosterKeyID(key)}
90 app, e := NewRuntime(cfg, f.p.directory.db, f.p.directory.logger)
91 if e != nil {
92 t.Fatal(e)
93 }
94 f.p.directory = app
95 id := f.team(t, "A1", "alpha", string(sealtest.Canaries[1]), chooserPerson)
96 tx, e := app.db.Writer().Begin()
97 if e != nil {
98 t.Fatal(e)
99 }
100 defer tx.Rollback()
101 if _, e = tx.Exec(`DELETE FROM memberships WHERE team_id=?`, id); e != nil {
102 t.Fatal(e)
103 }
104 for i := 0; i < n; i++ {
105 raw := make([]byte, 16)
106 raw[0] = byte(i >> 8)
107 raw[1] = byte(i)
108 ref := "pr_" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw))
109 if _, e = tx.Exec(`INSERT INTO memberships VALUES(?,?,'member')`, id, ref); e != nil {
110 t.Fatal(e)
111 }
112 }
113 token, generation := rosterAtom(11), rosterAtom(12)
114 hash := sha256.Sum256([]byte(token))
115 for _, q := range []struct {
116 sql string
117 args []any
118 }{
119 {`INSERT INTO roster_generation VALUES(1,?)`, []any{generation}},
120 {`INSERT INTO roster_consumers VALUES('alpha-docs',?,'docs','https://alpha.docs.oficina.works',1)`, []any{id}},
121 {`INSERT INTO roster_tokens VALUES(?,'alpha-docs','current',?,NULL)`, []any{hash[:], time.Now().Unix() - 1}},
122 } {
123 if _, e = tx.Exec(q.sql, q.args...); e != nil {
124 t.Fatal(e)
125 }
126 }
127 if e = tx.Commit(); e != nil {
128 t.Fatal(e)
129 }
130 mux := http.NewServeMux()
131 mux.Handle("/", app.Router())
132 handler, closeHandler, e := rastrillo.Handler(rastrillo.Options{Mux: mux, CSP: app.SecurityPolicy(), Logger: f.p.directory.logger})
133 if e != nil {
134 t.Fatal(e)
135 }
136 t.Cleanup(func() { closeHandler() })
137 server := httptest.NewTLSServer(handler)
138 t.Cleanup(server.Close)
139 target, _ := url.Parse(server.URL)
140 transport := rosterTransport(func(r *http.Request) (*http.Response, error) {
141 if r.URL.Scheme != "https" || r.URL.Host != strings.TrimPrefix(directoryOrigin, "https://") {
142 return nil, errors.New("unexpected test destination")
143 }
144 c := r.Clone(r.Context())
145 u := *r.URL
146 c.URL = &u
147 c.URL.Host = target.Host
148 c.Host = r.URL.Host
149 return server.Client().Transport.RoundTrip(c)
150 })
151 return &rosterIntegration{f: f, key: key, team: id, token: token, generation: generation, server: server, transport: transport}
152}
153func (f *rosterIntegration) record(t *testing.T, team, consumer, audience, kid string) roster.VerifiedRecord {
154 t.Helper()
155 op, _ := rosterPrivate(t)
156 binding := roster.Bindings{Issuer: Origin, Directory: directoryOrigin, Consumer: consumer, App: "docs", Team: team, Audience: audience}
157 record := roster.Provisioning{Version: 1, Operation: "test-operation", Sequence: 1, Bindings: binding, Generation: f.generation, MinEpoch: 1, Keys: []roster.Key{{ID: kid, Public: base64.RawURLEncoding.EncodeToString(f.key.SignPub())}}}
158 wire, e := roster.SignProvisioning(op, "test-operator", record)
159 if e != nil {
160 t.Fatal(e)
161 }
162 verified, e := roster.VerifyProvisioning(wire, []roster.Key{{ID: "test-operator", Public: base64.RawURLEncoding.EncodeToString(op.SignPub())}}, binding)
163 if e != nil {
164 t.Fatal(e)
165 }
166 return verified
167}
168func (f *rosterIntegration) cache(t *testing.T, transport http.RoundTripper, kid string) (*roster.Cache, *rosterMemory, *rosterClock) {
169 t.Helper()
170 memory := &rosterMemory{}
171 clock := &rosterClock{at: time.Now()}
172 c, e := roster.New(context.Background(), roster.Config{Record: f.record(t, "A1", "alpha-docs", "https://alpha.docs.oficina.works", kid), Token: f.token, Transport: transport, Durable: memory, Sealer: f.f.p.directory.secrets.Sealer, Clock: clock})
173 if e != nil {
174 t.Fatal(e)
175 }
176 return c, memory, clock
177}
178func (f *rosterIntegration) get(t *testing.T, query, token string) (int, http.Header, []byte) {
179 t.Helper()
180 r, e := http.NewRequest("GET", directoryOrigin+"/roster?"+query, nil)
181 if e != nil {
182 t.Fatal(e)
183 }
184 r.Header.Set("Authorization", "Bearer "+token)
185 res, e := f.transport.RoundTrip(r)
186 if e != nil {
187 t.Fatal(e)
188 }
189 defer res.Body.Close()
190 b, e := io.ReadAll(io.LimitReader(res.Body, roster.BodyLimit+1))
191 if e != nil {
192 t.Fatal(e)
193 }
194 if res.Header.Get("Cache-Control") != "no-store, no-transform" || res.Header.Get("Content-Type") != "application/json" {
195 t.Fatal(res.Header)
196 }
197 for _, k := range []string{"Content-Encoding", "Location", "Set-Cookie", "ETag", "Last-Modified"} {
198 if res.Header.Get(k) != "" {
199 t.Fatal("framework changed roster", k)
200 }
201 }
202 return res.StatusCode, res.Header, b
203}
204func decodeRosterPage(t *testing.T, b []byte) roster.Page {
205 t.Helper()
206 var env struct {
207 Payload string `json:"payload"`
208 }
209 if json.Unmarshal(b, &env) != nil {
210 t.Fatal("missing envelope")
211 }
212 plain, e := base64.RawURLEncoding.DecodeString(env.Payload)
213 if e != nil {
214 t.Fatal(e)
215 }
216 var p roster.Page
217 if json.Unmarshal(plain, &p) != nil {
218 t.Fatal("missing page")
219 }
220 return p
221}
222func TestRosterFrameworkAndActualClientZeroOneMany(t *testing.T) {
223 for _, n := range []int{0, 1, 201} {
224 t.Run(strconv.Itoa(n), func(t *testing.T) {
225 f := newRosterIntegration(t, n)
226 cache, _, _ := f.cache(t, f.transport, rosterKeyID(f.key))
227 d := cache.Check(context.Background(), Origin, "A1", chooserPerson)
228 want := roster.Member
229 if n == 0 {
230 want = roster.NotMember
231 }
232 if d.State != want {
233 t.Fatal("actual signed client", n, d.State)
234 }
235 if n > 0 && !d.Lease.Valid() {
236 t.Fatal("missing lease")
237 }
238 status, _, wire := f.get(t, "team=A1&fetch="+rosterTestChallenge, f.token)
239 if status != 200 {
240 t.Fatal(status)
241 }
242 p := decodeRosterPage(t, wire)
243 if p.Members == nil || p.Complete == nil || p.Index == nil || p.RequestCursorHash != roster.CursorHash("") {
244 t.Fatal("page shape")
245 }
246 if n == 0 && (len(*p.Members) != 0 || !*p.Complete || p.Cursor != nil) {
247 t.Fatal("empty roster encoding")
248 }
249 sealtest.AssertNoPlaintext(t, f.f.p.directoryPath)
250 corpus := [][]byte{[]byte(f.token), []byte(string(sealtest.Canaries[1]))}
251 if p.Cursor != nil {
252 sealed, e := base64.RawURLEncoding.DecodeString(*p.Cursor)
253 if e != nil {
254 t.Fatal(e)
255 }
256 plain, e := f.f.p.directory.secrets.Sealer.Open("directory.roster-cursor/"+directoryOrigin, sealed)
257 if e != nil {
258 t.Fatal(e)
259 }
260 corpus = append(corpus, []byte(*p.Cursor), plain)
261 }
262 for _, suffix := range []string{"", "-wal", "-shm"} {
263 b, e := os.ReadFile(f.f.p.directoryPath + suffix)
264 if os.IsNotExist(e) {
265 continue
266 }
267 if e != nil {
268 t.Fatal(e)
269 }
270 for _, secret := range corpus {
271 if bytes.Contains(b, secret) {
272 t.Fatal("request secret at rest")
273 }
274 }
275 }
276 for _, secret := range append(corpus, []byte(chooserPerson), []byte("/roster?")) {
277 if bytes.Contains(f.f.p.logs.Bytes(), secret) {
278 t.Fatal("roster request logged")
279 }
280 }
281 })
282 }
283}
284func TestRosterHTTPConflictDoesNotPublishAndUnknownKidRefuses(t *testing.T) {
285 for _, generation := range []bool{false, true} {
286 t.Run(map[bool]string{false: "epoch", true: "generation"}[generation], func(t *testing.T) {
287 f := newRosterIntegration(t, 201)
288 changed := false
289 statuses := []int{}
290 transport := rosterTransport(func(r *http.Request) (*http.Response, error) {
291 if r.URL.Query().Get("cursor") != "" && !changed {
292 changed = true
293 var e error
294 if generation {
295 _, e = f.f.p.directory.db.Writer().Exec(`UPDATE roster_generation SET generation=?`, rosterAtom(13))
296 } else {
297 _, e = f.f.p.directory.db.Writer().Exec(`DELETE FROM memberships WHERE team_id=?`, f.team)
298 }
299 if e != nil {
300 return nil, e
301 }
302 }
303 res, e := f.transport.RoundTrip(r)
304 if e == nil {
305 statuses = append(statuses, res.StatusCode)
306 }
307 return res, e
308 })
309 cache, memory, _ := f.cache(t, transport, rosterKeyID(f.key))
310 d := cache.Check(context.Background(), Origin, "A1", chooserPerson)
311 if d.State != roster.Unknown || !changed || len(statuses) != 2 || statuses[0] != 200 || statuses[1] != 409 {
312 t.Fatal(d.State, statuses)
313 }
314 memory.mu.Lock()
315 evidence := append([]byte{}, memory.b...)
316 memory.mu.Unlock()
317 if len(evidence) > 0 {
318 bindings := roster.Bindings{Issuer: Origin, Directory: directoryOrigin, Consumer: "alpha-docs", App: "docs", Team: "A1", Audience: "https://alpha.docs.oficina.works"}
319 raw, e := json.Marshal(bindings)
320 if e != nil {
321 t.Fatal(e)
322 }
323 digest := sha256.Sum256(raw)
324 plain, e := f.f.p.directory.secrets.Sealer.Open("oficina/roster/evidence/v1/"+base64.RawURLEncoding.EncodeToString(digest[:]), evidence)
325 if e != nil {
326 t.Fatal(e)
327 }
328 var state struct {
329 Snapshot bool `json:"snapshot"`
330 Blocked bool `json:"blocked"`
331 Members []roster.MemberEntry `json:"members"`
332 }
333 if json.Unmarshal(plain, &state) != nil || state.Snapshot || len(state.Members) != 0 || state.Blocked != generation {
334 t.Fatal("partial roster persisted as complete")
335 }
336 }
337 if d.Lease.Valid() {
338 t.Fatal("conflict granted lease")
339 }
340 })
341 }
342 f := newRosterIntegration(t, 1)
343 cache, _, _ := f.cache(t, f.transport, "unrecognized-key")
344 if d := cache.Check(context.Background(), Origin, "A1", chooserPerson); d.State != roster.Unknown {
345 t.Fatal("unknown key accepted", d.State)
346 }
347}
348func TestRosterDisableExpiresStandingAndMissingGeneration(t *testing.T) {
349 f := newRosterIntegration(t, 1)
350 cache, _, clock := f.cache(t, f.transport, rosterKeyID(f.key))
351 d := cache.Check(context.Background(), Origin, "A1", chooserPerson)
352 if d.State != roster.Member {
353 t.Fatal(d.State)
354 }
355 if _, e := f.f.p.directory.db.Writer().Exec(`UPDATE roster_consumers SET enabled=0`); e != nil {
356 t.Fatal(e)
357 }
358 clock.advance(60 * time.Second)
359 if next := cache.Check(context.Background(), Origin, "A1", chooserPerson); next.State != roster.Unknown || d.Lease.Valid() {
360 t.Fatal("disabled token extended standing", next.State)
361 }
362 if _, e := f.f.p.directory.db.Writer().Exec(`UPDATE roster_consumers SET enabled=1`); e != nil {
363 t.Fatal(e)
364 }
365 if _, e := f.f.p.directory.db.Writer().Exec(`DELETE FROM roster_generation`); e != nil {
366 t.Fatal(e)
367 }
368 status, _, b := f.get(t, "team=A1&fetch="+rosterTestChallenge, f.token)
369 if status != 503 || string(b) != `{"error":"unavailable"}` {
370 t.Fatal("missing generation", status)
371 }
372 var count int
373 if e := f.f.p.directory.db.Writer().QueryRow(`SELECT count(*) FROM roster_generation`).Scan(&count); e != nil || count != 0 {
374 t.Fatal("generation minted", count, e)
375 }
376}
377func TestRosterTwoConsumersCannotCrossTeams(t *testing.T) {
378 f := newRosterIntegration(t, 1)
379 id := f.f.team(t, "B2", "beta", "Other team", chooserOther)
380 token := rosterAtom(14)
381 hash := sha256.Sum256([]byte(token))
382 tx, e := f.f.p.directory.db.Writer().Begin()
383 if e != nil {
384 t.Fatal(e)
385 }
386 defer tx.Rollback()
387 for _, q := range []struct {
388 sql string
389 args []any
390 }{{`INSERT INTO roster_consumers VALUES('beta-docs',?,'docs','https://beta.docs.oficina.works',1)`, []any{id}}, {`INSERT INTO roster_tokens VALUES(?,'beta-docs','current',?,NULL)`, []any{hash[:], time.Now().Unix() - 1}}} {
391 if _, e = tx.Exec(q.sql, q.args...); e != nil {
392 t.Fatal(e)
393 }
394 }
395 if e = tx.Commit(); e != nil {
396 t.Fatal(e)
397 }
398 for _, pair := range []struct{ token, team string }{{f.token, "B2"}, {token, "A1"}, {f.token, "Missing"}, {token, "Missing"}} {
399 status, _, b := f.get(t, "team="+pair.team+"&fetch="+rosterTestChallenge, pair.token)
400 if status != 404 || string(b) != `{"error":"not_found"}` {
401 t.Fatal(status, string(b))
402 }
403 }
404 status, _, b := f.get(t, "team=B2&fetch="+rosterTestChallenge, token)
405 p := decodeRosterPage(t, b)
406 if status != 200 || p.Team != "B2" || p.Consumer != "beta-docs" || p.Audience != "https://beta.docs.oficina.works" || len(*p.Members) != 1 || (*p.Members)[0].Subject != chooserOther {
407 t.Fatal("foreign roster")
408 }
409}
410
411func TestRosterRealClientRefusesSignedWrongScopeAndContext(t *testing.T) {
412 for _, kind := range []string{"scope", "context", "signature"} {
413 t.Run(kind, func(t *testing.T) {
414 f := newRosterIntegration(t, 1)
415 transport := rosterTransport(func(r *http.Request) (*http.Response, error) {
416 res, e := f.transport.RoundTrip(r)
417 if e != nil {
418 return nil, e
419 }
420 raw, e := io.ReadAll(res.Body)
421 res.Body.Close()
422 if e != nil {
423 return nil, e
424 }
425 var env struct {
426 Version int `json:"v"`
427 KeyID string `json:"kid"`
428 Payload string `json:"payload"`
429 Signature string `json:"signature"`
430 }
431 if e = json.Unmarshal(raw, &env); e != nil {
432 return nil, e
433 }
434 payload, e := base64.RawURLEncoding.DecodeString(env.Payload)
435 if e != nil {
436 return nil, e
437 }
438 switch kind {
439 case "scope":
440 var p roster.Page
441 if e = json.Unmarshal(payload, &p); e != nil {
442 return nil, e
443 }
444 p.Team = "B2"
445 raw, e = roster.SignPage(f.key, rosterKeyID(f.key), p)
446 case "context":
447 var sig []byte
448 sig, e = family.Sign(f.key, roster.ProvisioningContext, payload)
449 if e == nil {
450 env.Signature = base64.RawURLEncoding.EncodeToString(sig)
451 raw, e = json.Marshal(env)
452 }
453 case "signature":
454 var sig []byte
455 sig, e = base64.RawURLEncoding.DecodeString(env.Signature)
456 if e == nil {
457 sig[0] ^= 1
458 env.Signature = base64.RawURLEncoding.EncodeToString(sig)
459 raw, e = json.Marshal(env)
460 }
461 }
462 if e != nil {
463 return nil, e
464 }
465 res.Body = io.NopCloser(bytes.NewReader(raw))
466 res.ContentLength = int64(len(raw))
467 return res, nil
468 })
469 cache, _, _ := f.cache(t, transport, rosterKeyID(f.key))
470 if d := cache.Check(context.Background(), Origin, "A1", chooserPerson); d.State != roster.Unknown || d.Lease.Valid() {
471 t.Fatal("invalid signed page granted standing", kind)
472 }
473 })
474 }
475}
476func TestRosterFrameworkMethodAndPathRefusalsNeverYieldPage(t *testing.T) {
477 f := newRosterIntegration(t, 1)
478 for _, method := range []string{"HEAD", "POST", "OPTIONS", "DELETE"} {
479 r, e := http.NewRequest(method, directoryOrigin+"/roster?team=A1&fetch="+rosterTestChallenge, nil)
480 if e != nil {
481 t.Fatal(e)
482 }
483 r.Header.Set("Authorization", "Bearer "+f.token)
484 res, e := f.transport.RoundTrip(r)
485 if e != nil {
486 t.Fatal(e)
487 }
488 res.Body.Close()
489 if res.StatusCode != 405 || res.Header.Get("Cache-Control") != "no-store, no-transform" || res.Header.Get("Content-Type") != "application/json" || res.Header.Get("Allow") != "GET" || res.Header.Get("Location") != "" {
490 t.Fatal(method, res.StatusCode, res.Header)
491 }
492 }
493 r, e := http.NewRequest("GET", directoryOrigin+"//roster?team=A1&fetch="+rosterTestChallenge, nil)
494 if e != nil {
495 t.Fatal(e)
496 }
497 r.Header.Set("Authorization", "Bearer "+f.token)
498 res, e := f.transport.RoundTrip(r)
499 if e != nil {
500 t.Fatal(e)
501 }
502 res.Body.Close()
503 if res.StatusCode == 200 {
504 t.Fatal("alternate path yielded roster")
505 }
506 calls := 0
507 redirect := rosterTransport(func(r *http.Request) (*http.Response, error) {
508 calls++
509 return &http.Response{StatusCode: 301, Header: http.Header{"Location": {directoryOrigin + "/roster"}, "Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(`{}`)), Request: r}, nil
510 })
511 cache, _, _ := f.cache(t, redirect, rosterKeyID(f.key))
512 if d := cache.Check(context.Background(), Origin, "A1", chooserPerson); d.State != roster.Unknown || calls != 1 {
513 t.Fatal("redirect followed", calls)
514 }
515}
516
517func TestRosterHTTPTokenRotationOverlapBoundary(t *testing.T) {
518 f := newRosterIntegration(t, 1)
519 rotated := rosterAtom(15)
520 hash := sha256.Sum256([]byte(rotated))
521 at := time.Now()
522 tx, e := f.f.p.directory.db.Writer().Begin()
523 if e != nil {
524 t.Fatal(e)
525 }
526 defer tx.Rollback()
527 if _, e = tx.Exec(`UPDATE roster_tokens SET slot='previous',issued_at=?,expires_at=? WHERE consumer_id='alpha-docs'`, at.Unix(), at.Unix()+300); e != nil {
528 t.Fatal(e)
529 }
530 if _, e = tx.Exec(`INSERT INTO roster_tokens VALUES(?,'alpha-docs','current',?,NULL)`, hash[:], at.Unix()); e != nil {
531 t.Fatal(e)
532 }
533 if e = tx.Commit(); e != nil {
534 t.Fatal(e)
535 }
536 clock := &rosterClock{at: at}
537 f.f.p.directory.directory.roster.now = func() time.Time { now, _ := clock.Now(); return now }
538 for _, token := range []string{f.token, rotated} {
539 status, _, _ := f.get(t, "team=A1&fetch="+rosterTestChallenge, token)
540 if status != 200 {
541 t.Fatal("rotation overlap refused", status)
542 }
543 }
544 clock.advance(300 * time.Second)
545 status, _, _ := f.get(t, "team=A1&fetch="+rosterTestChallenge, f.token)
546 if status != 401 {
547 t.Fatal("expired previous token accepted", status)
548 }
549 status, _, _ = f.get(t, "team=A1&fetch="+rosterTestChallenge, rotated)
550 if status != 200 {
551 t.Fatal("current token refused", status)
552 }
553}
554