Files
metacrypt/internal/server/grpc.go
Claude b8e348db03 Add TLS unsealing via gRPC to CLI and server
Implements the SystemService gRPC endpoint (Status, Init, Unseal, Seal)
alongside the existing REST API, secured with the same TLS certificate.

The `metacrypt unseal` CLI command now prefers gRPC when --grpc-addr is
provided, falling back to the REST API via --addr. Both transports require
TLS; a custom CA certificate can be supplied with --ca-cert.

Server changes:
- internal/server/grpc.go: SystemServiceServer implementation with
  StartGRPC/ShutdownGRPC methods; uses the TLS cert from config.
- internal/server/server.go: adds grpcSrv field and grpc import.
- cmd/metacrypt/server.go: starts gRPC goroutine when grpc_addr is set
  in config, shuts it down on signal.

Generated code (from proto/metacrypt/v1/system.proto):
- gen/metacrypt/v1/system.pb.go: protobuf message types
- gen/metacrypt/v1/system_grpc.pb.go: gRPC client/server stubs

Dependencies added to go.mod (run `go mod tidy` to populate go.sum):
- google.golang.org/grpc v1.71.1
- google.golang.org/protobuf v1.36.5
- google.golang.org/genproto/googleapis/rpc (indirect)
- golang.org/x/net (indirect)

https://claude.ai/code/session_013m1QXGoTB4jaPUN5gwir8F
2026-03-15 09:49:48 -07:00

126 lines
4.2 KiB
Go

package server
import (
"context"
"crypto/tls"
"fmt"
"net"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
grpcstatus "google.golang.org/grpc/status"
metacryptv1 "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1"
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
)
// systemServiceServer implements metacryptv1.SystemServiceServer.
type systemServiceServer struct {
metacryptv1.UnimplementedSystemServiceServer
s *Server
}
func (g *systemServiceServer) Status(_ context.Context, _ *metacryptv1.StatusRequest) (*metacryptv1.StatusResponse, error) {
return &metacryptv1.StatusResponse{State: g.s.seal.State().String()}, nil
}
func (g *systemServiceServer) Init(ctx context.Context, req *metacryptv1.InitRequest) (*metacryptv1.InitResponse, error) {
params := crypto.Argon2Params{
Time: g.s.cfg.Seal.Argon2Time,
Memory: g.s.cfg.Seal.Argon2Memory,
Threads: g.s.cfg.Seal.Argon2Threads,
}
if err := g.s.seal.Initialize(ctx, []byte(req.Password), params); err != nil {
if err == seal.ErrAlreadyInitialized {
return nil, grpcstatus.Error(codes.AlreadyExists, "already initialized")
}
g.s.logger.Error("grpc init failed", "error", err)
return nil, grpcstatus.Error(codes.Internal, "initialization failed")
}
return &metacryptv1.InitResponse{State: g.s.seal.State().String()}, nil
}
func (g *systemServiceServer) Unseal(ctx context.Context, req *metacryptv1.UnsealRequest) (*metacryptv1.UnsealResponse, error) {
if err := g.s.seal.Unseal([]byte(req.Password)); err != nil {
switch err {
case seal.ErrNotInitialized:
return nil, grpcstatus.Error(codes.FailedPrecondition, "not initialized")
case seal.ErrInvalidPassword:
return nil, grpcstatus.Error(codes.Unauthenticated, "invalid password")
case seal.ErrRateLimited:
return nil, grpcstatus.Error(codes.ResourceExhausted, "too many attempts, try again later")
case seal.ErrNotSealed:
return nil, grpcstatus.Error(codes.AlreadyExists, "already unsealed")
default:
g.s.logger.Error("grpc unseal failed", "error", err)
return nil, grpcstatus.Error(codes.Internal, "unseal failed")
}
}
if err := g.s.engines.UnsealAll(ctx); err != nil {
g.s.logger.Error("grpc engine unseal failed", "error", err)
return nil, grpcstatus.Error(codes.Internal, "engine unseal failed")
}
return &metacryptv1.UnsealResponse{State: g.s.seal.State().String()}, nil
}
func (g *systemServiceServer) Seal(_ context.Context, _ *metacryptv1.SealRequest) (*metacryptv1.SealResponse, error) {
if err := g.s.engines.SealAll(); err != nil {
g.s.logger.Error("grpc seal engines failed", "error", err)
}
if err := g.s.seal.Seal(); err != nil {
g.s.logger.Error("grpc seal failed", "error", err)
return nil, grpcstatus.Error(codes.Internal, "seal failed")
}
g.s.auth.ClearCache()
return &metacryptv1.SealResponse{State: g.s.seal.State().String()}, nil
}
// StartGRPC starts the gRPC server on cfg.Server.GRPCAddr using the same TLS
// certificate as the HTTP server. It blocks until the listener closes.
func (s *Server) StartGRPC() error {
if s.cfg.Server.GRPCAddr == "" {
return nil
}
cert, err := tls.LoadX509KeyPair(s.cfg.Server.TLSCert, s.cfg.Server.TLSKey)
if err != nil {
return fmt.Errorf("grpc: load TLS key pair: %w", err)
}
tlsCfg := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
},
}
grpcSrv := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsCfg)))
metacryptv1.RegisterSystemServiceServer(grpcSrv, &systemServiceServer{s: s})
lis, err := net.Listen("tcp", s.cfg.Server.GRPCAddr)
if err != nil {
return fmt.Errorf("grpc: listen: %w", err)
}
s.grpcSrv = grpcSrv
s.logger.Info("starting gRPC server", "addr", s.cfg.Server.GRPCAddr)
if err := grpcSrv.Serve(lis); err != nil {
return fmt.Errorf("grpc: serve: %w", err)
}
return nil
}
// ShutdownGRPC gracefully stops the gRPC server.
func (s *Server) ShutdownGRPC() {
if s.grpcSrv != nil {
s.grpcSrv.GracefulStop()
}
}