// Package jointoken implementiert one-shot Cluster-Join-Token für // Phase 3.4. // // Format: "eg-join-v1.." // // Payload (CBOR-ähnlich, kompakt JSON): {n: nonce(16B base64), // e: expires_at_unix, f: ca_fingerprint_hex_16chars} // // HMAC: SHA-256 über payload-bytes, Key aus /var/lib/edgeguard/cluster-join-secret // (auto-generiert beim ersten Generate-Call, 32B random, 0600). // // Lifecycle: // 1. Admin POSTet /cluster/join-tokens → Server generiert Token, schickt // ihn als Klartext-String (einmalig). Server speichert NICHTS // (token ist self-contained). // 2. Joiner POSTet /cluster/issue-cert mit {token, csr}. // 3. Server Verify(token): HMAC ok? Nicht abgelaufen? CA-Fingerprint // passt zum eigenen? Nonce nicht schon in join_tokens_used? // 4. Bei Erfolg: nonce in join_tokens_used inserten (PK-Conflict = // Re-Use → reject). Dann CSR signieren + zurückgeben. // // One-Shot-Semantik: PG-Insert mit ON CONFLICT DO NOTHING; wenn keine // Row inserted wurde → schon konsumiert. Das ersetzt einen sonst // nötigen Distributed-Lock. package jointoken import ( "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "time" "github.com/jackc/pgx/v5/pgxpool" ) const ( // SecretPath: HMAC-Key. Auto-Generated beim ersten Generate. DefaultSecretPath = "/var/lib/edgeguard/cluster-join-secret" tokenPrefix = "eg-join-v1." // DefaultTTL — wie lange ein generierter Token gültig bleibt. DefaultTTL = 24 * time.Hour ) // Service kapselt Token-Gen + -Verify mit DB-One-Shot-Tracking. type Service struct { Pool *pgxpool.Pool SecretPath string GetCAFinger func() (string, error) // liefert 16-hex-fingerprint der eigenen CA TTL time.Duration } func New(pool *pgxpool.Pool, getCAFinger func() (string, error)) *Service { s := &Service{ Pool: pool, SecretPath: DefaultSecretPath, GetCAFinger: getCAFinger, TTL: DefaultTTL, } // Ensure the HMAC secret file exists at startup so Consume() never // fails with "no such file" on the first join attempt. if _, err := s.ensureSecret(); err != nil { // Non-fatal: generate won't work either, but we log and continue. _ = err } return s } // payload ist das JSON inside-the-token. type payload struct { Nonce string `json:"n"` Expires int64 `json:"e"` CAFinger16 string `json:"f"` } // Generate erzeugt einen frischen Token. Caller (Handler) muss bereits // Admin-Auth geprüft haben. func (s *Service) Generate() (string, time.Time, error) { if s.GetCAFinger == nil { return "", time.Time{}, errors.New("jointoken: GetCAFinger unset") } finger, err := s.GetCAFinger() if err != nil { return "", time.Time{}, fmt.Errorf("ca fingerprint: %w", err) } secret, err := s.ensureSecret() if err != nil { return "", time.Time{}, fmt.Errorf("load secret: %w", err) } nonceBytes := make([]byte, 16) if _, err := rand.Read(nonceBytes); err != nil { return "", time.Time{}, err } ttl := s.TTL if ttl <= 0 { ttl = DefaultTTL } exp := time.Now().Add(ttl).UTC() p := payload{ Nonce: base64.RawURLEncoding.EncodeToString(nonceBytes), Expires: exp.Unix(), CAFinger16: finger, } pj, err := json.Marshal(p) if err != nil { return "", time.Time{}, err } mac := hmac.New(sha256.New, secret) mac.Write(pj) sig := mac.Sum(nil) return tokenPrefix + base64.RawURLEncoding.EncodeToString(pj) + "." + base64.RawURLEncoding.EncodeToString(sig), exp, nil } // Consume verifiziert den Token UND markiert die nonce als verbraucht // — wenn die nonce schon in join_tokens_used steht (oder das INSERT // nichts geändert hat), lehnen wir ab. Erfolgreicher Consume liefert // die CA-Fingerprint aus dem Payload (Caller kann gegen die eigene // CA vergleichen). // // `consumedBy` ist freier String der zum Audit ins DB-Row landet // (typisch: peer.fqdn oder peer.api_url). func (s *Service) Consume(ctx context.Context, token, consumedBy string) (payload, error) { var zero payload if !strings.HasPrefix(token, tokenPrefix) { return zero, errors.New("invalid token format") } rest := strings.TrimPrefix(token, tokenPrefix) parts := strings.SplitN(rest, ".", 2) if len(parts) != 2 { return zero, errors.New("invalid token format") } pj, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return zero, fmt.Errorf("decode payload: %w", err) } sig, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return zero, fmt.Errorf("decode sig: %w", err) } secret, err := s.loadSecret() if err != nil { return zero, fmt.Errorf("load secret: %w", err) } mac := hmac.New(sha256.New, secret) mac.Write(pj) want := mac.Sum(nil) if !hmac.Equal(want, sig) { return zero, errors.New("bad signature") } var p payload if err := json.Unmarshal(pj, &p); err != nil { return zero, fmt.Errorf("unmarshal payload: %w", err) } now := time.Now().UTC() if now.Unix() > p.Expires { return zero, errors.New("token expired") } if s.GetCAFinger != nil { ownFinger, err := s.GetCAFinger() if err == nil && ownFinger != p.CAFinger16 { return zero, errors.New("ca fingerprint mismatch (wrong cluster?)") } } // One-shot-INSERT — wenn die nonce schon da ist (Re-Use), schlägt // das fehl und wir lehnen ab. exp := time.Unix(p.Expires, 0).UTC() tag, err := s.Pool.Exec(ctx, ` INSERT INTO join_tokens_used (nonce, expires_at, consumed_by) VALUES ($1, $2, $3) ON CONFLICT (nonce) DO NOTHING`, p.Nonce, exp, consumedBy) if err != nil { return zero, fmt.Errorf("track nonce: %w", err) } if tag.RowsAffected() == 0 { return zero, errors.New("token already used") } return p, nil } // CleanupExpired löscht abgelaufene Nonce-Rows. Idempotent; Aufruf vom // Scheduler einmal pro Stunde reicht. func (s *Service) CleanupExpired(ctx context.Context) (int64, error) { tag, err := s.Pool.Exec(ctx, `DELETE FROM join_tokens_used WHERE expires_at < NOW() - INTERVAL '7 days'`) if err != nil { return 0, err } return tag.RowsAffected(), nil } // ── secret handling ─────────────────────────────────────────────────── func (s *Service) ensureSecret() ([]byte, error) { if b, err := s.loadSecret(); err == nil && len(b) >= 32 { return b, nil } // Generate fresh secret. Dir wird in postinst erstellt (0700, // edgeguard-owned), wir schreiben direkt rein. dir := filepath.Dir(s.path()) if err := os.MkdirAll(dir, 0o700); err != nil { return nil, err } key := make([]byte, 32) if _, err := rand.Read(key); err != nil { return nil, err } if err := os.WriteFile(s.path(), key, 0o600); err != nil { return nil, err } return key, nil } func (s *Service) loadSecret() ([]byte, error) { return os.ReadFile(s.path()) } func (s *Service) path() string { if s.SecretPath != "" { return s.SecretPath } return DefaultSecretPath } // CAFingerprint16 liefert die ersten 16 Hex-Chars vom SHA-256 der CA- // Certificate DER-Bytes. Eindeutig genug für den Cluster-Trust-Check // (64 bit Entropie); kürzer als 64 Hex damit Token nicht aufbläst. func CAFingerprint16(caCertDER []byte) string { sum := sha256.Sum256(caCertDER) return hex.EncodeToString(sum[:])[:16] }