Files
mcias/internal/server/vault.go
Kyle Isom 41d01edfb4 Migrate module path from kyle/ to mc/ org
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>
2026-03-27 02:03:46 -07:00

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()})
}