Files
pangolin/server/internal/mtls/identity.go
T
wangjia 73fd281bb5 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>
2026-06-13 01:44:21 +08:00

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
}