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 }