All import paths updated from git.wntrmute.dev/kyle/mcias to git.wntrmute.dev/mc/mcias to match the Gitea organization. Includes main module and clients/go submodule. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
103 lines
3.2 KiB
Go
103 lines
3.2 KiB
Go
// Vault seal/unseal REST handlers for MCIAS.
|
|
package server
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"git.wntrmute.dev/mc/mcias/internal/audit"
|
|
"git.wntrmute.dev/mc/mcias/internal/middleware"
|
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
|
"git.wntrmute.dev/mc/mcias/internal/vault"
|
|
)
|
|
|
|
// unsealRequest is the request body for POST /v1/vault/unseal.
|
|
type unsealRequest struct {
|
|
Passphrase string `json:"passphrase"`
|
|
}
|
|
|
|
// handleUnseal accepts a passphrase, derives the master key, decrypts the
|
|
// signing key, and unseals the vault. Rate-limited to 3/s burst 5.
|
|
//
|
|
// Security: The passphrase is never logged. A generic error is returned on
|
|
// any failure to prevent information leakage about the vault state.
|
|
func (s *Server) handleUnseal(w http.ResponseWriter, r *http.Request) {
|
|
if !s.vault.IsSealed() {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "already unsealed"})
|
|
return
|
|
}
|
|
|
|
var req unsealRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if req.Passphrase == "" {
|
|
middleware.WriteError(w, http.StatusBadRequest, "passphrase is required", "bad_request")
|
|
return
|
|
}
|
|
|
|
// Derive master key from passphrase.
|
|
masterKey, err := vault.DeriveFromPassphrase(req.Passphrase, s.db)
|
|
if err != nil {
|
|
s.logger.Error("vault unseal: derive key", "error", err)
|
|
middleware.WriteError(w, http.StatusUnauthorized, "unseal failed", "unauthorized")
|
|
return
|
|
}
|
|
|
|
// Decrypt the signing key.
|
|
privKey, pubKey, err := vault.DecryptSigningKey(s.db, masterKey)
|
|
if err != nil {
|
|
// Zero derived master key on failure.
|
|
for i := range masterKey {
|
|
masterKey[i] = 0
|
|
}
|
|
s.logger.Error("vault unseal: decrypt signing key", "error", err)
|
|
middleware.WriteError(w, http.StatusUnauthorized, "unseal failed", "unauthorized")
|
|
return
|
|
}
|
|
|
|
if err := s.vault.Unseal(masterKey, privKey, pubKey); err != nil {
|
|
s.logger.Error("vault unseal: state transition", "error", err)
|
|
middleware.WriteError(w, http.StatusConflict, "vault is already unsealed", "conflict")
|
|
return
|
|
}
|
|
|
|
ip := middleware.ClientIP(r, nil)
|
|
s.writeAudit(r, model.EventVaultUnsealed, nil, nil, audit.JSON("source", "api", "ip", ip))
|
|
s.logger.Info("vault unsealed via API", "ip", ip)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "unsealed"})
|
|
}
|
|
|
|
// handleSeal seals the vault, zeroing all key material. Admin-only.
|
|
//
|
|
// Security: The caller's token becomes invalid after sealing because the
|
|
// public key needed to validate it is no longer available.
|
|
func (s *Server) handleSeal(w http.ResponseWriter, r *http.Request) {
|
|
if s.vault.IsSealed() {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "already sealed"})
|
|
return
|
|
}
|
|
|
|
claims := middleware.ClaimsFromContext(r.Context())
|
|
var actorID *int64
|
|
if claims != nil {
|
|
acct, err := s.db.GetAccountByUUID(claims.Subject)
|
|
if err == nil {
|
|
actorID = &acct.ID
|
|
}
|
|
}
|
|
|
|
s.vault.Seal()
|
|
|
|
ip := middleware.ClientIP(r, nil)
|
|
s.writeAudit(r, model.EventVaultSealed, actorID, nil, audit.JSON("source", "api", "ip", ip))
|
|
s.logger.Info("vault sealed via API", "ip", ip)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "sealed"})
|
|
}
|
|
|
|
// handleVaultStatus returns the current seal state of the vault.
|
|
func (s *Server) handleVaultStatus(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]bool{"sealed": s.vault.IsSealed()})
|
|
}
|