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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user