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:
273
internal/grpcserver/user.go
Normal file
273
internal/grpcserver/user.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package grpcserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/engine/user"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/policy"
|
||||
)
|
||||
|
||||
type userServer struct {
|
||||
pb.UnimplementedUserServiceServer
|
||||
s *GRPCServer
|
||||
}
|
||||
|
||||
func (us *userServer) callerInfo(ctx context.Context) *engine.CallerInfo {
|
||||
ti := tokenInfoFromContext(ctx)
|
||||
if ti == nil {
|
||||
return nil
|
||||
}
|
||||
return &engine.CallerInfo{
|
||||
Username: ti.Username,
|
||||
Roles: ti.Roles,
|
||||
IsAdmin: ti.IsAdmin,
|
||||
}
|
||||
}
|
||||
|
||||
func (us *userServer) policyChecker(ctx context.Context) engine.PolicyChecker {
|
||||
caller := us.callerInfo(ctx)
|
||||
if caller == nil {
|
||||
return nil
|
||||
}
|
||||
return func(resource, action string) (string, bool) {
|
||||
pReq := &policy.Request{
|
||||
Username: caller.Username,
|
||||
Roles: caller.Roles,
|
||||
Resource: resource,
|
||||
Action: action,
|
||||
}
|
||||
effect, matched, err := us.s.policy.Match(ctx, pReq)
|
||||
if err != nil {
|
||||
return string(policy.EffectDeny), false
|
||||
}
|
||||
return string(effect), matched
|
||||
}
|
||||
}
|
||||
|
||||
func (us *userServer) handleRequest(ctx context.Context, mount, operation string, req *engine.Request) (*engine.Response, error) {
|
||||
resp, err := us.s.engines.HandleRequest(ctx, mount, req)
|
||||
if err != nil {
|
||||
st := codes.Internal
|
||||
switch {
|
||||
case errors.Is(err, engine.ErrMountNotFound):
|
||||
st = codes.NotFound
|
||||
case errors.Is(err, user.ErrUserNotFound):
|
||||
st = codes.NotFound
|
||||
case errors.Is(err, user.ErrUserExists):
|
||||
st = codes.AlreadyExists
|
||||
case errors.Is(err, user.ErrUnauthorized):
|
||||
st = codes.Unauthenticated
|
||||
case errors.Is(err, user.ErrForbidden):
|
||||
st = codes.PermissionDenied
|
||||
case errors.Is(err, user.ErrTooMany):
|
||||
st = codes.InvalidArgument
|
||||
case errors.Is(err, user.ErrNoRecipients):
|
||||
st = codes.InvalidArgument
|
||||
case errors.Is(err, user.ErrInvalidEnvelope):
|
||||
st = codes.InvalidArgument
|
||||
case errors.Is(err, user.ErrRecipientNotFound):
|
||||
st = codes.NotFound
|
||||
case errors.Is(err, user.ErrDecryptionFailed):
|
||||
st = codes.InvalidArgument
|
||||
case strings.Contains(err.Error(), "forbidden"):
|
||||
st = codes.PermissionDenied
|
||||
case strings.Contains(err.Error(), "not found"):
|
||||
st = codes.NotFound
|
||||
}
|
||||
us.s.logger.Error("grpc: user "+operation, "mount", mount, "error", err)
|
||||
return nil, status.Error(st, err.Error())
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (us *userServer) Register(ctx context.Context, req *pb.UserRegisterRequest) (*pb.UserRegisterResponse, error) {
|
||||
if req.Mount == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "mount is required")
|
||||
}
|
||||
resp, err := us.handleRequest(ctx, req.Mount, "register", &engine.Request{
|
||||
Operation: "register",
|
||||
CallerInfo: us.callerInfo(ctx),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
username, _ := resp.Data["username"].(string)
|
||||
pubKey, _ := resp.Data["public_key"].(string)
|
||||
algorithm, _ := resp.Data["algorithm"].(string)
|
||||
us.s.logger.Info("audit: user registered", "mount", req.Mount, "username", username)
|
||||
return &pb.UserRegisterResponse{Username: username, PublicKey: pubKey, Algorithm: algorithm}, nil
|
||||
}
|
||||
|
||||
func (us *userServer) Provision(ctx context.Context, req *pb.UserProvisionRequest) (*pb.UserProvisionResponse, error) {
|
||||
if req.Mount == "" || req.Username == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "mount and username are required")
|
||||
}
|
||||
resp, err := us.handleRequest(ctx, req.Mount, "provision", &engine.Request{
|
||||
Operation: "provision",
|
||||
CallerInfo: us.callerInfo(ctx),
|
||||
Data: map[string]interface{}{"username": req.Username},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
username, _ := resp.Data["username"].(string)
|
||||
pubKey, _ := resp.Data["public_key"].(string)
|
||||
algorithm, _ := resp.Data["algorithm"].(string)
|
||||
us.s.logger.Info("audit: user provisioned", "mount", req.Mount, "username", username, "by", callerUsername(ctx))
|
||||
return &pb.UserProvisionResponse{Username: username, PublicKey: pubKey, Algorithm: algorithm}, nil
|
||||
}
|
||||
|
||||
func (us *userServer) GetPublicKey(ctx context.Context, req *pb.UserGetPublicKeyRequest) (*pb.UserGetPublicKeyResponse, error) {
|
||||
if req.Mount == "" || req.Username == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "mount and username are required")
|
||||
}
|
||||
resp, err := us.handleRequest(ctx, req.Mount, "get-public-key", &engine.Request{
|
||||
Operation: "get-public-key",
|
||||
CallerInfo: us.callerInfo(ctx),
|
||||
Data: map[string]interface{}{"username": req.Username},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
username, _ := resp.Data["username"].(string)
|
||||
pubKey, _ := resp.Data["public_key"].(string)
|
||||
algorithm, _ := resp.Data["algorithm"].(string)
|
||||
return &pb.UserGetPublicKeyResponse{Username: username, PublicKey: pubKey, Algorithm: algorithm}, nil
|
||||
}
|
||||
|
||||
func (us *userServer) ListUsers(ctx context.Context, req *pb.UserListUsersRequest) (*pb.UserListUsersResponse, error) {
|
||||
if req.Mount == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "mount is required")
|
||||
}
|
||||
resp, err := us.handleRequest(ctx, req.Mount, "list-users", &engine.Request{
|
||||
Operation: "list-users",
|
||||
CallerInfo: us.callerInfo(ctx),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, _ := resp.Data["users"].([]interface{})
|
||||
users := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
if s, ok := v.(string); ok {
|
||||
users = append(users, s)
|
||||
}
|
||||
}
|
||||
return &pb.UserListUsersResponse{Users: users}, nil
|
||||
}
|
||||
|
||||
func (us *userServer) Encrypt(ctx context.Context, req *pb.UserEncryptRequest) (*pb.UserEncryptResponse, error) {
|
||||
if req.Mount == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "mount is required")
|
||||
}
|
||||
if req.Plaintext == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "plaintext is required")
|
||||
}
|
||||
if len(req.Recipients) == 0 {
|
||||
return nil, status.Error(codes.InvalidArgument, "recipients are required")
|
||||
}
|
||||
recipients := make([]interface{}, len(req.Recipients))
|
||||
for i, r := range req.Recipients {
|
||||
recipients[i] = r
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"plaintext": req.Plaintext,
|
||||
"recipients": recipients,
|
||||
}
|
||||
if req.Metadata != "" {
|
||||
data["metadata"] = req.Metadata
|
||||
}
|
||||
resp, err := us.handleRequest(ctx, req.Mount, "encrypt", &engine.Request{
|
||||
Operation: "encrypt",
|
||||
CallerInfo: us.callerInfo(ctx),
|
||||
CheckPolicy: us.policyChecker(ctx),
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
envelope, _ := resp.Data["envelope"].(string)
|
||||
us.s.logger.Info("audit: user encrypt", "mount", req.Mount, "recipients", req.Recipients, "username", callerUsername(ctx))
|
||||
return &pb.UserEncryptResponse{Envelope: envelope}, nil
|
||||
}
|
||||
|
||||
func (us *userServer) Decrypt(ctx context.Context, req *pb.UserDecryptRequest) (*pb.UserDecryptResponse, error) {
|
||||
if req.Mount == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "mount is required")
|
||||
}
|
||||
if req.Envelope == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "envelope is required")
|
||||
}
|
||||
resp, err := us.handleRequest(ctx, req.Mount, "decrypt", &engine.Request{
|
||||
Operation: "decrypt",
|
||||
CallerInfo: us.callerInfo(ctx),
|
||||
Data: map[string]interface{}{"envelope": req.Envelope},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plaintext, _ := resp.Data["plaintext"].(string)
|
||||
sender, _ := resp.Data["sender"].(string)
|
||||
metadata, _ := resp.Data["metadata"].(string)
|
||||
return &pb.UserDecryptResponse{Plaintext: plaintext, Sender: sender, Metadata: metadata}, nil
|
||||
}
|
||||
|
||||
func (us *userServer) ReEncrypt(ctx context.Context, req *pb.UserReEncryptRequest) (*pb.UserReEncryptResponse, error) {
|
||||
if req.Mount == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "mount is required")
|
||||
}
|
||||
if req.Envelope == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "envelope is required")
|
||||
}
|
||||
resp, err := us.handleRequest(ctx, req.Mount, "re-encrypt", &engine.Request{
|
||||
Operation: "re-encrypt",
|
||||
CallerInfo: us.callerInfo(ctx),
|
||||
Data: map[string]interface{}{"envelope": req.Envelope},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
envelope, _ := resp.Data["envelope"].(string)
|
||||
return &pb.UserReEncryptResponse{Envelope: envelope}, nil
|
||||
}
|
||||
|
||||
func (us *userServer) RotateKey(ctx context.Context, req *pb.UserRotateKeyRequest) (*pb.UserRotateKeyResponse, error) {
|
||||
if req.Mount == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "mount is required")
|
||||
}
|
||||
resp, err := us.handleRequest(ctx, req.Mount, "rotate-key", &engine.Request{
|
||||
Operation: "rotate-key",
|
||||
CallerInfo: us.callerInfo(ctx),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
username, _ := resp.Data["username"].(string)
|
||||
pubKey, _ := resp.Data["public_key"].(string)
|
||||
algorithm, _ := resp.Data["algorithm"].(string)
|
||||
us.s.logger.Info("audit: user key rotated", "mount", req.Mount, "username", username)
|
||||
return &pb.UserRotateKeyResponse{Username: username, PublicKey: pubKey, Algorithm: algorithm}, nil
|
||||
}
|
||||
|
||||
func (us *userServer) DeleteUser(ctx context.Context, req *pb.UserDeleteUserRequest) (*pb.UserDeleteUserResponse, error) {
|
||||
if req.Mount == "" || req.Username == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "mount and username are required")
|
||||
}
|
||||
_, err := us.handleRequest(ctx, req.Mount, "delete-user", &engine.Request{
|
||||
Operation: "delete-user",
|
||||
CallerInfo: us.callerInfo(ctx),
|
||||
Data: map[string]interface{}{"username": req.Username},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
us.s.logger.Info("audit: user deleted", "mount", req.Mount, "username", req.Username, "by", callerUsername(ctx))
|
||||
return &pb.UserDeleteUserResponse{}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user