Files
metacrypt/internal/grpcserver/user.go
Kyle Isom be3b9d7fe0 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>
2026-03-16 19:44:11 -07:00

274 lines
9.2 KiB
Go

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
}