Add user-to-user encryption engine with ECDH key exchange and AES-256-GCM

Implements the complete user engine for multi-recipient envelope encryption:
- ECDH key agreement (X25519, P-256, P-384) with HKDF-derived wrapping keys
- Per-message random DEK wrapped individually for each recipient
- 9 operations: register, provision, get-public-key, list-users, encrypt,
  decrypt, re-encrypt, rotate-key, delete-user
- Auto-provisioning of sender and recipients on encrypt
- Role-based authorization (admin-only provision/delete, user-only decrypt)
- gRPC UserService with proto definitions and REST API routes
- 16 comprehensive tests covering lifecycle, crypto roundtrips, multi-recipient,
  key rotation, auth enforcement, and algorithm variants

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 19:44:11 -07:00
parent ac4577f778
commit be3b9d7fe0
10 changed files with 4004 additions and 1 deletions

View File

@@ -45,6 +45,17 @@ func (s *Server) registerRoutes(r chi.Router) {
r.Get("/v1/pki/{mount}/issuer/{name}", s.requireUnseal(s.handlePKIIssuer))
r.Get("/v1/pki/{mount}/issuer/{name}/crl", s.requireUnseal(s.handlePKICRL))
// User-to-user encryption routes (auth required).
r.Post("/v1/user/{mount}/register", s.requireAuth(s.handleUserRegister))
r.Post("/v1/user/{mount}/provision", s.requireAdmin(s.handleUserProvision))
r.Get("/v1/user/{mount}/keys", s.requireAuth(s.handleUserListUsers))
r.Get("/v1/user/{mount}/keys/{username}", s.requireAuth(s.handleUserGetPublicKey))
r.Delete("/v1/user/{mount}/keys/{username}", s.requireAdmin(s.handleUserDeleteUser))
r.Post("/v1/user/{mount}/encrypt", s.requireAuth(s.handleUserEncrypt))
r.Post("/v1/user/{mount}/decrypt", s.requireAuth(s.handleUserDecrypt))
r.Post("/v1/user/{mount}/re-encrypt", s.requireAuth(s.handleUserReEncrypt))
r.Post("/v1/user/{mount}/rotate", s.requireAuth(s.handleUserRotateKey))
r.HandleFunc("/v1/policy/rules", s.requireAuth(s.handlePolicyRules))
r.HandleFunc("/v1/policy/rule", s.requireAuth(s.handlePolicyRule))
s.registerACMERoutes(r)
@@ -608,10 +619,261 @@ func (s *Server) getCAEngine(mountName string) (*ca.CAEngine, error) {
return caEng, nil
}
// --- User-to-User Encryption Handlers ---
func (s *Server) handleUserRegister(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "register",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleUserProvision(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
var req struct {
Username string `json:"username"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.Username == "" {
http.Error(w, `{"error":"username is required"}`, http.StatusBadRequest)
return
}
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "provision",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
Data: map[string]interface{}{"username": req.Username},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleUserListUsers(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "list-users",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleUserGetPublicKey(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
username := chi.URLParam(r, "username")
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "get-public-key",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
Data: map[string]interface{}{"username": username},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleUserDeleteUser(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
username := chi.URLParam(r, "username")
info := TokenInfoFromContext(r.Context())
_, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "delete-user",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
Data: map[string]interface{}{"username": username},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
}
func (s *Server) handleUserEncrypt(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
var req struct {
Plaintext string `json:"plaintext"`
Metadata string `json:"metadata"`
Recipients []string `json:"recipients"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
recipients := make([]interface{}, len(req.Recipients))
for i, r := range req.Recipients {
recipients[i] = r
}
info := TokenInfoFromContext(r.Context())
policyChecker := func(resource, action string) (string, bool) {
pReq := &policy.Request{
Username: info.Username,
Roles: info.Roles,
Resource: resource,
Action: action,
}
eff, matched, pErr := s.policy.Match(r.Context(), pReq)
if pErr != nil {
return string(policy.EffectDeny), false
}
return string(eff), matched
}
data := map[string]interface{}{
"plaintext": req.Plaintext,
"recipients": recipients,
}
if req.Metadata != "" {
data["metadata"] = req.Metadata
}
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "encrypt",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: policyChecker,
Data: data,
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleUserDecrypt(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
var req struct {
Envelope string `json:"envelope"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "decrypt",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
Data: map[string]interface{}{"envelope": req.Envelope},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleUserReEncrypt(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
var req struct {
Envelope string `json:"envelope"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "re-encrypt",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
Data: map[string]interface{}{"envelope": req.Envelope},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleUserRotateKey(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "rotate-key",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) writeEngineError(w http.ResponseWriter, err error) {
status := http.StatusInternalServerError
switch {
case errors.Is(err, engine.ErrMountNotFound):
status = http.StatusNotFound
case strings.Contains(err.Error(), "forbidden"):
status = http.StatusForbidden
case strings.Contains(err.Error(), "authentication required"):
status = http.StatusUnauthorized
case strings.Contains(err.Error(), "not found"):
status = http.StatusNotFound
case strings.Contains(err.Error(), "too many"):
status = http.StatusBadRequest
}
http.Error(w, `{"error":"`+err.Error()+`"}`, status)
}
// operationAction maps an engine operation name to a policy action ("read" or "write").
func operationAction(op string) string {
switch op {
case "list-issuers", "list-certs", "get-cert", "get-root", "get-chain", "get-issuer":
case "list-issuers", "list-certs", "get-cert", "get-root", "get-chain", "get-issuer",
"get-public-key", "list-users":
return "read"
default:
return "write"