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 ) // Copy approved in review (plan Task 8), 2026-09-29. 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() // The record read, the revision advance and the conditional write are // one app mutation, so a foreign operation on the app refuses the whole // publication rather than half of it. return s.appMutation(ctx, acct, app, "door-keys-publish", func(ctx context.Context) error { return s.publishDoorKeysLocked(ctx, acct, app, pub) }) } // publishDoorKeysLocked is steps 2 to 5 of publishDoorKeys, run with the // app's publication mutex held. func (s *Server) publishDoorKeysLocked(ctx context.Context, acct *Account, app string, pub ed25519.PublicKey) error { 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.DoorKeysLiveContext(ctx, 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.DoorKeyAdvanceRevisionContext(ctx, 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.DoorKeysLiveContext(ctx, 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) } // republishDoorKeys is publishDoorKeysDetached for a change made elsewhere // that revoked an app name's keys in passing (purge, move): best-effort, // because that change has already landed and is not undone by a publish // that fails. The re-sign pass publishes within 12 hours regardless. A // console with nothing to sign with or nowhere to write skips it. func (s *Server) republishDoorKeys(r *http.Request, acct *Account, app string) { if len(s.cfg.Key) != ed25519.PrivateKeySize || s.cfg.Releases == nil { return } if err := s.publishDoorKeysDetached(r, acct, app); err != nil { log.Printf("console: door keys %s/%s: publish after revoking (the re-sign pass retries): %v", acct.Sqid, app, err) } } // 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.DoorKeyInsertContext(r.Context(), 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.DoorKeyRevokeContext(r.Context(), 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.DoorKeysLiveContext(r.Context(), 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.DoorKeyRevokeContext(r.Context(), 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: } } }