oficina / home Public

Mount disabled-by-default signed directory roster endpoint

Paul Campbell pushed by paul@keymail.dev fd1194da0e0843be60690310b2fad149a65bae63 parent b3bd3b7
11 files changed, +1169 −8
  • go.mod +1 −1
  • go.sum +2 −0
  • internal/home/directory.go +8 −2
  • internal/home/directory_runtime.go +15 −3
  • internal/home/roster_http.go +162 −0
  • internal/home/roster_http_test.go +174 −0
  • internal/home/roster_integration_test.go +553 −0
  • internal/home/roster_runtime.go +105 −0
  • internal/home/roster_runtime_test.go +128 −0
  • internal/home/runtime.go +15 −1
  • internal/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) {