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,62 @@
|
||||
package mtls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
bootstrapTokenPrefix = "enroll:token:"
|
||||
bootstrapTokenTTL = 15 * time.Minute
|
||||
)
|
||||
|
||||
// BootstrapTokenManager issues and validates one-time enrollment tokens.
|
||||
// Tokens are generated before a node is provisioned (called by task #14 during
|
||||
// cloud-init preparation) and consumed exactly once during the Enroll RPC.
|
||||
type BootstrapTokenManager struct {
|
||||
redis redis.Cmdable
|
||||
}
|
||||
|
||||
// NewBootstrapTokenManager creates a manager backed by the given Redis client.
|
||||
func NewBootstrapTokenManager(r redis.Cmdable) *BootstrapTokenManager {
|
||||
return &BootstrapTokenManager{redis: r}
|
||||
}
|
||||
|
||||
// IssueToken allocates a cryptographically random 32-byte token for a node.
|
||||
// The mapping enroll:token:{token} → nodeUUID is stored in Redis with a 15-minute TTL.
|
||||
//
|
||||
// Callers (task #14) invoke this pre-flight when a new node record is created;
|
||||
// the token is then injected into the node's cloud-init user-data.
|
||||
func (m *BootstrapTokenManager) IssueToken(ctx context.Context, nodeUUID string) (string, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("mtls: generate token entropy: %w", err)
|
||||
}
|
||||
token := hex.EncodeToString(raw) // 64-char hex string
|
||||
|
||||
key := bootstrapTokenPrefix + token
|
||||
if err := m.redis.Set(ctx, key, nodeUUID, bootstrapTokenTTL).Err(); err != nil {
|
||||
return "", fmt.Errorf("mtls: store bootstrap token: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ConsumeToken atomically retrieves and deletes the token (GETDEL).
|
||||
// Returns the bound nodeUUID on the first and only successful call.
|
||||
// A second call—or any call after the 15-minute TTL—returns an error.
|
||||
func (m *BootstrapTokenManager) ConsumeToken(ctx context.Context, token string) (string, error) {
|
||||
key := bootstrapTokenPrefix + token
|
||||
nodeUUID, err := m.redis.GetDel(ctx, key).Result()
|
||||
if err == redis.Nil {
|
||||
return "", fmt.Errorf("mtls: bootstrap token not found, already used, or expired")
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("mtls: consume bootstrap token: %w", err)
|
||||
}
|
||||
return nodeUUID, nil
|
||||
}
|
||||
Reference in New Issue
Block a user