feat(mtls): implement mTLS/CA + bootstrap token framework [tsk_FUQws_DMIcXa]
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>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user