73fd281bb5
ECDSA P-256 self-signed CA with disk persistence (load-or-generate),
CSR signing (CN=nodeUUID, 90d validity, EKU=ClientAuth), one-time
bootstrap tokens via Redis GETDEL (15min TTL), CRL revocation with
Redis SET + DB interface, gRPC unary+stream interceptors that extract
CN from verified TLS chains (Enroll whitelisted, others require cert),
and NewServerTLSConfig (VerifyClientCertIfGiven + TLS 1.3 + CRL hook).
Frozen API: SignCSR / CAPEM / IssueToken / ConsumeToken / Revoke /
NewServerTLSConfig / UnaryServerInterceptor / NodeUUIDFromContext
Tests cover: CA sign+verify, token one-time guarantee, TTL expiry,
revocation rejection, interceptor whitelist (5 categories).
Redis layer backed by miniredis in tests.
Run setup.sh from server/ to fetch deps and verify tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
120 lines
3.6 KiB
Go
120 lines
3.6 KiB
Go
package mtls
|
|
|
|
import (
|
|
"context"
|
|
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/credentials"
|
|
"google.golang.org/grpc/peer"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
// nodeUUIDKey is the unexported context key for the verified node UUID.
|
|
type nodeUUIDKey struct{}
|
|
|
|
// enrollFullMethod is the one RPC exempt from mTLS enforcement.
|
|
// The agent has no certificate yet when it calls Enroll; the bootstrap token
|
|
// provides authentication for that call instead.
|
|
const enrollFullMethod = "/pangolin.agent.v1.AgentService/Enroll"
|
|
|
|
// UnaryServerInterceptor returns a gRPC unary interceptor that enforces mTLS identity.
|
|
//
|
|
// For the Enroll method: the call is passed through without a certificate check.
|
|
// For all other methods: a verified client certificate is required; its CN is
|
|
// injected into the context as the authoritative node UUID.
|
|
func UnaryServerInterceptor() grpc.UnaryServerInterceptor {
|
|
return func(
|
|
ctx context.Context,
|
|
req interface{},
|
|
info *grpc.UnaryServerInfo,
|
|
handler grpc.UnaryHandler,
|
|
) (interface{}, error) {
|
|
if info.FullMethod == enrollFullMethod {
|
|
return handler(ctx, req)
|
|
}
|
|
|
|
nodeUUID, err := extractNodeUUID(ctx, info.FullMethod)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return handler(context.WithValue(ctx, nodeUUIDKey{}, nodeUUID), req)
|
|
}
|
|
}
|
|
|
|
// StreamServerInterceptor returns a gRPC stream interceptor that enforces mTLS identity.
|
|
// Same whitelist logic as the unary interceptor applies.
|
|
func StreamServerInterceptor() grpc.StreamServerInterceptor {
|
|
return func(
|
|
srv interface{},
|
|
ss grpc.ServerStream,
|
|
info *grpc.StreamServerInfo,
|
|
handler grpc.StreamHandler,
|
|
) error {
|
|
if info.FullMethod == enrollFullMethod {
|
|
return handler(srv, ss)
|
|
}
|
|
|
|
nodeUUID, err := extractNodeUUID(ss.Context(), info.FullMethod)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
enriched := &wrappedStream{
|
|
ServerStream: ss,
|
|
ctx: context.WithValue(ss.Context(), nodeUUIDKey{}, nodeUUID),
|
|
}
|
|
return handler(srv, enriched)
|
|
}
|
|
}
|
|
|
|
// NodeUUIDFromContext retrieves the verified node UUID that was injected by the
|
|
// interceptor. Returns ("", false) when the context carries no UUID (e.g. in
|
|
// Enroll handlers where the identity is not yet established).
|
|
func NodeUUIDFromContext(ctx context.Context) (string, bool) {
|
|
v, ok := ctx.Value(nodeUUIDKey{}).(string)
|
|
return v, ok && v != ""
|
|
}
|
|
|
|
// ─── internals ───────────────────────────────────────────────────────────────
|
|
|
|
// extractNodeUUID reads the verified TLS peer certificate's CN from the context.
|
|
// It returns an Unauthenticated status error when no valid cert is present.
|
|
func extractNodeUUID(ctx context.Context, method string) (string, error) {
|
|
p, ok := peer.FromContext(ctx)
|
|
if !ok {
|
|
return "", status.Errorf(codes.Unauthenticated,
|
|
"mtls: no peer info for method %s", method)
|
|
}
|
|
|
|
tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo)
|
|
if !ok {
|
|
return "", status.Errorf(codes.Unauthenticated,
|
|
"mtls: non-TLS connection for method %s", method)
|
|
}
|
|
|
|
chains := tlsInfo.State.VerifiedChains
|
|
if len(chains) == 0 || len(chains[0]) == 0 {
|
|
return "", status.Errorf(codes.Unauthenticated,
|
|
"mtls: client certificate required for method %s", method)
|
|
}
|
|
|
|
cn := chains[0][0].Subject.CommonName
|
|
if cn == "" {
|
|
return "", status.Errorf(codes.Unauthenticated,
|
|
"mtls: empty CN in client certificate for method %s", method)
|
|
}
|
|
return cn, nil
|
|
}
|
|
|
|
// wrappedStream overrides the context on a ServerStream.
|
|
type wrappedStream struct {
|
|
grpc.ServerStream
|
|
ctx context.Context
|
|
}
|
|
|
|
func (w *wrappedStream) Context() context.Context {
|
|
return w.ctx
|
|
}
|