console: door keys, with the database as authority and a re-signed projection (#175 P7)
Task 2 of the P7 plan. door_keys and door_key_revisions tables; person-only mint, list and revoke doors; publishDoorKeys serialises per app, rebuilds from the database, advances only when the database is below the bucket's verified revision, and writes by ETag compare-and-swap with at most three attempts. A mint whose publish fails revokes the unshown key rather than stranding it in a slot. The re-sign pass runs at startup and every 12 hours, wired beside the console's other passes. Built by an Opus subagent from the plan; reviewed before commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Human
Prompt
Go ahead. (start P7)
From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.
Summary
P7 Task 2: console door-key authority, doors and re-sign pass.
8 files changed,
+1011
−4
cmd/console/main.go+4 −0internal/console/apidoorkeys.go+340 −0internal/console/apidoorkeys_test.go+427 −0internal/console/authcli.go+5 −0internal/console/doorkeys_store.go+215 −0internal/console/server.go+6 −0internal/console/store.go+5 −1internal/console/svcroutes_test.go+9 −3
diff --git a/cmd/console/main.go b/cmd/console/main.go| index ae91ed32e..8b5a8e789 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -379,6 +379,10 @@ func serve() { |
| // SES outcomes an app asked to be told about. Same placement rule as |
| // every pass above; with nothing queued, a pass is two indexed queries. |
| go srv.StartMailEventPush(context.Background()) |
| + // Door keys (#175 P7): re-sign every app's record before its 72-hour |
| + // expiry, once at startup and then every 12 hours. Inert without a |
| + // signing key or a deployment bucket. |
| + go srv.StartDoorKeyResign(context.Background()) |
| // Once, at boot: log any route already sitting at another app's platform |
| // apex (internal/console/apex.go). Reports; repairs nothing. |
| go srv.ReportApexSquats(context.Background()) |
diff --git a/internal/console/apidoorkeys.go b/internal/console/apidoorkeys.go| new file mode 100644 |
| index 000000000..919ab5366 |
| --- /dev/null |
| +++ b/internal/console/apidoorkeys.go |
| @@ -0,0 +1,340 @@ |
| +package console |
| + |
| +// Door keys (#175 P7): the credential an app's own processes present at a |
| +// box's edge-local door. The console's database is the authority |
| +// (doorkeys_store.go). The bucket holds a projection of it at |
| +// doorkey.RecordKey, signed with the deployment's root release key, carrying |
| +// a revision and a 72-hour expiry, and re-signed every 12 hours. |
| +// |
| +// Exactly one code path writes that projection: publishDoorKeys. It always |
| +// rebuilds from the database and never reads a key back out of the bucket, |
| +// so a record planted or copied there by a bucket writer is overwritten, not |
| +// extended (Astra round 1, finding 1). |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "net/http" |
| + "os" |
| + "sync" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/doorkey" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const ( |
| + // doorKeyResignEvery is how often every record is re-signed with a fresh |
| + // expiry. A record lives doorkey.Lifetime (72 h), so a console that is |
| + // down for up to 60 hours past its last re-sign leaves no door refusing. |
| + doorKeyResignEvery = 12 * time.Hour |
| + // doorKeyPublishAttempts bounds publishDoorKeys' compare-and-swap loop. |
| + doorKeyPublishAttempts = 3 |
| + // doorKeyPublishBudget bounds one publication started from a request or |
| + // from the re-sign pass. |
| + doorKeyPublishBudget = 20 * time.Second |
| +) |
| + |
| +// Draft copy, pending review (plan Task 8). |
| +const ( |
| + doorKeyReadOnlyMsg = "no signing key on this host. the console is running read-only" |
| + doorKeyNoBucketMsg = "no deployment bucket on this host. the console is running without one" |
| +) |
| + |
| +// doorPublishLock is the app's publication mutex, created on first use. |
| +func (s *Server) doorPublishLock(sqid, app string) *sync.Mutex { |
| + s.doorPublishMu.Lock() |
| + defer s.doorPublishMu.Unlock() |
| + if s.doorPublish == nil { |
| + s.doorPublish = map[string]*sync.Mutex{} |
| + } |
| + k := sqid + "/" + app |
| + mu := s.doorPublish[k] |
| + if mu == nil { |
| + mu = &sync.Mutex{} |
| + s.doorPublish[k] = mu |
| + } |
| + return mu |
| +} |
| + |
| +// publishDoorKeys writes the app's signed record from the database. The |
| +// protocol (plan Task 2, round 2 findings 4 to 6): |
| +// |
| +// 1. Hold the app's publication mutex throughout. A renewal and a revoke |
| +// for the same app are serialized, and each builds its record from the |
| +// database inside the lock, so a renewal can never put back a key set |
| +// older than one already published. |
| +// 2. Read the bucket's object and its ETag. The bucket's revision is the one |
| +// a valid signature vouches for (expired or not), and zero for anything |
| +// absent or unverifiable: a tampered object is overwritten, never |
| +// trusted. |
| +// 3. Read the live keys and the revision in one transaction. If the |
| +// database is below the bucket (a restored database, or a second |
| +// writer), advance its counter past the bucket first and read again. An |
| +// equal revision is the ordinary renewal and is re-published as it is. |
| +// 4. Sign the record with a fresh expiry and Put it against the ETag read in |
| +// step 2 (create-only when there was no object). |
| +// 5. A lost compare-and-swap starts again at step 2, rebuilding from the |
| +// database, never reusing signed bytes. After doorKeyPublishAttempts |
| +// losses the conflict is returned. |
| +func (s *Server) publishDoorKeys(ctx context.Context, acct *Account, app string) error { |
| + if len(s.cfg.Key) != ed25519.PrivateKeySize { |
| + return errors.New("console: door keys: no signing key") |
| + } |
| + if s.cfg.Releases == nil { |
| + return errors.New("console: door keys: no deployment bucket") |
| + } |
| + pub, _ := s.cfg.Key.Public().(ed25519.PublicKey) |
| + mu := s.doorPublishLock(acct.Sqid, app) |
| + mu.Lock() |
| + defer mu.Unlock() |
| + |
| + key := doorkey.RecordKey(acct.Sqid, app) |
| + var lost error |
| + for attempt := 0; attempt < doorKeyPublishAttempts; attempt++ { |
| + wire, etag, err := s.cfg.Releases.Get(ctx, key) |
| + var bucketRev uint64 |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + etag = "" |
| + case err != nil: |
| + return fmt.Errorf("console: door keys %s/%s: read: %w", acct.Sqid, app, err) |
| + default: |
| + if rev, verr := doorkey.VerifyRevision(pub, wire); verr == nil { |
| + bucketRev = rev |
| + } else { |
| + log.Printf("console: door keys %s/%s: overwriting an unverifiable object: %v", acct.Sqid, app, verr) |
| + } |
| + } |
| + |
| + keys, rev, err := s.store.DoorKeysLive(acct.ID, app) |
| + if err != nil { |
| + return fmt.Errorf("console: door keys %s/%s: %w", acct.Sqid, app, err) |
| + } |
| + if rev < bucketRev { |
| + if _, err := s.store.DoorKeyAdvanceRevision(acct.ID, app, bucketRev); err != nil { |
| + return fmt.Errorf("console: door keys %s/%s: advance past %d: %w", acct.Sqid, app, bucketRev, err) |
| + } |
| + log.Printf("console: door keys %s/%s: database revision %d was below the bucket's %d; advanced past it", |
| + acct.Sqid, app, rev, bucketRev) |
| + if keys, rev, err = s.store.DoorKeysLive(acct.ID, app); err != nil { |
| + return fmt.Errorf("console: door keys %s/%s: %w", acct.Sqid, app, err) |
| + } |
| + } |
| + if rev == 0 { |
| + return nil // never minted, and nothing verifiable in the bucket: nothing to publish |
| + } |
| + |
| + now := time.Now().UTC() |
| + body, err := doorkey.Sign(s.cfg.Key, doorkey.Record{V: 1, Account: acct.Sqid, App: app, |
| + Revision: rev, Keys: keys, SignedAt: now, ExpiresAt: now.Add(doorkey.Lifetime)}) |
| + if err != nil { |
| + return fmt.Errorf("console: door keys %s/%s: sign: %w", acct.Sqid, app, err) |
| + } |
| + _, err = s.cfg.Releases.Put(ctx, key, body, etag) |
| + if err == nil { |
| + return nil |
| + } |
| + if !errors.Is(err, release.ErrCASConflict) { |
| + return fmt.Errorf("console: door keys %s/%s: write: %w", acct.Sqid, app, err) |
| + } |
| + lost = err |
| + } |
| + return fmt.Errorf("console: door keys %s/%s: %d attempts: %w", acct.Sqid, app, doorKeyPublishAttempts, lost) |
| +} |
| + |
| +// publishDoorKeysDetached runs publishDoorKeys for a request whose database |
| +// write has already landed: a client that hangs up must not cancel the |
| +// publication of a change that is already the truth. |
| +func (s *Server) publishDoorKeysDetached(r *http.Request, acct *Account, app string) error { |
| + ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), doorKeyPublishBudget) |
| + defer cancel() |
| + return s.publishDoorKeys(ctx, acct, app) |
| +} |
| + |
| +// doorKeyWritable answers the 503 a console that cannot publish gives, before |
| +// anything is written. |
| +func (s *Server) doorKeyWritable(w http.ResponseWriter) bool { |
| + if len(s.cfg.Key) == 0 { |
| + jsonError(w, http.StatusServiceUnavailable, doorKeyReadOnlyMsg) |
| + return false |
| + } |
| + if s.cfg.Releases == nil { |
| + jsonError(w, http.StatusServiceUnavailable, doorKeyNoBucketMsg) |
| + return false |
| + } |
| + return true |
| +} |
| + |
| +// handleAPIDoorKeyMint mints one door key for the app. Person-only, the |
| +// instance-token rule: a credential may never mint a credential. The zero |
| +// Set at the registration refuses every service role (authcli.go), and |
| +// apiPerson is the inner wall. |
| +// |
| +// The key is shown exactly once. Only its sha256 is stored. |
| +func (s *Server) handleAPIDoorKeyMint(w http.ResponseWriter, r *http.Request) { |
| + if !s.apiPerson(w, r) { |
| + return |
| + } |
| + addr, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + p, _ := principalFrom(r.Context()) |
| + if !s.allowWrite(p, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests. wait a minute and try again") |
| + return |
| + } |
| + if !s.doorKeyWritable(w) { |
| + return |
| + } |
| + var req struct { |
| + Label string `json:"label"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + raw, entry, err := doorkey.NewToken(acct.Sqid, app) |
| + if err != nil { |
| + log.Printf("console: door key mint %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusInternalServerError, "could not mint the door key. try again") |
| + return |
| + } |
| + entry.Label = cleanLabel(req.Label) |
| + if _, err := s.store.DoorKeyInsert(acct.ID, app, entry); err != nil { |
| + if errors.Is(err, errDoorKeysFull) { |
| + jsonError(w, http.StatusConflict, fmt.Sprintf( |
| + "this app already has %d door keys. revoke one before you mint another", doorkey.MaxKeys)) |
| + return |
| + } |
| + log.Printf("console: door key mint %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusInternalServerError, "could not mint the door key. try again") |
| + return |
| + } |
| + if err := s.publishDoorKeysDetached(r, acct, app); err != nil { |
| + // The secret is about to be lost (it is never shown), so the row it |
| + // hashes is revoked rather than left holding one of the app's slots |
| + // until the next re-sign publishes a key nobody has. |
| + log.Printf("console: door key mint %s/%s: publish: %v", acct.Sqid, app, err) |
| + if _, rerr := s.store.DoorKeyRevoke(acct.ID, app, entry.ID); rerr != nil { |
| + log.Printf("console: door key mint %s/%s: withdraw unpublished key %s: %v", acct.Sqid, app, entry.ID, rerr) |
| + } |
| + jsonError(w, http.StatusBadGateway, "could not publish the door key. try again") |
| + return |
| + } |
| + s.audit(acct, app, addr, "door-key", "minted door key "+entry.ID+" for "+app+" (cli)") |
| + s.bumpSerial(r.Context()) |
| + writeJSON(w, http.StatusCreated, map[string]any{ |
| + "key": raw, "id": entry.ID, "account": acct.Sqid, "app": app, |
| + }) |
| +} |
| + |
| +// handleAPIDoorKeyList lists the app's live keys: id, label and when each |
| +// was minted. Never a digest. |
| +func (s *Server) handleAPIDoorKeyList(w http.ResponseWriter, r *http.Request) { |
| + if !s.apiPerson(w, r) { |
| + return |
| + } |
| + _, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + keys, _, err := s.store.DoorKeysLive(acct.ID, app) |
| + if err != nil { |
| + log.Printf("console: door key list %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusInternalServerError, "could not list the door keys. try again") |
| + return |
| + } |
| + type listed struct { |
| + ID string `json:"id"` |
| + Label string `json:"label"` |
| + CreatedAt time.Time `json:"created_at"` |
| + } |
| + out := make([]listed, 0, len(keys)) |
| + for _, k := range keys { |
| + out = append(out, listed{ID: k.ID, Label: k.Label, CreatedAt: k.CreatedAt}) |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"keys": out}) |
| +} |
| + |
| +// handleAPIDoorKeyRevoke revokes one live key and publishes the record |
| +// without it. |
| +func (s *Server) handleAPIDoorKeyRevoke(w http.ResponseWriter, r *http.Request) { |
| + if !s.apiPerson(w, r) { |
| + return |
| + } |
| + addr, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + p, _ := principalFrom(r.Context()) |
| + if !s.allowWrite(p, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests. wait a minute and try again") |
| + return |
| + } |
| + if !s.doorKeyWritable(w) { |
| + return |
| + } |
| + id := r.PathValue("id") |
| + if _, err := s.store.DoorKeyRevoke(acct.ID, app, id); err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + jsonError(w, http.StatusNotFound, "no live door key with that id") |
| + return |
| + } |
| + log.Printf("console: door key revoke %s/%s %s: %v", acct.Sqid, app, id, err) |
| + jsonError(w, http.StatusInternalServerError, "could not revoke the door key. try again") |
| + return |
| + } |
| + s.audit(acct, app, addr, "door-key-revoke", "revoked door key "+id+" for "+app+" (cli)") |
| + if err := s.publishDoorKeysDetached(r, acct, app); err != nil { |
| + // The database is the truth and the key is revoked there; the |
| + // re-sign pass publishes it on its next run. |
| + log.Printf("console: door key revoke %s/%s %s: publish: %v", acct.Sqid, app, id, err) |
| + jsonError(w, http.StatusBadGateway, |
| + "the door key is revoked but not yet published. boxes keep admitting it until the console publishes again, within 12 hours") |
| + return |
| + } |
| + s.bumpSerial(r.Context()) |
| + writeJSON(w, http.StatusOK, map[string]any{"revoked": id}) |
| +} |
| + |
| +// resignDoorKeys re-publishes every app's record with a fresh expiry. A |
| +// failure for one app is logged and does not stop the others. |
| +func (s *Server) resignDoorKeys(ctx context.Context) { |
| + apps, err := s.store.DoorKeyApps() |
| + if err != nil { |
| + log.Printf("console: door keys re-sign: %v", err) |
| + return |
| + } |
| + for _, a := range apps { |
| + pctx, cancel := context.WithTimeout(ctx, doorKeyPublishBudget) |
| + err := s.publishDoorKeys(pctx, &Account{ID: a.AccountID, Sqid: a.Sqid}, a.App) |
| + cancel() |
| + if err != nil { |
| + log.Printf("console: door keys re-sign %s/%s: %v", a.Sqid, a.App, err) |
| + } |
| + } |
| +} |
| + |
| +// StartDoorKeyResign re-signs every door-key record now and then every 12 |
| +// hours until ctx is done. Started from cmd/console beside the other passes, |
| +// and only on a console that holds the signing key: a read-only console has |
| +// nothing to sign with. |
| +func (s *Server) StartDoorKeyResign(ctx context.Context) { |
| + if len(s.cfg.Key) == 0 || s.cfg.Releases == nil { |
| + return |
| + } |
| + t := time.NewTicker(doorKeyResignEvery) |
| + defer t.Stop() |
| + for { |
| + s.resignDoorKeys(ctx) |
| + select { |
| + case <-ctx.Done(): |
| + return |
| + case <-t.C: |
| + } |
| + } |
| +} |
diff --git a/internal/console/apidoorkeys_test.go b/internal/console/apidoorkeys_test.go| new file mode 100644 |
| index 000000000..19f96cb08 |
| --- /dev/null |
| +++ b/internal/console/apidoorkeys_test.go |
| @@ -0,0 +1,427 @@ |
| +package console |
| + |
| +// Door keys (#175 P7, apidoorkeys.go): the console's database is the |
| +// authority, and the bucket holds a signed, revisioned, expiring projection |
| +// of it that one serialized publisher rebuilds from the database every time. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "os" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/doorkey" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +func doorKeysURL(fx *consoleFixture) string { return fx.ts.URL + "/api/cli/apps/bab/titogo/door-keys" } |
| + |
| +func readDoorRecord(t *testing.T, fx *consoleFixture) doorkey.Record { |
| + t.Helper() |
| + wire, _, err := fx.rel.Get(context.Background(), doorkey.RecordKey("bab", "titogo")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec, err := doorkey.Verify(fx.pub, wire, time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return rec |
| +} |
| + |
| +// mintDoorKey mints one key through the door and returns the parsed token. |
| +func mintDoorKey(t *testing.T, fx *consoleFixture, token string) doorkey.Token { |
| + t.Helper() |
| + code, _, out := rawReq(t, http.MethodPost, doorKeysURL(fx), token, `{"label":"router"}`) |
| + if code != http.StatusCreated { |
| + t.Fatalf("mint = %d %s", code, out) |
| + } |
| + var minted struct{ Key, ID string } |
| + if err := json.Unmarshal([]byte(out), &minted); err != nil { |
| + t.Fatal(err) |
| + } |
| + tok, err := doorkey.ParseToken(minted.Key) |
| + if err != nil || tok.ID != minted.ID { |
| + t.Fatalf("minted key %q: %v", minted.Key, err) |
| + } |
| + return tok |
| +} |
| + |
| +// putDoorObject overwrites whatever sits at the app's record key. |
| +func putDoorObject(t *testing.T, fx *consoleFixture, body []byte) { |
| + t.Helper() |
| + key := doorkey.RecordKey("bab", "titogo") |
| + _, etag, err := fx.rel.Get(context.Background(), key) |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.rel.Put(context.Background(), key, body, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func signedDoorRecord(t *testing.T, fx *consoleFixture, rev uint64, keys []doorkey.Entry) []byte { |
| + t.Helper() |
| + now := time.Now().UTC() |
| + wire, err := doorkey.Sign(fx.priv, doorkey.Record{V: 1, Account: "bab", App: "titogo", Revision: rev, |
| + Keys: keys, SignedAt: now, ExpiresAt: now.Add(time.Hour)}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return wire |
| +} |
| + |
| +func TestDoorKeysMintListRevoke(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-door-keys") |
| + code, _, out := rawReq(t, http.MethodPost, doorKeysURL(fx), token, `{"label":"router"}`) |
| + if code != http.StatusCreated { |
| + t.Fatalf("mint = %d %s", code, out) |
| + } |
| + var minted struct{ Key, ID, Account, App string } |
| + json.Unmarshal([]byte(out), &minted) |
| + tok, err := doorkey.ParseToken(minted.Key) |
| + if err != nil || tok.ID != minted.ID { |
| + t.Fatalf("minted key %q: %v", minted.Key, err) |
| + } |
| + if minted.Account != "bab" || minted.App != "titogo" { |
| + t.Fatalf("mint answered account/app %q/%q", minted.Account, minted.App) |
| + } |
| + rec := readDoorRecord(t, fx) |
| + if !rec.Admits(tok) || rec.Revision != 1 { |
| + t.Fatalf("record %+v does not admit the minted key at revision 1", rec) |
| + } |
| + code, _, out = rawReq(t, http.MethodGet, doorKeysURL(fx), token, "") |
| + if code != http.StatusOK || !strings.Contains(out, minted.ID) || strings.Contains(out, "sha256") || |
| + !strings.Contains(out, "router") { |
| + t.Fatalf("list = %d %s", code, out) |
| + } |
| + if code, _, out = rawReq(t, http.MethodPost, doorKeysURL(fx)+"/"+minted.ID+"/revoke", token, ""); code != http.StatusOK { |
| + t.Fatalf("revoke = %d %s", code, out) |
| + } |
| + if rec := readDoorRecord(t, fx); rec.Admits(tok) || rec.Revision != 2 { |
| + t.Fatalf("after revoke: %+v", rec) |
| + } |
| + // Revoked is gone from the list, and a second revoke is an unknown id. |
| + if code, _, out = rawReq(t, http.MethodGet, doorKeysURL(fx), token, ""); code != http.StatusOK || strings.Contains(out, minted.ID) { |
| + t.Fatalf("list after revoke = %d %s", code, out) |
| + } |
| + if code, _, out = rawReq(t, http.MethodPost, doorKeysURL(fx)+"/"+minted.ID+"/revoke", token, ""); code != http.StatusNotFound { |
| + t.Fatalf("second revoke = %d %s, want 404", code, out) |
| + } |
| + if code, _, out = rawReq(t, http.MethodPost, doorKeysURL(fx)+"/0123456789abcdef/revoke", token, ""); code != http.StatusNotFound { |
| + t.Fatalf("unknown id revoke = %d %s, want 404", code, out) |
| + } |
| +} |
| + |
| +// Finding 1: whatever sits in the bucket, the console publishes from its |
| +// database. A record copied in from another app's path is overwritten, not |
| +// extended. |
| +func TestDoorKeysPublishFromTheDatabaseNotTheBucket(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-door-keys-copy") |
| + _, planted, _ := doorkey.NewToken("bab", "titogo") |
| + now := time.Now().UTC() |
| + forged, _ := doorkey.Sign(fx.priv, doorkey.Record{V: 1, Account: "bab", App: "titogo", Revision: 99, |
| + Keys: []doorkey.Entry{planted}, SignedAt: now, ExpiresAt: now.Add(time.Hour)}) |
| + fx.rel.Put(context.Background(), doorkey.RecordKey("bab", "titogo"), forged, "") |
| + if code, _, out := rawReq(t, http.MethodPost, doorKeysURL(fx), token, `{}`); code != http.StatusCreated { |
| + t.Fatalf("mint = %d %s", code, out) |
| + } |
| + rec := readDoorRecord(t, fx) |
| + for _, e := range rec.Keys { |
| + if e.ID == planted.ID { |
| + t.Fatal("the console carried a key it never minted into its signed record") |
| + } |
| + } |
| + if len(rec.Keys) != 1 { |
| + t.Fatalf("record carries %d keys, want the one minted", len(rec.Keys)) |
| + } |
| +} |
| + |
| +func TestDoorKeysRefuseServiceCredentialsAndReadOnlyConsoles(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-door-keys-svc") |
| + admin := serviceToken(t, fx.st, OpsAccountID, "root", "", svcrole.Admin) |
| + for _, c := range []struct{ method, url, body string }{ |
| + {http.MethodPost, doorKeysURL(fx), `{}`}, |
| + {http.MethodGet, doorKeysURL(fx), ""}, |
| + {http.MethodPost, doorKeysURL(fx) + "/0123456789abcdef/revoke", ""}, |
| + } { |
| + if code, _, out := rawReq(t, c.method, c.url, admin, c.body); code != http.StatusForbidden { |
| + t.Errorf("%s %s with an admin service credential = %d %s, want 403", c.method, c.url, code, out) |
| + } |
| + } |
| + if _, _, err := fx.rel.Get(context.Background(), doorkey.RecordKey("bab", "titogo")); err == nil { |
| + t.Fatal("a refused service credential left a door-key record") |
| + } |
| + |
| + ro := newConsoleFixtureKeyed(t, false) |
| + now := time.Now() |
| + if _, err := ro.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := ro.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + member := cliToken(t, ro, "alice@keymail.dev", "code-door-keys-ro") |
| + if code, _, out := rawReq(t, http.MethodPost, doorKeysURL(ro), member, `{}`); code != http.StatusServiceUnavailable { |
| + t.Fatalf("mint on a read-only console = %d %s, want 503", code, out) |
| + } |
| + if code, _, out := rawReq(t, http.MethodPost, doorKeysURL(ro)+"/0123456789abcdef/revoke", member, ""); code != http.StatusServiceUnavailable { |
| + t.Fatalf("revoke on a read-only console = %d %s, want 503", code, out) |
| + } |
| + if _, _, err := ro.rel.Get(context.Background(), doorkey.RecordKey("bab", "titogo")); err == nil { |
| + t.Fatal("a read-only console wrote a door-key record") |
| + } |
| + if keys, _, err := ro.st.DoorKeysLive(OpsAccountID, "titogo"); err != nil || len(keys) != 0 { |
| + t.Fatalf("a read-only console stored keys: %v %v", keys, err) |
| + } |
| +} |
| + |
| +func TestDoorKeysRefuseANinthKey(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-door-keys-full") |
| + for i := 0; i < doorkey.MaxKeys; i++ { |
| + mintDoorKey(t, fx, token) |
| + } |
| + if code, _, out := rawReq(t, http.MethodPost, doorKeysURL(fx), token, `{}`); code != http.StatusConflict { |
| + t.Fatalf("ninth mint = %d %s, want 409", code, out) |
| + } |
| + rec := readDoorRecord(t, fx) |
| + if len(rec.Keys) != doorkey.MaxKeys || rec.Revision != doorkey.MaxKeys { |
| + t.Fatalf("record after a refused ninth: %d keys at revision %d", len(rec.Keys), rec.Revision) |
| + } |
| +} |
| + |
| +func TestDoorKeyResignRefreshesExpiry(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-door-keys-resign") |
| + tok := mintDoorKey(t, fx, token) |
| + before := readDoorRecord(t, fx) |
| + acct, err := fx.st.AccountByID(OpsAccountID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + time.Sleep(2 * time.Millisecond) |
| + if err := fx.srv.publishDoorKeys(context.Background(), acct, "titogo"); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := readDoorRecord(t, fx) |
| + if after.Revision != before.Revision { |
| + t.Fatalf("a renewal moved the revision %d -> %d", before.Revision, after.Revision) |
| + } |
| + if !after.ExpiresAt.After(before.ExpiresAt) || !after.SignedAt.After(before.SignedAt) { |
| + t.Fatalf("a renewal did not refresh the expiry: %v -> %v", before.ExpiresAt, after.ExpiresAt) |
| + } |
| + if !after.Admits(tok) { |
| + t.Fatal("a renewal dropped a live key") |
| + } |
| + if _, rev, _ := fx.st.DoorKeysLive(OpsAccountID, "titogo"); rev != before.Revision { |
| + t.Fatalf("a renewal moved the database's revision to %d", rev) |
| + } |
| +} |
| + |
| +// blockingStore holds the first Put to one key until release is closed, |
| +// signalling entered when it gets there. |
| +type blockingStore struct { |
| + release.Store |
| + key string |
| + once sync.Once |
| + entered chan struct{} |
| + release chan struct{} |
| +} |
| + |
| +func (b *blockingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if key == b.key { |
| + first := false |
| + b.once.Do(func() { first = true }) |
| + if first { |
| + close(b.entered) |
| + <-b.release |
| + } |
| + } |
| + return b.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +// Round 2 finding 5: a renewal that read revision N must not land after a |
| +// revoke published N+1. The renewal is held inside its Put, under the |
| +// publication lock, while the revoke writes the database and queues behind |
| +// the lock. The bucket must end on the revoke's revision. |
| +func TestDoorKeyRevokeIsNotOverwrittenByARacingRenewal(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-door-keys-race") |
| + tok := mintDoorKey(t, fx, token) |
| + acct, err := fx.st.AccountByID(OpsAccountID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + bs := &blockingStore{Store: fx.rel, key: doorkey.RecordKey("bab", "titogo"), |
| + entered: make(chan struct{}), release: make(chan struct{})} |
| + fx.srv.cfg.Releases = bs |
| + |
| + var wg sync.WaitGroup |
| + var renewErr error |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + renewErr = fx.srv.publishDoorKeys(context.Background(), acct, "titogo") |
| + }() |
| + <-bs.entered // the renewal has signed revision 1 and is holding the lock |
| + |
| + var revokeCode int |
| + var revokeOut string |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + revokeCode, _, revokeOut = rawReq(t, http.MethodPost, doorKeysURL(fx)+"/"+tok.ID+"/revoke", token, "") |
| + }() |
| + // The revoke's database write lands before it reaches the lock. |
| + deadline := time.Now().Add(5 * time.Second) |
| + for { |
| + if _, rev, _ := fx.st.DoorKeysLive(OpsAccountID, "titogo"); rev == 2 { |
| + break |
| + } |
| + if time.Now().After(deadline) { |
| + t.Fatal("the revoke never wrote the database") |
| + } |
| + time.Sleep(time.Millisecond) |
| + } |
| + close(bs.release) |
| + wg.Wait() |
| + if renewErr != nil { |
| + t.Fatalf("renewal: %v", renewErr) |
| + } |
| + if revokeCode != http.StatusOK { |
| + t.Fatalf("revoke = %d %s", revokeCode, revokeOut) |
| + } |
| + rec := readDoorRecord(t, fx) |
| + if rec.Revision != 2 || rec.Admits(tok) { |
| + t.Fatalf("bucket after the race: revision %d, admits the revoked key %v", rec.Revision, rec.Admits(tok)) |
| + } |
| +} |
| + |
| +// Round 2 finding 6: a database whose counter is behind the bucket (a |
| +// restore) publishes above the bucket's revision, never at or below it. |
| +func TestDoorKeyPublishAdvancesPastTheBucketRevision(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-door-keys-advance") |
| + first := mintDoorKey(t, fx, token) |
| + if rec := readDoorRecord(t, fx); rec.Revision != 1 { |
| + t.Fatalf("first publish at revision %d", rec.Revision) |
| + } |
| + putDoorObject(t, fx, signedDoorRecord(t, fx, 7, nil)) |
| + second := mintDoorKey(t, fx, token) |
| + rec := readDoorRecord(t, fx) |
| + if rec.Revision != 8 { |
| + t.Fatalf("published revision %d over a bucket at 7, want 8", rec.Revision) |
| + } |
| + if !rec.Admits(first) || !rec.Admits(second) || len(rec.Keys) != 2 { |
| + t.Fatalf("advanced record lost the database's keys: %+v", rec.Keys) |
| + } |
| + if _, rev, err := fx.st.DoorKeysLive(OpsAccountID, "titogo"); err != nil || rev != 8 { |
| + t.Fatalf("database counter = %d %v, want 8", rev, err) |
| + } |
| + // An equal revision is the renewal case: republished as it is. |
| + acct, _ := fx.st.AccountByID(OpsAccountID) |
| + if err := fx.srv.publishDoorKeys(context.Background(), acct, "titogo"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec := readDoorRecord(t, fx); rec.Revision != 8 { |
| + t.Fatalf("renewal at an equal revision published %d, want 8", rec.Revision) |
| + } |
| +} |
| + |
| +// Round 2 finding 4: replacing an existing object works, and a tampered |
| +// object is overwritten rather than trusted. |
| +func TestDoorKeyPublishReplacesExistingAndTamperedObjects(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-door-keys-replace") |
| + a := mintDoorKey(t, fx, token) |
| + b := mintDoorKey(t, fx, token) // the second Put replaces the first object |
| + if rec := readDoorRecord(t, fx); rec.Revision != 2 || !rec.Admits(a) || !rec.Admits(b) { |
| + t.Fatalf("after two mints: %+v", rec) |
| + } |
| + putDoorObject(t, fx, []byte("not a door-key record")) |
| + if code, _, out := rawReq(t, http.MethodPost, doorKeysURL(fx)+"/"+a.ID+"/revoke", token, ""); code != http.StatusOK { |
| + t.Fatalf("revoke over a tampered object = %d %s", code, out) |
| + } |
| + rec := readDoorRecord(t, fx) |
| + if rec.Revision != 3 || rec.Admits(a) || !rec.Admits(b) { |
| + t.Fatalf("after revoking over garbage: revision %d, keys %+v", rec.Revision, rec.Keys) |
| + } |
| + // A validly shaped record signed by some other key is tampering too. |
| + other := newConsoleFixture(t) // a different signing key |
| + now := time.Now().UTC() |
| + foreign, _ := doorkey.Sign(other.priv, doorkey.Record{V: 1, Account: "bab", App: "titogo", Revision: 50, |
| + SignedAt: now, ExpiresAt: now.Add(time.Hour)}) |
| + putDoorObject(t, fx, foreign) |
| + acct, _ := fx.st.AccountByID(OpsAccountID) |
| + if err := fx.srv.publishDoorKeys(context.Background(), acct, "titogo"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec := readDoorRecord(t, fx); rec.Revision != 3 || !rec.Admits(b) { |
| + t.Fatalf("a foreign-signed revision was trusted: revision %d", rec.Revision) |
| + } |
| +} |
| + |
| +// casConflictStore loses the first n Puts to one key. |
| +type casConflictStore struct { |
| + release.Store |
| + key string |
| + mu sync.Mutex |
| + lose int |
| + puts int |
| +} |
| + |
| +func (c *casConflictStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if key == c.key { |
| + c.mu.Lock() |
| + c.puts++ |
| + lose := c.puts <= c.lose |
| + c.mu.Unlock() |
| + if lose { |
| + return "", release.ErrCASConflict |
| + } |
| + } |
| + return c.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +// Step 5: a lost compare-and-swap rebuilds and retries, at most three times. |
| +func TestDoorKeyPublishRetriesAConflictAtMostThreeTimes(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-door-keys-cas") |
| + mintDoorKey(t, fx, token) |
| + acct, _ := fx.st.AccountByID(OpsAccountID) |
| + |
| + cs := &casConflictStore{Store: fx.rel, key: doorkey.RecordKey("bab", "titogo"), lose: 2} |
| + fx.srv.cfg.Releases = cs |
| + if err := fx.srv.publishDoorKeys(context.Background(), acct, "titogo"); err != nil { |
| + t.Fatalf("two conflicts then a win: %v", err) |
| + } |
| + if cs.puts != 3 { |
| + t.Fatalf("%d puts, want 3", cs.puts) |
| + } |
| + |
| + cs = &casConflictStore{Store: fx.rel, key: doorkey.RecordKey("bab", "titogo"), lose: 100} |
| + fx.srv.cfg.Releases = cs |
| + if err := fx.srv.publishDoorKeys(context.Background(), acct, "titogo"); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("persistent conflict = %v, want ErrCASConflict", err) |
| + } |
| + if cs.puts != 3 { |
| + t.Fatalf("%d puts under a persistent conflict, want 3", cs.puts) |
| + } |
| +} |
| + |
| +// The re-sign pass covers every app with a revision row. |
| +func TestDoorKeyResignPassCoversEveryApp(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-door-keys-pass") |
| + mintDoorKey(t, fx, token) |
| + apps, err := fx.st.DoorKeyApps() |
| + if err != nil || len(apps) != 1 || apps[0].App != "titogo" || apps[0].Sqid != "bab" || apps[0].AccountID != OpsAccountID { |
| + t.Fatalf("DoorKeyApps = %+v %v", apps, err) |
| + } |
| + before := readDoorRecord(t, fx) |
| + time.Sleep(2 * time.Millisecond) |
| + fx.srv.resignDoorKeys(context.Background()) |
| + if after := readDoorRecord(t, fx); !after.ExpiresAt.After(before.ExpiresAt) { |
| + t.Fatal("the re-sign pass did not renew the record") |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 2c77fb0c0..9fdd2e11c 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -635,6 +635,11 @@ func (s *Server) registerCLIRoutes() { |
| // credential gets here from 401 to 403. Pinned by |
| // TestNoRoleReachesAMintFleetOrPolicyDoor and TestAdminIsRefusedAtAClosedDoor. |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/instance-token", svcrole.Set{}, s.handleAPIInstanceToken) |
| + // Door keys (#175 P7): person-only, the instance-token rule. A door key |
| + // reaches every instance of the app through every box's edge-local door. |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/door-keys", svcrole.Set{}, s.handleAPIDoorKeyList) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/door-keys", svcrole.Set{}, s.handleAPIDoorKeyMint) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/door-keys/{id}/revoke", svcrole.Set{}, s.handleAPIDoorKeyRevoke) |
| // The sidecar API (apisidecars.go) — the only door onto sidecars/ from |
| // outside a box, sharing the instance family's gate exactly |
| // (instanceGate) and, since the role gate landed, its Allow sets too: |
diff --git a/internal/console/doorkeys_store.go b/internal/console/doorkeys_store.go| new file mode 100644 |
| index 000000000..9b00ccee8 |
| --- /dev/null |
| +++ b/internal/console/doorkeys_store.go |
| @@ -0,0 +1,215 @@ |
| +package console |
| + |
| +// Door keys, storage half (#175 P7). The console's database is the |
| +// authority for which door keys an app has; the bucket only ever holds a |
| +// signed projection of these rows (apidoorkeys.go). Only a key's sha256 is |
| +// stored, never its secret. |
| +// |
| +// Each app has a revision counter, bumped in the same transaction as every |
| +// insert and revoke, so a published record's revision always names exactly |
| +// one key set. DoorKeyAdvanceRevision moves it forward past a revision the |
| +// bucket already carries, which keeps revisions monotonic across a restored |
| +// database. |
| + |
| +import ( |
| + "database/sql" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/doorkey" |
| +) |
| + |
| +// errDoorKeysFull is DoorKeyInsert's refusal when the app already has |
| +// doorkey.MaxKeys live keys. |
| +var errDoorKeysFull = errors.New("console: door keys full") |
| + |
| +// migrateDoorKeys creates the door-key tables. Additive and idempotent, |
| +// called from Store.migrate. |
| +func (s *Store) migrateDoorKeys() error { |
| + for _, stmt := range []string{ |
| + `CREATE TABLE IF NOT EXISTS door_keys ( |
| + account_id INTEGER NOT NULL, |
| + app TEXT NOT NULL, |
| + id TEXT NOT NULL, |
| + sha256 TEXT NOT NULL, |
| + label TEXT NOT NULL DEFAULT '', |
| + created_at INTEGER NOT NULL, |
| + revoked_at INTEGER NOT NULL DEFAULT 0, |
| + PRIMARY KEY (account_id, app, id) |
| +);`, |
| + `CREATE TABLE IF NOT EXISTS door_key_revisions ( |
| + account_id INTEGER NOT NULL, |
| + app TEXT NOT NULL, |
| + revision INTEGER NOT NULL, |
| + PRIMARY KEY (account_id, app) |
| +);`, |
| + } { |
| + if _, err := s.db.Exec(stmt); err != nil { |
| + return err |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// bumpDoorKeyRevision adds one to the app's counter inside tx, creating the |
| +// row at 1, and returns the new revision. |
| +func bumpDoorKeyRevision(tx *sql.Tx, accountID int64, app string) (uint64, error) { |
| + if _, err := tx.Exec(`INSERT INTO door_key_revisions (account_id, app, revision) VALUES (?, ?, 1) |
| +ON CONFLICT (account_id, app) DO UPDATE SET revision = revision + 1`, accountID, app); err != nil { |
| + return 0, err |
| + } |
| + var rev uint64 |
| + err := tx.QueryRow(`SELECT revision FROM door_key_revisions WHERE account_id = ? AND app = ?`, |
| + accountID, app).Scan(&rev) |
| + return rev, err |
| +} |
| + |
| +// DoorKeyInsert stores a newly minted key's digest and bumps the app's |
| +// revision in the same transaction. It refuses with errDoorKeysFull when the |
| +// app already has doorkey.MaxKeys live keys. |
| +func (s *Store) DoorKeyInsert(accountID int64, app string, e doorkey.Entry) (uint64, error) { |
| + tx, err := s.db.Begin() |
| + if err != nil { |
| + return 0, err |
| + } |
| + defer tx.Rollback() |
| + var live int |
| + if err := tx.QueryRow(`SELECT COUNT(*) FROM door_keys WHERE account_id = ? AND app = ? AND revoked_at = 0`, |
| + accountID, app).Scan(&live); err != nil { |
| + return 0, err |
| + } |
| + if live >= doorkey.MaxKeys { |
| + return 0, errDoorKeysFull |
| + } |
| + created := e.CreatedAt |
| + if created.IsZero() { |
| + created = time.Now() |
| + } |
| + if _, err := tx.Exec(`INSERT INTO door_keys (account_id, app, id, sha256, label, created_at) |
| +VALUES (?, ?, ?, ?, ?, ?)`, accountID, app, e.ID, e.SHA256, e.Label, created.UnixNano()); err != nil { |
| + return 0, err |
| + } |
| + rev, err := bumpDoorKeyRevision(tx, accountID, app) |
| + if err != nil { |
| + return 0, err |
| + } |
| + return rev, tx.Commit() |
| +} |
| + |
| +// DoorKeyRevoke marks one live key revoked and bumps the app's revision in |
| +// the same transaction. An unknown or already revoked id is os.ErrNotExist. |
| +func (s *Store) DoorKeyRevoke(accountID int64, app, id string) (uint64, error) { |
| + tx, err := s.db.Begin() |
| + if err != nil { |
| + return 0, err |
| + } |
| + defer tx.Rollback() |
| + res, err := tx.Exec(`UPDATE door_keys SET revoked_at = ? |
| +WHERE account_id = ? AND app = ? AND id = ? AND revoked_at = 0`, time.Now().UnixNano(), accountID, app, id) |
| + if err != nil { |
| + return 0, err |
| + } |
| + if n, err := res.RowsAffected(); err != nil { |
| + return 0, err |
| + } else if n == 0 { |
| + return 0, os.ErrNotExist |
| + } |
| + rev, err := bumpDoorKeyRevision(tx, accountID, app) |
| + if err != nil { |
| + return 0, err |
| + } |
| + return rev, tx.Commit() |
| +} |
| + |
| +// DoorKeysLive reads the app's live keys and its revision in one |
| +// transaction, so the two always describe the same key set. An app with no |
| +// revision row answers no keys at revision 0. |
| +func (s *Store) DoorKeysLive(accountID int64, app string) ([]doorkey.Entry, uint64, error) { |
| + tx, err := s.db.Begin() |
| + if err != nil { |
| + return nil, 0, err |
| + } |
| + defer tx.Rollback() |
| + var rev uint64 |
| + err = tx.QueryRow(`SELECT revision FROM door_key_revisions WHERE account_id = ? AND app = ?`, |
| + accountID, app).Scan(&rev) |
| + if err != nil && !errors.Is(err, sql.ErrNoRows) { |
| + return nil, 0, err |
| + } |
| + rows, err := tx.Query(`SELECT id, sha256, label, created_at FROM door_keys |
| +WHERE account_id = ? AND app = ? AND revoked_at = 0 ORDER BY created_at, id`, accountID, app) |
| + if err != nil { |
| + return nil, 0, err |
| + } |
| + defer rows.Close() |
| + keys := []doorkey.Entry{} |
| + for rows.Next() { |
| + var e doorkey.Entry |
| + var created int64 |
| + if err := rows.Scan(&e.ID, &e.SHA256, &e.Label, &created); err != nil { |
| + return nil, 0, err |
| + } |
| + e.CreatedAt = time.Unix(0, created).UTC() |
| + keys = append(keys, e) |
| + } |
| + if err := rows.Err(); err != nil { |
| + return nil, 0, err |
| + } |
| + return keys, rev, tx.Commit() |
| +} |
| + |
| +// DoorKeyApp is one app the re-sign loop publishes. |
| +type DoorKeyApp struct { |
| + AccountID int64 |
| + Sqid, App string |
| +} |
| + |
| +// DoorKeyApps lists every app with a revision row: every app that has ever |
| +// had a key minted, including one whose keys are now all revoked, because |
| +// its empty record still has to stay fresh. |
| +func (s *Store) DoorKeyApps() ([]DoorKeyApp, error) { |
| + rows, err := s.db.Query(`SELECT r.account_id, a.sqid, r.app FROM door_key_revisions r |
| +JOIN accounts a ON a.id = r.account_id ORDER BY a.sqid, r.app`) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []DoorKeyApp |
| + for rows.Next() { |
| + var d DoorKeyApp |
| + if err := rows.Scan(&d.AccountID, &d.Sqid, &d.App); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, d) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// DoorKeyAdvanceRevision sets the app's revision to past+1 when it is below |
| +// past, and returns the resulting revision. The publisher calls it when the |
| +// bucket carries a verified revision above the database's (a restore, or a |
| +// second writer), so the next record it signs is above anything a box may |
| +// already have admitted. |
| +func (s *Store) DoorKeyAdvanceRevision(accountID int64, app string, past uint64) (uint64, error) { |
| + if past >= 1<<63-1 { |
| + return 0, fmt.Errorf("console: door-key revision %d out of range", past) |
| + } |
| + tx, err := s.db.Begin() |
| + if err != nil { |
| + return 0, err |
| + } |
| + defer tx.Rollback() |
| + if _, err := tx.Exec(`INSERT INTO door_key_revisions (account_id, app, revision) VALUES (?, ?, ?) |
| +ON CONFLICT (account_id, app) DO UPDATE SET revision = excluded.revision WHERE revision < ?`, |
| + accountID, app, int64(past+1), int64(past)); err != nil { |
| + return 0, err |
| + } |
| + var rev uint64 |
| + if err := tx.QueryRow(`SELECT revision FROM door_key_revisions WHERE account_id = ? AND app = ?`, |
| + accountID, app).Scan(&rev); err != nil { |
| + return 0, err |
| + } |
| + return rev, tx.Commit() |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 581b63162..db0eea533 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -730,6 +730,12 @@ type Server struct { |
| // door serialises these verbs per app" (objstore/walk.go:560) — and until |
| // #381 nothing did. |
| storeWalks *storeWalkLocks |
| + // doorPublish is the door-key publication lock per app, keyed |
| + // "<sqid>/<app>" (apidoorkeys.go): one publisher per app at a time, so a |
| + // renewal can never land an older key set after a revoke. Built lazily |
| + // under doorPublishMu, so the zero Server needs no setup. |
| + doorPublishMu sync.Mutex |
| + doorPublish map[string]*sync.Mutex |
| // sendApp is how the alert watcher (alertwatch.go) delivers keymail: |
| // km.SendApp in production, a capture in tests. A field rather than a |
| // direct call so an alertPass assertion needs no keymail server. |
diff --git a/internal/console/store.go b/internal/console/store.go| index 0b4f3f3c3..964b0884c 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -412,7 +412,11 @@ func (s *Store) migrate() error { |
| return err |
| } |
| // Pending-change quorum's own tables (pending.go), for the same reason. |
| - return s.migratePendingChanges() |
| + if err := s.migratePendingChanges(); err != nil { |
| + return err |
| + } |
| + // Door keys' own tables (doorkeys_store.go), for the same reason. |
| + return s.migrateDoorKeys() |
| } |
| // mailCountsBackfillVersion is the user_version this migration stamps. The |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index 44765af64..ced377726 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -72,7 +72,7 @@ import ( |
| // authority — it is the end of the boundary the roles are drawn inside. One |
| // substring covers all seven doors, and no role may ever reach any of them. |
| var ( |
| - mintDoors = []string{"/instance-token", "/api/cli/services/", "/email/credentials"} |
| + mintDoors = []string{"/instance-token", "/door-keys", "/api/cli/services/", "/email/credentials"} |
| fleetDoors = []string{"/api/cli/fleets/"} |
| policyDoors = []string{"/instances-manifest", "/{app}/routes", "/email/pause", "/build-probe"} |
| systemDoors = []string{"/api/cli/system/"} |
| @@ -98,6 +98,12 @@ var closedDoors = []string{ |
| // A credential may never mint a credential, including itself. This is |
| // the escalation cut of spec §3. |
| "POST /api/cli/apps/{account}/{app}/instance-token", |
| + // Door keys (#175 P7) are credentials too: minting one is the same cut, |
| + // and listing and revoking them are closed with it, as the services |
| + // family's are. |
| + "GET /api/cli/apps/{account}/{app}/door-keys", |
| + "POST /api/cli/apps/{account}/{app}/door-keys", |
| + "POST /api/cli/apps/{account}/{app}/door-keys/{id}/revoke", |
| // Enabling instances writes the MANIFEST — standing policy about which |
| // domain, pool, regions and channel the app may ever be brought up on. |
| // A credential the app itself holds must not rewrite the bounds it runs |
| @@ -794,8 +800,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) { |
| } |
| seen++ |
| } |
| - if seen != len(closedDoors) || seen != 31 { |
| - t.Errorf("walked %d closed doors, want all %d (and 31 today: instance-token, the routes door, instances-manifest, sidecars-manifest, the route-channel move, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, five system doors, two deployment-wide antivirus, and four store-entitlement doors)", |
| + if seen != len(closedDoors) || seen != 34 { |
| + t.Errorf("walked %d closed doors, want all %d (and 34 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, five system doors, two deployment-wide antivirus, and four store-entitlement doors)", |
| seen, len(closedDoors)) |
| } |
| } |