carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3// Door keys (#175 P7): the credential an app's own processes present at a
4// box's edge-local door. The console's database is the authority
5// (doorkeys_store.go). The bucket holds a projection of it at
6// doorkey.RecordKey, signed with the deployment's root release key, carrying
7// a revision and a 72-hour expiry, and re-signed every 12 hours.
8//
9// Exactly one code path writes that projection: publishDoorKeys. It always
10// rebuilds from the database and never reads a key back out of the bucket,
11// so a record planted or copied there by a bucket writer is overwritten, not
12// extended (Astra round 1, finding 1).
13
14import (
15 "context"
16 "crypto/ed25519"
17 "errors"
18 "fmt"
19 "log"
20 "net/http"
21 "os"
22 "sync"
23 "time"
24
25 "github.com/carlosframework/platform/internal/doorkey"
26 "github.com/carlosframework/platform/internal/release"
27)
28
29const (
30 // doorKeyResignEvery is how often every record is re-signed with a fresh
31 // expiry. A record lives doorkey.Lifetime (72 h), so a console that is
32 // down for up to 60 hours past its last re-sign leaves no door refusing.
33 doorKeyResignEvery = 12 * time.Hour
34 // doorKeyPublishAttempts bounds publishDoorKeys' compare-and-swap loop.
35 doorKeyPublishAttempts = 3
36 // doorKeyPublishBudget bounds one publication started from a request or
37 // from the re-sign pass.
38 doorKeyPublishBudget = 20 * time.Second
39)
40
41// Copy approved in review (plan Task 8), 2026-09-29.
42const (
43 doorKeyReadOnlyMsg = "no signing key on this host. the console is running read-only"
44 doorKeyNoBucketMsg = "no deployment bucket on this host. the console is running without one"
45)
46
47// doorPublishLock is the app's publication mutex, created on first use.
48func (s *Server) doorPublishLock(sqid, app string) *sync.Mutex {
49 s.doorPublishMu.Lock()
50 defer s.doorPublishMu.Unlock()
51 if s.doorPublish == nil {
52 s.doorPublish = map[string]*sync.Mutex{}
53 }
54 k := sqid + "/" + app
55 mu := s.doorPublish[k]
56 if mu == nil {
57 mu = &sync.Mutex{}
58 s.doorPublish[k] = mu
59 }
60 return mu
61}
62
63// publishDoorKeys writes the app's signed record from the database. The
64// protocol (plan Task 2, round 2 findings 4 to 6):
65//
66// 1. Hold the app's publication mutex throughout. A renewal and a revoke
67// for the same app are serialized, and each builds its record from the
68// database inside the lock, so a renewal can never put back a key set
69// older than one already published.
70// 2. Read the bucket's object and its ETag. The bucket's revision is the one
71// a valid signature vouches for (expired or not), and zero for anything
72// absent or unverifiable: a tampered object is overwritten, never
73// trusted.
74// 3. Read the live keys and the revision in one transaction. If the
75// database is below the bucket (a restored database, or a second
76// writer), advance its counter past the bucket first and read again. An
77// equal revision is the ordinary renewal and is re-published as it is.
78// 4. Sign the record with a fresh expiry and Put it against the ETag read in
79// step 2 (create-only when there was no object).
80// 5. A lost compare-and-swap starts again at step 2, rebuilding from the
81// database, never reusing signed bytes. After doorKeyPublishAttempts
82// losses the conflict is returned.
83func (s *Server) publishDoorKeys(ctx context.Context, acct *Account, app string) error {
84 if len(s.cfg.Key) != ed25519.PrivateKeySize {
85 return errors.New("console: door keys: no signing key")
86 }
87 if s.cfg.Releases == nil {
88 return errors.New("console: door keys: no deployment bucket")
89 }
90 pub, _ := s.cfg.Key.Public().(ed25519.PublicKey)
91 mu := s.doorPublishLock(acct.Sqid, app)
92 mu.Lock()
93 defer mu.Unlock()
94 // The record read, the revision advance and the conditional write are
95 // one app mutation, so a foreign operation on the app refuses the whole
96 // publication rather than half of it.
97 return s.appMutation(ctx, acct, app, "door-keys-publish", func(ctx context.Context) error {
98 return s.publishDoorKeysLocked(ctx, acct, app, pub)
99 })
100}
101
102// publishDoorKeysLocked is steps 2 to 5 of publishDoorKeys, run with the
103// app's publication mutex held.
104func (s *Server) publishDoorKeysLocked(ctx context.Context, acct *Account, app string, pub ed25519.PublicKey) error {
105 key := doorkey.RecordKey(acct.Sqid, app)
106 var lost error
107 for attempt := 0; attempt < doorKeyPublishAttempts; attempt++ {
108 wire, etag, err := s.cfg.Releases.Get(ctx, key)
109 var bucketRev uint64
110 switch {
111 case errors.Is(err, os.ErrNotExist):
112 etag = ""
113 case err != nil:
114 return fmt.Errorf("console: door keys %s/%s: read: %w", acct.Sqid, app, err)
115 default:
116 if rev, verr := doorkey.VerifyRevision(pub, wire); verr == nil {
117 bucketRev = rev
118 } else {
119 log.Printf("console: door keys %s/%s: overwriting an unverifiable object: %v", acct.Sqid, app, verr)
120 }
121 }
122
123 keys, rev, err := s.store.DoorKeysLiveContext(ctx, acct.ID, app)
124 if err != nil {
125 return fmt.Errorf("console: door keys %s/%s: %w", acct.Sqid, app, err)
126 }
127 if rev < bucketRev {
128 if _, err := s.store.DoorKeyAdvanceRevisionContext(ctx, acct.ID, app, bucketRev); err != nil {
129 return fmt.Errorf("console: door keys %s/%s: advance past %d: %w", acct.Sqid, app, bucketRev, err)
130 }
131 log.Printf("console: door keys %s/%s: database revision %d was below the bucket's %d; advanced past it",
132 acct.Sqid, app, rev, bucketRev)
133 if keys, rev, err = s.store.DoorKeysLiveContext(ctx, acct.ID, app); err != nil {
134 return fmt.Errorf("console: door keys %s/%s: %w", acct.Sqid, app, err)
135 }
136 }
137 if rev == 0 {
138 return nil // never minted, and nothing verifiable in the bucket: nothing to publish
139 }
140
141 now := time.Now().UTC()
142 body, err := doorkey.Sign(s.cfg.Key, doorkey.Record{V: 1, Account: acct.Sqid, App: app,
143 Revision: rev, Keys: keys, SignedAt: now, ExpiresAt: now.Add(doorkey.Lifetime)})
144 if err != nil {
145 return fmt.Errorf("console: door keys %s/%s: sign: %w", acct.Sqid, app, err)
146 }
147 _, err = s.cfg.Releases.Put(ctx, key, body, etag)
148 if err == nil {
149 return nil
150 }
151 if !errors.Is(err, release.ErrCASConflict) {
152 return fmt.Errorf("console: door keys %s/%s: write: %w", acct.Sqid, app, err)
153 }
154 lost = err
155 }
156 return fmt.Errorf("console: door keys %s/%s: %d attempts: %w", acct.Sqid, app, doorKeyPublishAttempts, lost)
157}
158
159// publishDoorKeysDetached runs publishDoorKeys for a request whose database
160// write has already landed: a client that hangs up must not cancel the
161// publication of a change that is already the truth.
162func (s *Server) publishDoorKeysDetached(r *http.Request, acct *Account, app string) error {
163 ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), doorKeyPublishBudget)
164 defer cancel()
165 return s.publishDoorKeys(ctx, acct, app)
166}
167
168// republishDoorKeys is publishDoorKeysDetached for a change made elsewhere
169// that revoked an app name's keys in passing (purge, move): best-effort,
170// because that change has already landed and is not undone by a publish
171// that fails. The re-sign pass publishes within 12 hours regardless. A
172// console with nothing to sign with or nowhere to write skips it.
173func (s *Server) republishDoorKeys(r *http.Request, acct *Account, app string) {
174 if len(s.cfg.Key) != ed25519.PrivateKeySize || s.cfg.Releases == nil {
175 return
176 }
177 if err := s.publishDoorKeysDetached(r, acct, app); err != nil {
178 log.Printf("console: door keys %s/%s: publish after revoking (the re-sign pass retries): %v", acct.Sqid, app, err)
179 }
180}
181
182// doorKeyWritable answers the 503 a console that cannot publish gives, before
183// anything is written.
184func (s *Server) doorKeyWritable(w http.ResponseWriter) bool {
185 if len(s.cfg.Key) == 0 {
186 jsonError(w, http.StatusServiceUnavailable, doorKeyReadOnlyMsg)
187 return false
188 }
189 if s.cfg.Releases == nil {
190 jsonError(w, http.StatusServiceUnavailable, doorKeyNoBucketMsg)
191 return false
192 }
193 return true
194}
195
196// handleAPIDoorKeyMint mints one door key for the app. Person-only, the
197// instance-token rule: a credential may never mint a credential. The zero
198// Set at the registration refuses every service role (authcli.go), and
199// apiPerson is the inner wall.
200//
201// The key is shown exactly once. Only its sha256 is stored.
202func (s *Server) handleAPIDoorKeyMint(w http.ResponseWriter, r *http.Request) {
203 if !s.apiPerson(w, r) {
204 return
205 }
206 addr, acct, app, ok := s.apiAccountApp(w, r)
207 if !ok {
208 return
209 }
210 p, _ := principalFrom(r.Context())
211 if !s.allowWrite(p, time.Now()) {
212 jsonError(w, http.StatusTooManyRequests, "too many requests. wait a minute and try again")
213 return
214 }
215 if !s.doorKeyWritable(w) {
216 return
217 }
218 var req struct {
219 Label string `json:"label"`
220 }
221 if !readJSON(w, r, &req) {
222 return
223 }
224 raw, entry, err := doorkey.NewToken(acct.Sqid, app)
225 if err != nil {
226 log.Printf("console: door key mint %s/%s: %v", acct.Sqid, app, err)
227 jsonError(w, http.StatusInternalServerError, "could not mint the door key. try again")
228 return
229 }
230 entry.Label = cleanLabel(req.Label)
231 if _, err := s.store.DoorKeyInsertContext(r.Context(), acct.ID, app, entry); err != nil {
232 if errors.Is(err, errDoorKeysFull) {
233 jsonError(w, http.StatusConflict, fmt.Sprintf(
234 "this app already has %d door keys. revoke one before you mint another", doorkey.MaxKeys))
235 return
236 }
237 log.Printf("console: door key mint %s/%s: %v", acct.Sqid, app, err)
238 jsonError(w, http.StatusInternalServerError, "could not mint the door key. try again")
239 return
240 }
241 if err := s.publishDoorKeysDetached(r, acct, app); err != nil {
242 // The secret is about to be lost (it is never shown), so the row it
243 // hashes is revoked rather than left holding one of the app's slots
244 // until the next re-sign publishes a key nobody has.
245 log.Printf("console: door key mint %s/%s: publish: %v", acct.Sqid, app, err)
246 if _, rerr := s.store.DoorKeyRevokeContext(r.Context(), acct.ID, app, entry.ID); rerr != nil {
247 log.Printf("console: door key mint %s/%s: withdraw unpublished key %s: %v", acct.Sqid, app, entry.ID, rerr)
248 }
249 jsonError(w, http.StatusBadGateway, "could not publish the door key. try again")
250 return
251 }
252 s.audit(acct, app, addr, "door-key", "minted door key "+entry.ID+" for "+app+" (cli)")
253 s.bumpSerial(r.Context())
254 writeJSON(w, http.StatusCreated, map[string]any{
255 "key": raw, "id": entry.ID, "account": acct.Sqid, "app": app,
256 })
257}
258
259// handleAPIDoorKeyList lists the app's live keys: id, label and when each
260// was minted. Never a digest.
261func (s *Server) handleAPIDoorKeyList(w http.ResponseWriter, r *http.Request) {
262 if !s.apiPerson(w, r) {
263 return
264 }
265 _, acct, app, ok := s.apiAccountApp(w, r)
266 if !ok {
267 return
268 }
269 keys, _, err := s.store.DoorKeysLiveContext(r.Context(), acct.ID, app)
270 if err != nil {
271 log.Printf("console: door key list %s/%s: %v", acct.Sqid, app, err)
272 jsonError(w, http.StatusInternalServerError, "could not list the door keys. try again")
273 return
274 }
275 type listed struct {
276 ID string `json:"id"`
277 Label string `json:"label"`
278 CreatedAt time.Time `json:"created_at"`
279 }
280 out := make([]listed, 0, len(keys))
281 for _, k := range keys {
282 out = append(out, listed{ID: k.ID, Label: k.Label, CreatedAt: k.CreatedAt})
283 }
284 writeJSON(w, http.StatusOK, map[string]any{"keys": out})
285}
286
287// handleAPIDoorKeyRevoke revokes one live key and publishes the record
288// without it.
289func (s *Server) handleAPIDoorKeyRevoke(w http.ResponseWriter, r *http.Request) {
290 if !s.apiPerson(w, r) {
291 return
292 }
293 addr, acct, app, ok := s.apiAccountApp(w, r)
294 if !ok {
295 return
296 }
297 p, _ := principalFrom(r.Context())
298 if !s.allowWrite(p, time.Now()) {
299 jsonError(w, http.StatusTooManyRequests, "too many requests. wait a minute and try again")
300 return
301 }
302 if !s.doorKeyWritable(w) {
303 return
304 }
305 id := r.PathValue("id")
306 if _, err := s.store.DoorKeyRevokeContext(r.Context(), acct.ID, app, id); err != nil {
307 if errors.Is(err, os.ErrNotExist) {
308 jsonError(w, http.StatusNotFound, "no live door key with that id")
309 return
310 }
311 log.Printf("console: door key revoke %s/%s %s: %v", acct.Sqid, app, id, err)
312 jsonError(w, http.StatusInternalServerError, "could not revoke the door key. try again")
313 return
314 }
315 s.audit(acct, app, addr, "door-key-revoke", "revoked door key "+id+" for "+app+" (cli)")
316 if err := s.publishDoorKeysDetached(r, acct, app); err != nil {
317 // The database is the truth and the key is revoked there; the
318 // re-sign pass publishes it on its next run.
319 log.Printf("console: door key revoke %s/%s %s: publish: %v", acct.Sqid, app, id, err)
320 jsonError(w, http.StatusBadGateway,
321 "the door key is revoked but not yet published. boxes keep admitting it until the console publishes again, within 12 hours")
322 return
323 }
324 s.bumpSerial(r.Context())
325 writeJSON(w, http.StatusOK, map[string]any{"revoked": id})
326}
327
328// resignDoorKeys re-publishes every app's record with a fresh expiry. A
329// failure for one app is logged and does not stop the others.
330func (s *Server) resignDoorKeys(ctx context.Context) {
331 apps, err := s.store.DoorKeyApps()
332 if err != nil {
333 log.Printf("console: door keys re-sign: %v", err)
334 return
335 }
336 for _, a := range apps {
337 pctx, cancel := context.WithTimeout(ctx, doorKeyPublishBudget)
338 err := s.publishDoorKeys(pctx, &Account{ID: a.AccountID, Sqid: a.Sqid}, a.App)
339 cancel()
340 if err != nil {
341 log.Printf("console: door keys re-sign %s/%s: %v", a.Sqid, a.App, err)
342 }
343 }
344}
345
346// StartDoorKeyResign re-signs every door-key record now and then every 12
347// hours until ctx is done. Started from cmd/console beside the other passes,
348// and only on a console that holds the signing key: a read-only console has
349// nothing to sign with.
350func (s *Server) StartDoorKeyResign(ctx context.Context) {
351 if len(s.cfg.Key) == 0 || s.cfg.Releases == nil {
352 return
353 }
354 t := time.NewTicker(doorKeyResignEvery)
355 defer t.Stop()
356 for {
357 s.resignDoorKeys(ctx)
358 select {
359 case <-ctx.Done():
360 return
361 case <-t.C:
362 }
363 }
364}
365