R-879: seal box API keys, owner passphrases, controller keys and PBS-DR tokens at rest

hosts.api_key, customer_configs.api_key / retrieval_password and host_pbs_secrets.value now hold the
R-821/R-133 seal (enc:v1:, OFFSITE_SECRET_KEY). The two API keys get an api_key_hash lookup twin
(SHA-256, backfilled keyless in migrate()), so box authentication never needs the sealing key; a row
with no hash is matched on its plaintext only while it is plaintext. SealLegacyBoxSecrets seals legacy
rows at start-up (idempotent, non-fatal). A sealed value that does not open sets SecretsUnreadable:
serve/compare paths answer 500, saves refuse the record, the PBS token is not burned.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-05 21:18:14 +02:00
parent 86def579ed
commit 5f060e3d1e
12 changed files with 781 additions and 27 deletions
+30
View File
@@ -1101,6 +1101,12 @@ func (h *Handler) handleHostEnroll(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Not found", http.StatusNotFound)
return
}
// R-879: a sealed passphrase that did not open is a hub fault (500), never a wrong password.
if cc.SecretsUnreadable {
h.logger.Printf("[ERROR] customer %s: sealed secrets do not open (OFFSITE_SECRET_KEY)", cc.CustomerID)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if subtle.ConstantTimeCompare([]byte(password), []byte(cc.RetrievalPassword)) != 1 {
http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
return
@@ -1114,6 +1120,12 @@ func (h *Handler) handleHostEnroll(w http.ResponseWriter, r *http.Request) {
return
}
if existing != nil {
// R-879: never re-serve an empty key because the seal did not open.
if existing.SecretsUnreadable || existing.APIKey == "" {
h.logger.Printf("[ERROR] host-enroll: host %s key unreadable (sealed key does not open)", existing.HostID)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"host_id": existing.HostID, "api_key": existing.APIKey})
@@ -2570,6 +2582,12 @@ func (h *Handler) handleRecovery(w http.ResponseWriter, r *http.Request, custome
return
}
// R-879: a sealed passphrase that did not open is a hub fault (500), never a wrong password.
if cfg.SecretsUnreadable {
h.logger.Printf("[ERROR] customer %s: sealed secrets do not open (OFFSITE_SECRET_KEY)", cfg.CustomerID)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if subtle.ConstantTimeCompare([]byte(password), []byte(cfg.RetrievalPassword)) != 1 {
http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
return
@@ -2635,6 +2653,12 @@ func (h *Handler) handleConfigRetrieve(w http.ResponseWriter, r *http.Request, c
return
}
// R-879: a sealed passphrase that did not open is a hub fault (500), never a wrong password.
if cfg.SecretsUnreadable {
h.logger.Printf("[ERROR] customer %s: sealed secrets do not open (OFFSITE_SECRET_KEY)", cfg.CustomerID)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
// Constant-time comparison to prevent timing attacks
if subtle.ConstantTimeCompare([]byte(password), []byte(cfg.RetrievalPassword)) != 1 {
http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
@@ -2717,6 +2741,12 @@ func (h *Handler) handleArtifactManifest(w http.ResponseWriter, r *http.Request,
http.Error(w, "Not found", http.StatusNotFound)
return
}
// R-879: a sealed passphrase that did not open is a hub fault (500), never a wrong password.
if cfg.SecretsUnreadable {
h.logger.Printf("[ERROR] customer %s: sealed secrets do not open (OFFSITE_SECRET_KEY)", cfg.CustomerID)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if subtle.ConstantTimeCompare([]byte(password), []byte(cfg.RetrievalPassword)) != 1 {
http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
return