Mount disabled-by-default signed directory roster endpoint
11 files changed,
+1169
−8
go.mod+1 −1go.sum+2 −0internal/home/directory.go+8 −2internal/home/directory_runtime.go+15 −3internal/home/roster_http.go+162 −0internal/home/roster_http_test.go+174 −0internal/home/roster_integration_test.go+553 −0internal/home/roster_runtime.go+105 −0internal/home/roster_runtime_test.go+128 −0internal/home/runtime.go+15 −1internal/home/runtime_test.go+6 −1
CI passed — run details
diff --git a/go.mod b/go.mod| index 96144de..7747d06 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -5,7 +5,7 @@ go 1.25.0 |
| require ( |
| amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335 |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| - amadan.net/oficina/home/roster v0.0.0-20260908115900-667dc8218dd5 |
| + amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd |
| github.com/carlosframework/rastrillo v0.23.0 |
| github.com/go-chi/chi/v5 v5.3.2 |
diff --git a/go.sum b/go.sum| index d96b208..0242734 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -4,6 +4,8 @@ amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71 |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k= |
| amadan.net/oficina/home/roster v0.0.0-20260908115900-667dc8218dd5 h1:MQElRkuUPK46BWA3V3CVgJC9robI8X7LR0N67Jb3EZI= |
| amadan.net/oficina/home/roster v0.0.0-20260908115900-667dc8218dd5/go.mod h1:nhhPCCPaiXfZfQheJEOzKL8beyHkJjrag8vfmu/DcWU= |
| +amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf h1:SRKjCUgtk42WwR7VSn+V0eLNk20mfiY8dQmzdw13Sbo= |
| +amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf/go.mod h1:gqshuXyDtFZUCYbhuZyV3Epc51pUcsLbpqAqiA5UiXg= |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc= |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w= |
diff --git a/internal/home/directory.go b/internal/home/directory.go| index 2e3c8dc..fe25f09 100644 |
| --- a/internal/home/directory.go |
| +++ b/internal/home/directory.go |
| @@ -17,7 +17,7 @@ import ( |
| ) |
| func newDirectory(cfg Runtime, d *db.DB, logger *slog.Logger) (*App, error) { |
| - if cfg.role != "directory" || cfg.origins.validate() != nil || cfg.secrets.Sealer == nil || d == nil || cfg.signer != nil { |
| + if cfg.role != "directory" || cfg.origins.validate() != nil || cfg.secrets.Sealer == nil || d == nil || cfg.signer != nil || !cfg.roster.valid(cfg.role, cfg.keys) { |
| return nil, errExchangeConfig |
| } |
| if logger == nil { |
| @@ -93,5 +93,11 @@ func (a *App) directoryRouter() http.Handler { |
| r.Get("/static/tokens.css", serveAsset(staticAsset("tokens.css"), "text/css; charset=utf-8")) |
| r.Get("/static/theme.css", serveAsset(theme.CSS(), "text/css; charset=utf-8")) |
| }) |
| - return r |
| + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { |
| + if req.URL.Path == rosterPath { |
| + a.directory.roster.ServeHTTP(w, req) |
| + return |
| + } |
| + r.ServeHTTP(w, req) |
| + }) |
| } |
diff --git a/internal/home/directory_runtime.go b/internal/home/directory_runtime.go| index 3d15c65..a829720 100644 |
| --- a/internal/home/directory_runtime.go |
| +++ b/internal/home/directory_runtime.go |
| @@ -27,8 +27,9 @@ func directoryRouteAllowed(route directorydata.Route) bool { |
| } |
| type directoryFeatures struct { |
| - store *directorydata.Store |
| - docs *docsChooser |
| + roster *rosterHandler |
| + store *directorydata.Store |
| + docs *docsChooser |
| } |
| func (a *App) newDirectoryFeatures() (*directoryFeatures, error) { |
| @@ -46,7 +47,18 @@ func (a *App) newDirectoryFeatures() (*directoryFeatures, error) { |
| if err != nil { |
| return nil, errExchangeConfig |
| } |
| - return &directoryFeatures{store: store, docs: chooser}, nil |
| + var source rosterReadSource |
| + if a.runtime.roster.mode == "serve" { |
| + source, err = directorydata.NewRosterSource(a.db.Writer(), a.secrets.Sealer, directorydata.Config{Apps: apps, ValidateRoute: directoryRouteAllowed}, a.origins.Directory) |
| + if err != nil { |
| + return nil, errExchangeConfig |
| + } |
| + } |
| + handler, err := newRosterHandler(*a.runtime, source) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &directoryFeatures{store: store, docs: chooser, roster: handler}, nil |
| } |
| func chooserPath(r *http.Request) bool { |
diff --git a/internal/home/roster_http.go b/internal/home/roster_http.go| new file mode 100644 |
| index 0000000..4c9459c |
| --- /dev/null |
| +++ b/internal/home/roster_http.go |
| @@ -0,0 +1,162 @@ |
| +package home |
| + |
| +import ( |
| + "context" |
| + "encoding/base64" |
| + "net/http" |
| + "strings" |
| + "time" |
| + |
| + "amadan.net/oficina/home/internal/home/directorydata" |
| + "amadan.net/oficina/home/roster" |
| +) |
| + |
| +const rosterPath = "/roster" |
| + |
| +type rosterReadSource interface { |
| + Read(context.Context, directorydata.RosterRequest, time.Time) (roster.Page, int, error) |
| +} |
| +type rosterHandler struct { |
| + runtime rosterRuntime |
| + directory string |
| + source rosterReadSource |
| + now func() time.Time |
| +} |
| + |
| +func newRosterHandler(cfg Runtime, source rosterReadSource) (*rosterHandler, error) { |
| + if cfg.role != "directory" || !cfg.roster.valid(cfg.role, cfg.keys) || !directorydata.ValidOrigin(cfg.origins.Directory) || (cfg.roster.mode == "serve") != (source != nil) { |
| + return nil, errExchangeConfig |
| + } |
| + return &rosterHandler{runtime: cfg.roster, directory: cfg.origins.Directory, source: source, now: time.Now}, nil |
| +} |
| +func rosterFailure(w http.ResponseWriter, status int) { |
| + code := "unavailable" |
| + switch status { |
| + case 400: |
| + code = "bad_request" |
| + case 401: |
| + code = "unauthorized" |
| + case 404: |
| + code = "not_found" |
| + case 405: |
| + code = "method_not_allowed" |
| + default: |
| + status = 503 |
| + } |
| + w.WriteHeader(status) |
| + _, _ = w.Write([]byte(`{"error":"` + code + `"}`)) |
| +} |
| +func rosterQuery(r *http.Request, directory string) (directorydata.RosterRequest, bool) { |
| + var req directorydata.RosterRequest |
| + if r.URL.EscapedPath() != rosterPath || r.URL.IsAbs() || r.URL.Fragment != "" || r.ContentLength != 0 || len(r.TransferEncoding) != 0 || len(r.RequestURI) > 4096 || len(directory)+len(rosterPath)+1+len(r.URL.RawQuery) > 4096 { |
| + return req, false |
| + } |
| + fields := strings.Split(r.URL.RawQuery, "&") |
| + if len(fields) != 2 && len(fields) != 3 { |
| + return req, false |
| + } |
| + seen := map[string]bool{} |
| + for _, field := range fields { |
| + name, value, ok := strings.Cut(field, "=") |
| + if !ok || value == "" || seen[name] { |
| + return req, false |
| + } |
| + seen[name] = true |
| + switch name { |
| + case "team": |
| + if len(value) > 64 { |
| + return req, false |
| + } |
| + for _, c := range value { |
| + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9') { |
| + return req, false |
| + } |
| + } |
| + req.Team = value |
| + case "fetch": |
| + if !roster.ValidChallenge(value) { |
| + return req, false |
| + } |
| + req.Fetch = value |
| + case "cursor": |
| + if len(value) > roster.CursorLimit { |
| + return req, false |
| + } |
| + b, e := base64.RawURLEncoding.Strict().DecodeString(value) |
| + if e != nil || base64.RawURLEncoding.EncodeToString(b) != value { |
| + return req, false |
| + } |
| + req.Cursor = value |
| + default: |
| + return req, false |
| + } |
| + } |
| + return req, seen["team"] && seen["fetch"] |
| +} |
| +func rosterBearer(headers http.Header) (string, bool) { |
| + var values []string |
| + for name, v := range headers { |
| + if strings.EqualFold(name, "Authorization") { |
| + values = append(values, v...) |
| + } |
| + } |
| + if len(values) != 1 || !strings.HasPrefix(values[0], "Bearer ") { |
| + return "", false |
| + } |
| + token := strings.TrimPrefix(values[0], "Bearer ") |
| + return token, roster.ValidChallenge(token) |
| +} |
| +func (h *rosterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.Header().Set("Content-Type", "application/json") |
| + w.Header().Set("X-Content-Type-Options", "nosniff") |
| + if r.Host != strings.TrimPrefix(h.directory, "https://") { |
| + rosterFailure(w, 404) |
| + return |
| + } |
| + if r.Method != http.MethodGet { |
| + w.Header().Set("Allow", "GET") |
| + rosterFailure(w, 405) |
| + return |
| + } |
| + if h.runtime.mode != "serve" { |
| + rosterFailure(w, 503) |
| + return |
| + } |
| + req, ok := rosterQuery(r, h.directory) |
| + if !ok { |
| + rosterFailure(w, 400) |
| + return |
| + } |
| + req.Token, ok = rosterBearer(r.Header) |
| + if !ok { |
| + rosterFailure(w, 401) |
| + return |
| + } |
| + now := h.now() |
| + if now.Unix() <= 0 { |
| + rosterFailure(w, 503) |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) |
| + defer cancel() |
| + page, status, err := h.source.Read(ctx, req, now) |
| + if err != nil { |
| + if !(status == 400 && err == directorydata.ErrRosterBadRequest || status == 401 && err == directorydata.ErrRosterUnauthorized || status == 404 && err == directorydata.ErrRosterNotFound || status == 503 && err == directorydata.ErrUnavailable) { |
| + status = 503 |
| + } |
| + rosterFailure(w, status) |
| + return |
| + } |
| + if !(status == 200 && page.Type == "page" || status == 409 && page.Type == "stale-cursor") { |
| + rosterFailure(w, 503) |
| + return |
| + } |
| + wire, err := roster.SignPage(h.runtime.signer, h.runtime.keyID, page) |
| + if err != nil || len(wire) > roster.BodyLimit { |
| + rosterFailure(w, 503) |
| + return |
| + } |
| + w.WriteHeader(status) |
| + _, _ = w.Write(wire) |
| +} |
diff --git a/internal/home/roster_http_test.go b/internal/home/roster_http_test.go| new file mode 100644 |
| index 0000000..223db70 |
| --- /dev/null |
| +++ b/internal/home/roster_http_test.go |
| @@ -0,0 +1,174 @@ |
| +package home |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/home/internal/home/directorydata" |
| + "amadan.net/oficina/home/roster" |
| +) |
| + |
| +type rosterSpy struct { |
| + calls int |
| + req directorydata.RosterRequest |
| + page roster.Page |
| + status int |
| + err error |
| +} |
| + |
| +func (s *rosterSpy) Read(ctx context.Context, r directorydata.RosterRequest, now time.Time) (roster.Page, int, error) { |
| + s.calls++ |
| + s.req = r |
| + if d, ok := ctx.Deadline(); !ok || time.Until(d) > 5*time.Second { |
| + panic("missing bounded context") |
| + } |
| + return s.page, s.status, s.err |
| +} |
| +func rosterHandlerFixture(t *testing.T) (*rosterHandler, *rosterSpy) { |
| + t.Helper() |
| + p := newExchangePair(t) |
| + cfg := *p.directory.runtime |
| + k, _ := rosterPrivate(t) |
| + cfg.roster = rosterRuntime{mode: "serve", signer: k, keyID: rosterKeyID(k)} |
| + s := &rosterSpy{status: 503, err: directorydata.ErrUnavailable} |
| + h, e := newRosterHandler(cfg, s) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return h, s |
| +} |
| + |
| +const rosterTestChallenge = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" |
| + |
| +func rosterRequest() *http.Request { |
| + r := httptest.NewRequest("GET", "/roster?team=A1&fetch="+rosterTestChallenge, nil) |
| + r.Host = strings.TrimPrefix(directoryOrigin, "https://") |
| + r.Header.Set("Authorization", "Bearer "+rosterTestChallenge) |
| + return r |
| +} |
| +func rosterHeaders(t *testing.T, r *httptest.ResponseRecorder) { |
| + t.Helper() |
| + if r.Header().Get("Cache-Control") != "no-store" || r.Header().Get("Content-Type") != "application/json" || r.Header().Get("X-Content-Type-Options") != "nosniff" { |
| + t.Fatal(r.Header()) |
| + } |
| + for _, key := range []string{"Location", "Content-Encoding", "Set-Cookie", "ETag", "Last-Modified", "Access-Control-Allow-Origin"} { |
| + if r.Header().Get(key) != "" { |
| + t.Fatal("unexpected header", key) |
| + } |
| + } |
| +} |
| +func TestRosterHTTPGuardOrderAndNoPreauthSource(t *testing.T) { |
| + h, s := rosterHandlerFixture(t) |
| + cases := []struct { |
| + name string |
| + change func(*http.Request) |
| + want int |
| + }{ |
| + {"foreign host", func(r *http.Request) { r.Host = "evil.example" }, 404}, {"host case", func(r *http.Request) { r.Host = strings.ToUpper(r.Host) }, 404}, {"host port", func(r *http.Request) { r.Host += ":443" }, 404}, {"host trailing dot", func(r *http.Request) { r.Host += "." }, 404}, |
| + {"post", func(r *http.Request) { r.Method = "POST" }, 405}, {"head", func(r *http.Request) { r.Method = "HEAD" }, 405}, {"options", func(r *http.Request) { r.Method = "OPTIONS" }, 405}, |
| + {"missing token", func(r *http.Request) { r.Header.Del("Authorization") }, 401}, {"duplicate token", func(r *http.Request) { r.Header.Add("Authorization", r.Header.Get("Authorization")) }, 401}, {"case duplicate", func(r *http.Request) { r.Header["authorization"] = []string{r.Header.Get("Authorization")} }, 401}, {"comma", func(r *http.Request) { |
| + r.Header.Set("Authorization", r.Header.Get("Authorization")+","+r.Header.Get("Authorization")) |
| + }, 401}, {"token space", func(r *http.Request) { r.Header.Set("Authorization", r.Header.Get("Authorization")+" ") }, 401}, {"token tab", func(r *http.Request) { r.Header.Set("Authorization", "Bearer\t"+rosterTestChallenge) }, 401}, {"scheme", func(r *http.Request) { r.Header.Set("Authorization", "bearer "+rosterTestChallenge) }, 401}, {"padding", func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+rosterTestChallenge+"=") }, 401}, |
| + {"no fetch", func(r *http.Request) { r.URL.RawQuery = "team=A1" }, 400}, {"duplicate fetch", func(r *http.Request) { r.URL.RawQuery += "&fetch=" + rosterTestChallenge }, 400}, {"unknown", func(r *http.Request) { r.URL.RawQuery += "&epoch=1" }, 400}, {"email challenge", func(r *http.Request) { r.URL.RawQuery = "team=A1&fetch=secret@example.invalid" }, 400}, {"team path", func(r *http.Request) { r.URL.RawQuery = "team=../B2&fetch=" + rosterTestChallenge }, 400}, {"escaped key", func(r *http.Request) { r.URL.RawQuery = "%74eam=A1&fetch=" + rosterTestChallenge }, 400}, {"escaped value", func(r *http.Request) { r.URL.RawQuery = "team=%41&fetch=" + rosterTestChallenge }, 400}, {"extra separator", func(r *http.Request) { r.URL.RawQuery += "&" }, 400}, {"empty cursor", func(r *http.Request) { r.URL.RawQuery += "&cursor=" }, 400}, {"big cursor", func(r *http.Request) { r.URL.RawQuery += "&cursor=" + strings.Repeat("A", 2049) }, 400}, {"big target", func(r *http.Request) { r.RequestURI = strings.Repeat("A", 4097) }, 400}, {"body", func(r *http.Request) { r.ContentLength = 1 }, 400}, {"chunked", func(r *http.Request) { r.TransferEncoding = []string{"chunked"} }, 400}, {"absolute target", func(r *http.Request) { r.URL.Scheme = "https"; r.URL.Host = r.Host }, 400}, {"escaped path", func(r *http.Request) { r.URL.RawPath = "/r%6fster" }, 400}, |
| + } |
| + for _, tc := range cases { |
| + t.Run(tc.name, func(t *testing.T) { |
| + req := rosterRequest() |
| + tc.change(req) |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, req) |
| + if rec.Code != tc.want || s.calls != 0 { |
| + t.Fatal(rec.Code, s.calls) |
| + } |
| + rosterHeaders(t, rec) |
| + for _, value := range []string{rosterTestChallenge, "secret@example.invalid", "evil.example"} { |
| + if strings.Contains(rec.Body.String(), value) { |
| + t.Fatal("error input disclosure") |
| + } |
| + } |
| + if tc.want == 405 && rec.Header().Get("Allow") != "GET" { |
| + t.Fatal("missing Allow") |
| + } |
| + }) |
| + } |
| + req := rosterRequest() |
| + req.Header["Authorization"] = []string{"Bearer " + rosterTestChallenge} |
| + req.Header.Set("Cookie", "session=anything") |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, req) |
| + if rec.Code != 503 || s.calls != 1 || s.req.Team != "A1" || s.req.Fetch != rosterTestChallenge { |
| + t.Fatal(rec.Code, s.calls, s.req) |
| + } |
| +} |
| +func TestRosterHTTPDisabledIgnoresTokenQueryAndSessions(t *testing.T) { |
| + p := newExchangePair(t) |
| + h := p.directory.Router() |
| + var body []byte |
| + for _, query := range []string{"?team=A1&fetch=" + rosterTestChallenge, "?token=secret@example.invalid", "?" + strings.Repeat("x", 5000)} { |
| + req := rosterRequest() |
| + req.URL.RawQuery = strings.TrimPrefix(query, "?") |
| + req.Header.Set("Authorization", "canary-token") |
| + req.Header.Set("Cookie", p.directory.sessions.CookieName()+"=hostile; "+p.directory.sessions.CookieName()+"=duplicate") |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, req) |
| + if rec.Code != 503 { |
| + t.Fatal(rec.Code) |
| + } |
| + rosterHeaders(t, rec) |
| + if body != nil && !bytes.Equal(body, rec.Body.Bytes()) { |
| + t.Fatal("disabled details differed") |
| + } |
| + body = append([]byte{}, rec.Body.Bytes()...) |
| + } |
| + var n int |
| + if e := p.directory.db.Writer().QueryRow(`SELECT count(*) FROM sessions`).Scan(&n); e != nil || n != 0 { |
| + t.Fatal("roster touched sessions", n, e) |
| + } |
| +} |
| +func TestRosterHTTPResponseFailuresAreBufferedAndFixed(t *testing.T) { |
| + h, s := rosterHandlerFixture(t) |
| + for _, tc := range []struct { |
| + status int |
| + err error |
| + typ string |
| + }{{401, directorydata.ErrRosterUnauthorized, ""}, {404, directorydata.ErrRosterNotFound, ""}, {400, directorydata.ErrRosterBadRequest, ""}, {503, directorydata.ErrUnavailable, ""}, {200, errors.New("secret@example.invalid"), "page"}, {404, nil, "page"}, {304, nil, "page"}, {200, nil, "wrong"}} { |
| + s.status = tc.status |
| + s.err = tc.err |
| + s.page = roster.Page{Type: tc.typ} |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, rosterRequest()) |
| + want := tc.status |
| + if tc.err == nil || tc.status == 200 { |
| + want = 503 |
| + } |
| + if rec.Code != want { |
| + t.Fatal(rec.Code, want) |
| + } |
| + rosterHeaders(t, rec) |
| + if strings.Contains(rec.Body.String(), "secret@example.invalid") { |
| + t.Fatal("error leak") |
| + } |
| + } |
| + huge := strings.Repeat("x", roster.BodyLimit) |
| + s.page = roster.Page{Type: "page", Cursor: &huge} |
| + s.status = 200 |
| + s.err = nil |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, rosterRequest()) |
| + if rec.Code != 503 || rec.Body.String() != `{"error":"unavailable"}` { |
| + t.Fatal("partial signed response", rec.Code) |
| + } |
| + s.calls = 0 |
| + h.now = func() time.Time { return time.Unix(0, 0) } |
| + rec = httptest.NewRecorder() |
| + h.ServeHTTP(rec, rosterRequest()) |
| + if rec.Code != 503 || s.calls != 0 { |
| + t.Fatal("clock fault reached DB") |
| + } |
| +} |
diff --git a/internal/home/roster_integration_test.go b/internal/home/roster_integration_test.go| new file mode 100644 |
| index 0000000..2c70031 |
| --- /dev/null |
| +++ b/internal/home/roster_integration_test.go |
| @@ -0,0 +1,553 @@ |
| +package home |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "encoding/base32" |
| + "encoding/base64" |
| + "errors" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "os" |
| + "strconv" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/home/internal/home/sealtest" |
| + "amadan.net/oficina/home/roster" |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| + "github.com/carlosframework/rastrillo" |
| + "github.com/go-json-experiment/json" |
| +) |
| + |
| +type rosterTransport func(*http.Request) (*http.Response, error) |
| + |
| +func (f rosterTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } |
| + |
| +type rosterMemory struct { |
| + mu sync.Mutex |
| + b []byte |
| + saves int |
| +} |
| + |
| +func (m *rosterMemory) Load(context.Context) ([]byte, error) { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + if m.b == nil { |
| + return nil, roster.ErrNoEvidence |
| + } |
| + return append([]byte{}, m.b...), nil |
| +} |
| +func (m *rosterMemory) Save(_ context.Context, b []byte) error { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + m.b = append([]byte{}, b...) |
| + m.saves++ |
| + return nil |
| +} |
| + |
| +type rosterClock struct { |
| + mu sync.Mutex |
| + at time.Time |
| + elapsed time.Duration |
| +} |
| + |
| +func (c *rosterClock) Now() (time.Time, time.Duration) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + return c.at, c.elapsed |
| +} |
| +func (c *rosterClock) advance(d time.Duration) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + c.at = c.at.Add(d) |
| + c.elapsed += d |
| +} |
| + |
| +type rosterIntegration struct { |
| + f chooserFixture |
| + key *family.Keypair |
| + team int64 |
| + token, generation string |
| + server *httptest.Server |
| + transport http.RoundTripper |
| +} |
| + |
| +func rosterAtom(b byte) string { |
| + return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{b}, 32)) |
| +} |
| +func newRosterIntegration(t *testing.T, n int) *rosterIntegration { |
| + t.Helper() |
| + f := newChooserFixture(t) |
| + key, _ := rosterPrivate(t) |
| + cfg := *f.p.directory.runtime |
| + cfg.roster = rosterRuntime{mode: "serve", signer: key, keyID: rosterKeyID(key)} |
| + app, e := NewRuntime(cfg, f.p.directory.db, f.p.directory.logger) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + f.p.directory = app |
| + id := f.team(t, "A1", "alpha", string(sealtest.Canaries[1]), chooserPerson) |
| + tx, e := app.db.Writer().Begin() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer tx.Rollback() |
| + if _, e = tx.Exec(`DELETE FROM memberships WHERE team_id=?`, id); e != nil { |
| + t.Fatal(e) |
| + } |
| + for i := 0; i < n; i++ { |
| + raw := make([]byte, 16) |
| + raw[0] = byte(i >> 8) |
| + raw[1] = byte(i) |
| + ref := "pr_" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw)) |
| + if _, e = tx.Exec(`INSERT INTO memberships VALUES(?,?,'member')`, id, ref); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + token, generation := rosterAtom(11), rosterAtom(12) |
| + hash := sha256.Sum256([]byte(token)) |
| + for _, q := range []struct { |
| + sql string |
| + args []any |
| + }{ |
| + {`INSERT INTO roster_generation VALUES(1,?)`, []any{generation}}, |
| + {`INSERT INTO roster_consumers VALUES('alpha-docs',?,'docs','https://alpha.docs.oficina.works',1)`, []any{id}}, |
| + {`INSERT INTO roster_tokens VALUES(?,'alpha-docs','current',?,NULL)`, []any{hash[:], time.Now().Unix() - 1}}, |
| + } { |
| + if _, e = tx.Exec(q.sql, q.args...); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + if e = tx.Commit(); e != nil { |
| + t.Fatal(e) |
| + } |
| + mux := http.NewServeMux() |
| + mux.Handle("/", app.Router()) |
| + handler, closeHandler, e := rastrillo.Handler(rastrillo.Options{Mux: mux, CSP: app.SecurityPolicy(), Logger: f.p.directory.logger}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + t.Cleanup(func() { closeHandler() }) |
| + server := httptest.NewTLSServer(handler) |
| + t.Cleanup(server.Close) |
| + target, _ := url.Parse(server.URL) |
| + transport := rosterTransport(func(r *http.Request) (*http.Response, error) { |
| + if r.URL.Scheme != "https" || r.URL.Host != strings.TrimPrefix(directoryOrigin, "https://") { |
| + return nil, errors.New("unexpected test destination") |
| + } |
| + c := r.Clone(r.Context()) |
| + u := *r.URL |
| + c.URL = &u |
| + c.URL.Host = target.Host |
| + c.Host = r.URL.Host |
| + return server.Client().Transport.RoundTrip(c) |
| + }) |
| + return &rosterIntegration{f: f, key: key, team: id, token: token, generation: generation, server: server, transport: transport} |
| +} |
| +func (f *rosterIntegration) record(t *testing.T, team, consumer, audience, kid string) roster.VerifiedRecord { |
| + t.Helper() |
| + op, _ := rosterPrivate(t) |
| + binding := roster.Bindings{Issuer: Origin, Directory: directoryOrigin, Consumer: consumer, App: "docs", Team: team, Audience: audience} |
| + 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())}}} |
| + wire, e := roster.SignProvisioning(op, "test-operator", record) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + verified, e := roster.VerifyProvisioning(wire, []roster.Key{{ID: "test-operator", Public: base64.RawURLEncoding.EncodeToString(op.SignPub())}}, binding) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return verified |
| +} |
| +func (f *rosterIntegration) cache(t *testing.T, transport http.RoundTripper, kid string) (*roster.Cache, *rosterMemory, *rosterClock) { |
| + t.Helper() |
| + memory := &rosterMemory{} |
| + clock := &rosterClock{at: time.Now()} |
| + 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}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return c, memory, clock |
| +} |
| +func (f *rosterIntegration) get(t *testing.T, query, token string) (int, http.Header, []byte) { |
| + t.Helper() |
| + r, e := http.NewRequest("GET", directoryOrigin+"/roster?"+query, nil) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + r.Header.Set("Authorization", "Bearer "+token) |
| + res, e := f.transport.RoundTrip(r) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer res.Body.Close() |
| + b, e := io.ReadAll(io.LimitReader(res.Body, roster.BodyLimit+1)) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if res.Header.Get("Cache-Control") != "no-store" || res.Header.Get("Content-Type") != "application/json" { |
| + t.Fatal(res.Header) |
| + } |
| + for _, k := range []string{"Content-Encoding", "Location", "Set-Cookie", "ETag", "Last-Modified"} { |
| + if res.Header.Get(k) != "" { |
| + t.Fatal("framework changed roster", k) |
| + } |
| + } |
| + return res.StatusCode, res.Header, b |
| +} |
| +func decodeRosterPage(t *testing.T, b []byte) roster.Page { |
| + t.Helper() |
| + var env struct { |
| + Payload string `json:"payload"` |
| + } |
| + if json.Unmarshal(b, &env) != nil { |
| + t.Fatal("missing envelope") |
| + } |
| + plain, e := base64.RawURLEncoding.DecodeString(env.Payload) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + var p roster.Page |
| + if json.Unmarshal(plain, &p) != nil { |
| + t.Fatal("missing page") |
| + } |
| + return p |
| +} |
| +func TestRosterFrameworkAndActualClientZeroOneMany(t *testing.T) { |
| + for _, n := range []int{0, 1, 201} { |
| + t.Run(strconv.Itoa(n), func(t *testing.T) { |
| + f := newRosterIntegration(t, n) |
| + cache, _, _ := f.cache(t, f.transport, rosterKeyID(f.key)) |
| + d := cache.Check(context.Background(), Origin, "A1", chooserPerson) |
| + want := roster.Member |
| + if n == 0 { |
| + want = roster.NotMember |
| + } |
| + if d.State != want { |
| + t.Fatal("actual signed client", n, d.State) |
| + } |
| + if n > 0 && !d.Lease.Valid() { |
| + t.Fatal("missing lease") |
| + } |
| + status, _, wire := f.get(t, "team=A1&fetch="+rosterTestChallenge, f.token) |
| + if status != 200 { |
| + t.Fatal(status) |
| + } |
| + p := decodeRosterPage(t, wire) |
| + if p.Members == nil || p.Complete == nil || p.Index == nil || p.RequestCursorHash != roster.CursorHash("") { |
| + t.Fatal("page shape") |
| + } |
| + if n == 0 && (len(*p.Members) != 0 || !*p.Complete || p.Cursor != nil) { |
| + t.Fatal("empty roster encoding") |
| + } |
| + sealtest.AssertNoPlaintext(t, f.f.p.directoryPath) |
| + corpus := [][]byte{[]byte(f.token), []byte(string(sealtest.Canaries[1]))} |
| + if p.Cursor != nil { |
| + sealed, e := base64.RawURLEncoding.DecodeString(*p.Cursor) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + plain, e := f.f.p.directory.secrets.Sealer.Open("directory.roster-cursor/"+directoryOrigin, sealed) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + corpus = append(corpus, []byte(*p.Cursor), plain) |
| + } |
| + for _, suffix := range []string{"", "-wal", "-shm"} { |
| + b, e := os.ReadFile(f.f.p.directoryPath + suffix) |
| + if os.IsNotExist(e) { |
| + continue |
| + } |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + for _, secret := range corpus { |
| + if bytes.Contains(b, secret) { |
| + t.Fatal("request secret at rest") |
| + } |
| + } |
| + } |
| + for _, secret := range append(corpus, []byte(chooserPerson), []byte("/roster?")) { |
| + if bytes.Contains(f.f.p.logs.Bytes(), secret) { |
| + t.Fatal("roster request logged") |
| + } |
| + } |
| + }) |
| + } |
| +} |
| +func TestRosterHTTPConflictDoesNotPublishAndUnknownKidRefuses(t *testing.T) { |
| + for _, generation := range []bool{false, true} { |
| + t.Run(map[bool]string{false: "epoch", true: "generation"}[generation], func(t *testing.T) { |
| + f := newRosterIntegration(t, 201) |
| + changed := false |
| + statuses := []int{} |
| + transport := rosterTransport(func(r *http.Request) (*http.Response, error) { |
| + if r.URL.Query().Get("cursor") != "" && !changed { |
| + changed = true |
| + var e error |
| + if generation { |
| + _, e = f.f.p.directory.db.Writer().Exec(`UPDATE roster_generation SET generation=?`, rosterAtom(13)) |
| + } else { |
| + _, e = f.f.p.directory.db.Writer().Exec(`DELETE FROM memberships WHERE team_id=?`, f.team) |
| + } |
| + if e != nil { |
| + return nil, e |
| + } |
| + } |
| + res, e := f.transport.RoundTrip(r) |
| + if e == nil { |
| + statuses = append(statuses, res.StatusCode) |
| + } |
| + return res, e |
| + }) |
| + cache, memory, _ := f.cache(t, transport, rosterKeyID(f.key)) |
| + d := cache.Check(context.Background(), Origin, "A1", chooserPerson) |
| + if d.State != roster.Unknown || !changed || len(statuses) != 2 || statuses[0] != 200 || statuses[1] != 409 { |
| + t.Fatal(d.State, statuses) |
| + } |
| + memory.mu.Lock() |
| + evidence := append([]byte{}, memory.b...) |
| + memory.mu.Unlock() |
| + if len(evidence) > 0 { |
| + bindings := roster.Bindings{Issuer: Origin, Directory: directoryOrigin, Consumer: "alpha-docs", App: "docs", Team: "A1", Audience: "https://alpha.docs.oficina.works"} |
| + raw, e := json.Marshal(bindings) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + digest := sha256.Sum256(raw) |
| + plain, e := f.f.p.directory.secrets.Sealer.Open("oficina/roster/evidence/v1/"+base64.RawURLEncoding.EncodeToString(digest[:]), evidence) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + var state struct { |
| + Snapshot bool `json:"snapshot"` |
| + Blocked bool `json:"blocked"` |
| + Members []roster.MemberEntry `json:"members"` |
| + } |
| + if json.Unmarshal(plain, &state) != nil || state.Snapshot || len(state.Members) != 0 || state.Blocked != generation { |
| + t.Fatal("partial roster persisted as complete") |
| + } |
| + } |
| + if d.Lease.Valid() { |
| + t.Fatal("conflict granted lease") |
| + } |
| + }) |
| + } |
| + f := newRosterIntegration(t, 1) |
| + cache, _, _ := f.cache(t, f.transport, "unrecognized-key") |
| + if d := cache.Check(context.Background(), Origin, "A1", chooserPerson); d.State != roster.Unknown { |
| + t.Fatal("unknown key accepted", d.State) |
| + } |
| +} |
| +func TestRosterDisableExpiresStandingAndMissingGeneration(t *testing.T) { |
| + f := newRosterIntegration(t, 1) |
| + cache, _, clock := f.cache(t, f.transport, rosterKeyID(f.key)) |
| + d := cache.Check(context.Background(), Origin, "A1", chooserPerson) |
| + if d.State != roster.Member { |
| + t.Fatal(d.State) |
| + } |
| + if _, e := f.f.p.directory.db.Writer().Exec(`UPDATE roster_consumers SET enabled=0`); e != nil { |
| + t.Fatal(e) |
| + } |
| + clock.advance(60 * time.Second) |
| + if next := cache.Check(context.Background(), Origin, "A1", chooserPerson); next.State != roster.Unknown || d.Lease.Valid() { |
| + t.Fatal("disabled token extended standing", next.State) |
| + } |
| + if _, e := f.f.p.directory.db.Writer().Exec(`UPDATE roster_consumers SET enabled=1`); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e := f.f.p.directory.db.Writer().Exec(`DELETE FROM roster_generation`); e != nil { |
| + t.Fatal(e) |
| + } |
| + status, _, b := f.get(t, "team=A1&fetch="+rosterTestChallenge, f.token) |
| + if status != 503 || string(b) != `{"error":"unavailable"}` { |
| + t.Fatal("missing generation", status) |
| + } |
| + var count int |
| + if e := f.f.p.directory.db.Writer().QueryRow(`SELECT count(*) FROM roster_generation`).Scan(&count); e != nil || count != 0 { |
| + t.Fatal("generation minted", count, e) |
| + } |
| +} |
| +func TestRosterTwoConsumersCannotCrossTeams(t *testing.T) { |
| + f := newRosterIntegration(t, 1) |
| + id := f.f.team(t, "B2", "beta", "Other team", chooserOther) |
| + token := rosterAtom(14) |
| + hash := sha256.Sum256([]byte(token)) |
| + tx, e := f.f.p.directory.db.Writer().Begin() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer tx.Rollback() |
| + for _, q := range []struct { |
| + sql string |
| + args []any |
| + }{{`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}}} { |
| + if _, e = tx.Exec(q.sql, q.args...); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + if e = tx.Commit(); e != nil { |
| + t.Fatal(e) |
| + } |
| + for _, pair := range []struct{ token, team string }{{f.token, "B2"}, {token, "A1"}, {f.token, "Missing"}, {token, "Missing"}} { |
| + status, _, b := f.get(t, "team="+pair.team+"&fetch="+rosterTestChallenge, pair.token) |
| + if status != 404 || string(b) != `{"error":"not_found"}` { |
| + t.Fatal(status, string(b)) |
| + } |
| + } |
| + status, _, b := f.get(t, "team=B2&fetch="+rosterTestChallenge, token) |
| + p := decodeRosterPage(t, b) |
| + 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 { |
| + t.Fatal("foreign roster") |
| + } |
| +} |
| + |
| +func TestRosterRealClientRefusesSignedWrongScopeAndContext(t *testing.T) { |
| + for _, kind := range []string{"scope", "context", "signature"} { |
| + t.Run(kind, func(t *testing.T) { |
| + f := newRosterIntegration(t, 1) |
| + transport := rosterTransport(func(r *http.Request) (*http.Response, error) { |
| + res, e := f.transport.RoundTrip(r) |
| + if e != nil { |
| + return nil, e |
| + } |
| + raw, e := io.ReadAll(res.Body) |
| + res.Body.Close() |
| + if e != nil { |
| + return nil, e |
| + } |
| + var env struct { |
| + Version int `json:"v"` |
| + KeyID string `json:"kid"` |
| + Payload string `json:"payload"` |
| + Signature string `json:"signature"` |
| + } |
| + if e = json.Unmarshal(raw, &env); e != nil { |
| + return nil, e |
| + } |
| + payload, e := base64.RawURLEncoding.DecodeString(env.Payload) |
| + if e != nil { |
| + return nil, e |
| + } |
| + switch kind { |
| + case "scope": |
| + var p roster.Page |
| + if e = json.Unmarshal(payload, &p); e != nil { |
| + return nil, e |
| + } |
| + p.Team = "B2" |
| + raw, e = roster.SignPage(f.key, rosterKeyID(f.key), p) |
| + case "context": |
| + var sig []byte |
| + sig, e = family.Sign(f.key, roster.ProvisioningContext, payload) |
| + if e == nil { |
| + env.Signature = base64.RawURLEncoding.EncodeToString(sig) |
| + raw, e = json.Marshal(env) |
| + } |
| + case "signature": |
| + var sig []byte |
| + sig, e = base64.RawURLEncoding.DecodeString(env.Signature) |
| + if e == nil { |
| + sig[0] ^= 1 |
| + env.Signature = base64.RawURLEncoding.EncodeToString(sig) |
| + raw, e = json.Marshal(env) |
| + } |
| + } |
| + if e != nil { |
| + return nil, e |
| + } |
| + res.Body = io.NopCloser(bytes.NewReader(raw)) |
| + res.ContentLength = int64(len(raw)) |
| + return res, nil |
| + }) |
| + cache, _, _ := f.cache(t, transport, rosterKeyID(f.key)) |
| + if d := cache.Check(context.Background(), Origin, "A1", chooserPerson); d.State != roster.Unknown || d.Lease.Valid() { |
| + t.Fatal("invalid signed page granted standing", kind) |
| + } |
| + }) |
| + } |
| +} |
| +func TestRosterFrameworkMethodAndPathRefusalsNeverYieldPage(t *testing.T) { |
| + f := newRosterIntegration(t, 1) |
| + for _, method := range []string{"HEAD", "POST", "OPTIONS", "DELETE"} { |
| + r, e := http.NewRequest(method, directoryOrigin+"/roster?team=A1&fetch="+rosterTestChallenge, nil) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + r.Header.Set("Authorization", "Bearer "+f.token) |
| + res, e := f.transport.RoundTrip(r) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + res.Body.Close() |
| + if res.StatusCode != 405 || res.Header.Get("Cache-Control") != "no-store" || res.Header.Get("Content-Type") != "application/json" || res.Header.Get("Allow") != "GET" || res.Header.Get("Location") != "" { |
| + t.Fatal(method, res.StatusCode, res.Header) |
| + } |
| + } |
| + r, e := http.NewRequest("GET", directoryOrigin+"//roster?team=A1&fetch="+rosterTestChallenge, nil) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + r.Header.Set("Authorization", "Bearer "+f.token) |
| + res, e := f.transport.RoundTrip(r) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + res.Body.Close() |
| + if res.StatusCode == 200 { |
| + t.Fatal("alternate path yielded roster") |
| + } |
| + calls := 0 |
| + redirect := rosterTransport(func(r *http.Request) (*http.Response, error) { |
| + calls++ |
| + return &http.Response{StatusCode: 301, Header: http.Header{"Location": {directoryOrigin + "/roster"}, "Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(`{}`)), Request: r}, nil |
| + }) |
| + cache, _, _ := f.cache(t, redirect, rosterKeyID(f.key)) |
| + if d := cache.Check(context.Background(), Origin, "A1", chooserPerson); d.State != roster.Unknown || calls != 1 { |
| + t.Fatal("redirect followed", calls) |
| + } |
| +} |
| + |
| +func TestRosterHTTPTokenRotationOverlapBoundary(t *testing.T) { |
| + f := newRosterIntegration(t, 1) |
| + rotated := rosterAtom(15) |
| + hash := sha256.Sum256([]byte(rotated)) |
| + at := time.Now() |
| + tx, e := f.f.p.directory.db.Writer().Begin() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer tx.Rollback() |
| + 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 { |
| + t.Fatal(e) |
| + } |
| + if _, e = tx.Exec(`INSERT INTO roster_tokens VALUES(?,'alpha-docs','current',?,NULL)`, hash[:], at.Unix()); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = tx.Commit(); e != nil { |
| + t.Fatal(e) |
| + } |
| + clock := &rosterClock{at: at} |
| + f.f.p.directory.directory.roster.now = func() time.Time { now, _ := clock.Now(); return now } |
| + for _, token := range []string{f.token, rotated} { |
| + status, _, _ := f.get(t, "team=A1&fetch="+rosterTestChallenge, token) |
| + if status != 200 { |
| + t.Fatal("rotation overlap refused", status) |
| + } |
| + } |
| + clock.advance(300 * time.Second) |
| + status, _, _ := f.get(t, "team=A1&fetch="+rosterTestChallenge, f.token) |
| + if status != 401 { |
| + t.Fatal("expired previous token accepted", status) |
| + } |
| + status, _, _ = f.get(t, "team=A1&fetch="+rosterTestChallenge, rotated) |
| + if status != 200 { |
| + t.Fatal("current token refused", status) |
| + } |
| +} |
diff --git a/internal/home/roster_runtime.go b/internal/home/roster_runtime.go| new file mode 100644 |
| index 0000000..87a1b8a |
| --- /dev/null |
| +++ b/internal/home/roster_runtime.go |
| @@ -0,0 +1,105 @@ |
| +package home |
| + |
| +import ( |
| + "bytes" |
| + "crypto/ecdh" |
| + "crypto/elliptic" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "math/big" |
| + "os" |
| + |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| + "github.com/go-json-experiment/json" |
| +) |
| + |
| +// The directory's roster key signs call-8 pages only. Runtime.signer remains |
| +// credential-only; this key never signs identity or operator provisioning. |
| +type rosterRuntime struct { |
| + mode string |
| + signer *family.Keypair |
| + keyID string |
| +} |
| + |
| +func rosterKeyID(k *family.Keypair) string { |
| + sum := sha256.Sum256(k.SignPub()) |
| + return hex.EncodeToString(sum[:]) |
| +} |
| +func validRosterKey(k *family.Keypair) bool { |
| + if k == nil || k.SignPriv == nil || k.SignPriv.D == nil || k.SignPriv.Curve != elliptic.P256() || k.SignPriv.D.Sign() <= 0 || k.SignPriv.D.Cmp(elliptic.P256().Params().N) >= 0 || k.SignPriv.X == nil || k.SignPriv.Y == nil || k.BoxPriv == nil || k.BoxPriv.Curve() != ecdh.P256() { |
| + return false |
| + } |
| + x, y := elliptic.P256().ScalarBaseMult(k.SignPriv.D.Bytes()) |
| + return x.Cmp(k.SignPriv.X) == 0 && y.Cmp(k.SignPriv.Y) == 0 |
| +} |
| +func (r rosterRuntime) valid(role string, credentialKeys map[string][]byte) bool { |
| + if role != "directory" { |
| + return r.mode == "" && r.signer == nil && r.keyID == "" |
| + } |
| + if r.mode == "" || r.mode == "disabled" { |
| + return r.signer == nil && r.keyID == "" |
| + } |
| + if r.mode != "serve" || !validRosterKey(r.signer) || r.keyID != rosterKeyID(r.signer) { |
| + return false |
| + } |
| + for _, key := range credentialKeys { |
| + if bytes.Equal(key, r.signer.SignPub()) { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +func loadRosterRuntime(role string, credentialKeys map[string][]byte) (rosterRuntime, error) { |
| + mode, modeSet := os.LookupEnv("HOME_ROSTER_MODE") |
| + encoded, keySet := os.LookupEnv("HOME_ROSTER_KEY") |
| + if role != "directory" { |
| + if modeSet || keySet { |
| + return rosterRuntime{}, errExchangeConfig |
| + } |
| + return rosterRuntime{}, nil |
| + } |
| + if !modeSet { |
| + mode = "disabled" |
| + } |
| + if mode == "disabled" { |
| + if keySet { |
| + return rosterRuntime{}, errExchangeConfig |
| + } |
| + return rosterRuntime{mode: mode}, nil |
| + } |
| + if mode != "serve" || !keySet || len(encoded) > 4096 { |
| + return rosterRuntime{}, errExchangeConfig |
| + } |
| + raw, err := base64.StdEncoding.Strict().DecodeString(encoded) |
| + if err != nil || base64.StdEncoding.EncodeToString(raw) != encoded { |
| + return rosterRuntime{}, errExchangeConfig |
| + } |
| + var fields struct { |
| + Sign string `json:"sign_priv_d"` |
| + Box string `json:"box_priv_d"` |
| + } |
| + if json.Unmarshal(raw, &fields, json.RejectUnknownMembers(true)) != nil { |
| + return rosterRuntime{}, errExchangeConfig |
| + } |
| + for _, s := range []string{fields.Sign, fields.Box} { |
| + b, e := hex.DecodeString(s) |
| + if e != nil || len(b) != 32 || hex.EncodeToString(b) != s { |
| + return rosterRuntime{}, errExchangeConfig |
| + } |
| + d := new(big.Int).SetBytes(b) |
| + if d.Sign() <= 0 || d.Cmp(elliptic.P256().Params().N) >= 0 { |
| + return rosterRuntime{}, errExchangeConfig |
| + } |
| + } |
| + key, err := family.UnmarshalKeypair(raw) |
| + if err != nil || !validRosterKey(key) { |
| + return rosterRuntime{}, errExchangeConfig |
| + } |
| + cfg := rosterRuntime{mode: mode, signer: key, keyID: rosterKeyID(key)} |
| + if !cfg.valid(role, credentialKeys) { |
| + return rosterRuntime{}, errExchangeConfig |
| + } |
| + return cfg, nil |
| +} |
diff --git a/internal/home/roster_runtime_test.go b/internal/home/roster_runtime_test.go| new file mode 100644 |
| index 0000000..682d20a |
| --- /dev/null |
| +++ b/internal/home/roster_runtime_test.go |
| @@ -0,0 +1,128 @@ |
| +package home |
| + |
| +import ( |
| + "context" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "os" |
| + "strings" |
| + "testing" |
| + |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| + "github.com/carlosframework/rastrillo/migrate" |
| +) |
| + |
| +func rosterPrivate(t *testing.T) (*family.Keypair, string) { |
| + t.Helper() |
| + k, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + b, e := family.MarshalKeypair(k) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return k, base64.StdEncoding.EncodeToString(b) |
| +} |
| +func TestRosterRuntimeModeKeyAndRoleMatrix(t *testing.T) { |
| + credential, keys := runtimeEnv(t) |
| + _, private := rosterPrivate(t) |
| + t.Setenv("HOME_ROLE", "directory") |
| + t.Setenv("HOME_ASSERTION_KEYS", keys) |
| + cfg, e := LoadRuntime("home.db", quietLogger()) |
| + if e != nil || cfg.roster.mode != "disabled" || cfg.roster.signer != nil { |
| + t.Fatal("default roster not disabled", e) |
| + } |
| + for _, tc := range []struct { |
| + mode, key string |
| + ok bool |
| + }{{"disabled", "", false}, {"serve", "", false}, {"serve", private, true}, {"serve", credential, false}, {"typo", private, false}, {"", private, false}} { |
| + t.Setenv("HOME_ROSTER_MODE", tc.mode) |
| + t.Setenv("HOME_ROSTER_KEY", tc.key) |
| + cfg, e = LoadRuntime("home.db", quietLogger()) |
| + if (e == nil) != tc.ok { |
| + t.Fatal(tc.mode, "key matrix", e) |
| + } |
| + if e == nil && (cfg.signer != nil || !cfg.roster.valid("directory", cfg.keys)) { |
| + t.Fatal("role/key boundary") |
| + } |
| + } |
| + os.Unsetenv("HOME_ROSTER_KEY") |
| + t.Setenv("HOME_ROSTER_MODE", "disabled") |
| + if _, e = LoadRuntime("home.db", quietLogger()); e != nil { |
| + t.Fatal(e) |
| + } |
| + for _, role := range []string{"", "credential"} { |
| + t.Setenv("HOME_ROLE", role) |
| + if _, e = LoadRuntime("home.db", quietLogger()); e != errExchangeConfig { |
| + t.Fatal("roster config crossed role", role, e) |
| + } |
| + } |
| +} |
| +func TestRosterRuntimeStrictPrivateEncoding(t *testing.T) { |
| + _, keys := runtimeEnv(t) |
| + _, private := rosterPrivate(t) |
| + t.Setenv("HOME_ROLE", "directory") |
| + t.Setenv("HOME_ASSERTION_KEYS", keys) |
| + t.Setenv("HOME_ROSTER_MODE", "serve") |
| + raw, _ := base64.StdEncoding.DecodeString(private) |
| + invalid := []string{"", "not-base64", private + "\n", strings.Repeat("A", 4097)} |
| + for _, s := range []string{strings.TrimSuffix(string(raw), "}") + `,"unknown":1}`, strings.TrimSuffix(string(raw), "}") + `,"sign_priv_d":"` + strings.Repeat("1", 64) + `"}`, `{"sign_priv_d":"` + strings.Repeat("0", 64) + `","box_priv_d":"` + strings.Repeat("1", 64) + `"}`, `{"sign_priv_d":"` + strings.Repeat("f", 64) + `","box_priv_d":"` + strings.Repeat("1", 64) + `"}`, `{"sign_priv_d":"` + strings.Repeat("A", 64) + `","box_priv_d":"` + strings.Repeat("1", 64) + `"}`, `{"sign_priv_d":"01","box_priv_d":"01"}`} { |
| + invalid = append(invalid, base64.StdEncoding.EncodeToString([]byte(s))) |
| + } |
| + for _, bad := range invalid { |
| + t.Setenv("HOME_ROSTER_KEY", bad) |
| + if _, e := LoadRuntime("home.db", quietLogger()); e != errExchangeConfig { |
| + t.Fatal("bad private encoding accepted", e) |
| + } |
| + } |
| +} |
| +func TestRosterDirectConstructorsAndEmptySchema(t *testing.T) { |
| + p := newExchangePair(t) |
| + base := *p.directory.runtime |
| + k, _ := rosterPrivate(t) |
| + for _, r := range []rosterRuntime{{mode: "serve"}, {mode: "disabled", signer: k}, {mode: "serve", signer: k, keyID: "wrong"}, {mode: "typo"}} { |
| + cfg := base |
| + cfg.roster = r |
| + if _, e := NewRuntime(cfg, p.directory.db, quietLogger()); e != errExchangeConfig { |
| + t.Fatal("unchecked runtime", e) |
| + } |
| + if _, e := newDirectory(cfg, p.directory.db, quietLogger()); e != errExchangeConfig { |
| + t.Fatal("unchecked directory", e) |
| + } |
| + } |
| + cfg := base |
| + cfg.roster = rosterRuntime{mode: "serve", signer: k, keyID: rosterKeyID(k)} |
| + if _, e := NewRuntime(cfg, p.directory.db, quietLogger()); e != nil { |
| + t.Fatal(e) |
| + } |
| + cfg.keys = map[string][]byte{hex.EncodeToString([]byte("key")): k.SignPub()} |
| + if _, e := NewRuntime(cfg, p.directory.db, quietLogger()); e != errExchangeConfig { |
| + t.Fatal("credential key reuse", e) |
| + } |
| + cfg = *p.credential.runtime |
| + cfg.roster = rosterRuntime{mode: "serve", signer: k, keyID: rosterKeyID(k)} |
| + if _, e := NewRuntime(cfg, p.credential.db, quietLogger()); e != errExchangeConfig { |
| + t.Fatal("private roster crossed role", e) |
| + } |
| + for i := 0; i < 2; i++ { |
| + if _, e := migrate.Apply(context.Background(), p.directory.db, directorySchema); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e := NewRuntime(base, p.directory.db, quietLogger()); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + for _, table := range []string{"roster_generation", "roster_consumers", "roster_tokens", "roster_operator_receipts"} { |
| + var n int |
| + if e := p.directory.db.Writer().QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); e != nil || n != 0 { |
| + t.Fatal("authority initialized", table, n, e) |
| + } |
| + } |
| + for _, app := range []*App{p.credential.App, newTestApp(t).App} { |
| + var n int |
| + if e := app.db.Writer().QueryRow(`SELECT count(*) FROM sqlite_master WHERE name LIKE 'roster_%' OR name LIKE 'memberships_epoch_%'`).Scan(&n); e != nil || n != 0 { |
| + t.Fatal("roster schema crossed role", n, e) |
| + } |
| + } |
| +} |
diff --git a/internal/home/runtime.go b/internal/home/runtime.go| index db6c6a0..1da805b 100644 |
| --- a/internal/home/runtime.go |
| +++ b/internal/home/runtime.go |
| @@ -20,9 +20,10 @@ import ( |
| ) |
| var errExchangeConfig = errors.New("home: invalid assertion configuration") |
| -var directorySchema = migrate.Merge(sessions.Schema, migrate.MustFromFS(handoff.Migrations, "oficina_handoff"), directorydata.Schema) |
| +var directorySchema = migrate.Merge(sessions.Schema, migrate.MustFromFS(handoff.Migrations, "oficina_handoff"), directorydata.Schema, directorydata.RosterSchema) |
| type Runtime struct { |
| + roster rosterRuntime |
| role string |
| origins Origins |
| secrets Secrets |
| @@ -42,6 +43,12 @@ func LoadRuntime(dbPath string, logger *slog.Logger) (Runtime, error) { |
| return Runtime{}, err |
| } |
| cfg.role = os.Getenv("HOME_ROLE") |
| + if cfg.role != "directory" { |
| + cfg.roster, err = loadRosterRuntime(cfg.role, nil) |
| + if err != nil { |
| + return Runtime{}, err |
| + } |
| + } |
| switch cfg.role { |
| case "": |
| cfg.secrets, err = LoadSecrets(dbPath, logger) |
| @@ -117,6 +124,10 @@ func LoadRuntime(dbPath string, logger *slog.Logger) (Runtime, error) { |
| if _, err = assertion.NewVerifier(cfg.origins.Credential, cfg.origins.Directory, cfg.keys); err != nil { |
| return Runtime{}, errExchangeConfig |
| } |
| + cfg.roster, err = loadRosterRuntime(cfg.role, cfg.keys) |
| + if err != nil { |
| + return Runtime{}, err |
| + } |
| default: |
| return Runtime{}, errExchangeConfig |
| } |
| @@ -140,6 +151,9 @@ func (cfg Runtime) Schema() *migrate.Set { |
| return BootSchema |
| } |
| func NewRuntime(cfg Runtime, d *db.DB, logger *slog.Logger) (*App, error) { |
| + if !cfg.roster.valid(cfg.role, cfg.keys) { |
| + return nil, errExchangeConfig |
| + } |
| if cfg.role == "directory" { |
| return newDirectory(cfg, d, logger) |
| } |
diff --git a/internal/home/runtime_test.go b/internal/home/runtime_test.go| index 84036af..05894fe 100644 |
| --- a/internal/home/runtime_test.go |
| +++ b/internal/home/runtime_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "encoding/base64" |
| "encoding/hex" |
| "encoding/json" |
| + "os" |
| "testing" |
| assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| @@ -13,6 +14,10 @@ import ( |
| func runtimeEnv(t *testing.T) (string, string) { |
| t.Helper() |
| setSecretEnv(t, nil) |
| + for _, k := range []string{"HOME_ROSTER_MODE", "HOME_ROSTER_KEY"} { |
| + t.Setenv(k, "") |
| + os.Unsetenv(k) |
| + } |
| for _, k := range []string{"HOME_CONSUMERS_BASE64", "HOME_ASSERTION_KEYS_BASE64", "HOME_ROLE", "HOME_ASSERTION_KEY", "HOME_ASSERTION_KEYS", "HOME_CONSUMERS", "HOME_CREDENTIAL_ORIGIN", "HOME_DIRECTORY_ORIGIN"} { |
| t.Setenv(k, "") |
| } |
| @@ -57,7 +62,7 @@ func TestDirectoryRuntimeNeedsNoCredentialMaterial(t *testing.T) { |
| } |
| t.Setenv("HOME_ASSERTION_KEY", "present") |
| if _, err = LoadRuntime("directory.db", quietLogger()); err != errExchangeConfig { |
| - t.Fatal("directory accepted private signing material") |
| + t.Fatal("directory accepted credential private signing material") |
| } |
| } |
| func TestCredentialRuntimeValidatesManifestAndSigningKey(t *testing.T) { |