Verifizierte Bugs aus dem Code-Audit behoben (je mit Test/Build/nft -c geprüft):
- session: IssueWithRoleTTL mutierte geteiltes s.TTL (Data-Race + falsche TTL) → interne issue(); -race-Test.
- auth: Fallback/Federation leiteten role/TOTP nicht aus DB ab (2FA-Bypass auf Secondary, Rolle aus Remote) → viaDB-Flag + DB-Re-Lookup.
- waf: TrustedProxies waren No-op (bogus-Direktive) → XFF-Auflösung im SPOE-Agent (rightmostXFF/ipMatchesAny); RuleExclusions/TrustedProxies validiert (Direktiven-Injection); GetForHost via net.SplitHostPort.
- firewall: Auto-Rule mit IPv6-DstIP erzeugte 'ip daddr <v6>' → bricht ganzes nft-Ruleset; jetzt familienbewusst (ip/ip6, ungültige raus).
- kea: 'interfaces': null bei 0 Subnets → leeres Array.
- cluster_repair: nodeHasPublication schluckte DB-Fehler (Resync auf falschem Node) → (bool,error) fail-closed; IPv6-Primary-URL via net.JoinHostPort.
- cluster_replication: Replikations-Passwort via stdin statt psql -c (nicht mehr in argv/Logs).
- wireguard: Config (Private Key) jetzt configgen.AtomicWrite VOR Symlink/enable; SkipReload-Feld.
- render.go: --no-reload jetzt für alle Renderer (squid/unbound/chrony/wireguard).
- radius: leeres Secret/Passwort + Newlines abgelehnt; freeradius confEscape strippt CR/LF.
- configorch: continue-on-error + errors.Join statt Abbruch mitten in der Sequenz.
- i18n: fehlender Key common.status (de/en).
Verworfen als kein Bug: WAF detection-'blocked' (DetectionOnly liefert keine Interruption), render secrets.New('') (nutzt Default-Masterkey), FanOut-Sort (nur Kommentar), pg_hba (durch nft abgesichert).
Offen/bewusst zurückgestellt (low/risk): AlertWriter-Close (langlebiger Worker, vernachlässigbar), Rolling-Update-Kleinkram (sudoers-gebundener Script-Pfad / GET-State).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
244 lines
6.4 KiB
Go
244 lines
6.4 KiB
Go
// Package session implements signed admin-session tokens.
|
|
//
|
|
// Tokens are opaque strings of the form
|
|
//
|
|
// base64url(payload) . base64url(HMAC-SHA256(payload))
|
|
//
|
|
// where payload is a small JSON ({actor, role, iat, exp}). A 32-byte
|
|
// secret on disk (0600 edgeguard:edgeguard, generated on first use)
|
|
// keys the HMAC. No DB round-trip for verification — handlers
|
|
// validate the token and trust the payload.
|
|
//
|
|
// Pattern 1:1 nach mail-gateway/internal/services/session/. Audience-
|
|
// Splitting (admin vs portal) und API-Key-Synthese sind bewusst nicht
|
|
// im v1-Scope (kein Quarantine-Portal in EdgeGuard).
|
|
package session
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
DefaultSecretPath = "/var/lib/edgeguard/.jwt_fingerprint"
|
|
|
|
defaultTTL = 24 * time.Hour
|
|
secretSize = 32
|
|
)
|
|
|
|
type Token struct {
|
|
Actor string `json:"actor"`
|
|
Role string `json:"role,omitempty"`
|
|
Iat int64 `json:"iat"`
|
|
Exp int64 `json:"exp"`
|
|
}
|
|
|
|
type Signer struct {
|
|
Secret []byte
|
|
Now func() time.Time
|
|
TTL time.Duration
|
|
}
|
|
|
|
// NewSignerFromPath loads or creates a 32-byte secret at path. Parent
|
|
// dir gets 0o700, file is 0o600.
|
|
func NewSignerFromPath(path string) (*Signer, error) {
|
|
if path == "" {
|
|
path = DefaultSecretPath
|
|
}
|
|
secret, err := loadOrCreateSecret(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Signer{
|
|
Secret: secret,
|
|
Now: func() time.Time { return time.Now().UTC() },
|
|
TTL: defaultTTL,
|
|
}, nil
|
|
}
|
|
|
|
// NewSigner builds a signer with an in-memory secret — for tests.
|
|
func NewSigner(secret []byte, now func() time.Time, ttl time.Duration) *Signer {
|
|
if now == nil {
|
|
now = func() time.Time { return time.Now().UTC() }
|
|
}
|
|
if ttl == 0 {
|
|
ttl = defaultTTL
|
|
}
|
|
return &Signer{Secret: secret, Now: now, TTL: ttl}
|
|
}
|
|
|
|
func loadOrCreateSecret(path string) ([]byte, error) {
|
|
if b, err := os.ReadFile(path); err == nil {
|
|
if len(b) < secretSize {
|
|
return nil, fmt.Errorf("%s is shorter than %d bytes", path, secretSize)
|
|
}
|
|
return b[:secretSize], nil
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return nil, err
|
|
}
|
|
secret := make([]byte, secretSize)
|
|
if _, err := rand.Read(secret); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.WriteFile(path, secret, 0o600); err != nil {
|
|
return nil, err
|
|
}
|
|
return secret, nil
|
|
}
|
|
|
|
// issue builds + signs a token with an explicit TTL. No shared-state
|
|
// mutation — safe for concurrent use of the shared Signer singleton.
|
|
func (s *Signer) issue(actor, role string, ttl time.Duration) (string, *Token, error) {
|
|
now := s.Now()
|
|
t := Token{
|
|
Actor: actor,
|
|
Role: role,
|
|
Iat: now.Unix(),
|
|
Exp: now.Add(ttl).Unix(),
|
|
}
|
|
data, err := json.Marshal(t)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
mac := hmac.New(sha256.New, s.Secret)
|
|
mac.Write(data)
|
|
signature := mac.Sum(nil)
|
|
encoded := base64.RawURLEncoding.EncodeToString(data) + "." +
|
|
base64.RawURLEncoding.EncodeToString(signature)
|
|
return encoded, &t, nil
|
|
}
|
|
|
|
// IssueWithRole returns a signed token for the given actor + role.
|
|
func (s *Signer) IssueWithRole(actor, role string) (string, *Token, error) {
|
|
return s.issue(actor, role, s.TTL)
|
|
}
|
|
|
|
// Issue is IssueWithRole with empty role.
|
|
func (s *Signer) Issue(actor string) (string, *Token, error) {
|
|
return s.issue(actor, "", s.TTL)
|
|
}
|
|
|
|
// IssueWithRoleTTL issues a token with a custom TTL — no longer mutates
|
|
// the shared Signer (previously a data race under concurrent logins).
|
|
func (s *Signer) IssueWithRoleTTL(actor, role string, ttl time.Duration) (string, *Token, error) {
|
|
return s.issue(actor, role, ttl)
|
|
}
|
|
|
|
// Verify checks a token. Returns ErrInvalidToken or ErrExpiredToken.
|
|
func (s *Signer) Verify(raw string) (*Token, error) {
|
|
if raw == "" {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
var payloadB64, sigB64 string
|
|
for i := 0; i < len(raw); i++ {
|
|
if raw[i] == '.' {
|
|
payloadB64 = raw[:i]
|
|
sigB64 = raw[i+1:]
|
|
break
|
|
}
|
|
}
|
|
if payloadB64 == "" || sigB64 == "" {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
payload, err := base64.RawURLEncoding.DecodeString(payloadB64)
|
|
if err != nil {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
sig, err := base64.RawURLEncoding.DecodeString(sigB64)
|
|
if err != nil {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
mac := hmac.New(sha256.New, s.Secret)
|
|
mac.Write(payload)
|
|
if subtle.ConstantTimeCompare(mac.Sum(nil), sig) != 1 {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
var t Token
|
|
if err := json.Unmarshal(payload, &t); err != nil {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
if s.Now().Unix() >= t.Exp {
|
|
return nil, ErrExpiredToken
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
var (
|
|
ErrInvalidToken = errors.New("invalid session token")
|
|
ErrExpiredToken = errors.New("session token expired")
|
|
)
|
|
|
|
// blobEnvelope umhüllt eine beliebige Payload mit einem Ablaufzeitpunkt.
|
|
type blobEnvelope struct {
|
|
Exp int64 `json:"exp"`
|
|
Payload []byte `json:"p"`
|
|
}
|
|
|
|
// SignBlob signiert beliebige Bytes mit dem Session-Secret (HMAC-SHA256,
|
|
// gleiches Format wie Tokens: base64url(json).base64url(sig)) und einer
|
|
// TTL. Für stateless, cluster-sichere Kurzzeit-Cookies (z.B. der
|
|
// OIDC-Flow-State). Das Secret ist clusterweit synchron (.jwt_fingerprint).
|
|
func (s *Signer) SignBlob(payload []byte, ttl time.Duration) (string, error) {
|
|
env := blobEnvelope{
|
|
Exp: s.Now().Add(ttl).Unix(),
|
|
Payload: payload,
|
|
}
|
|
data, err := json.Marshal(env)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
mac := hmac.New(sha256.New, s.Secret)
|
|
mac.Write(data)
|
|
return base64.RawURLEncoding.EncodeToString(data) + "." +
|
|
base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
|
|
}
|
|
|
|
// VerifyBlob prüft Signatur + Ablauf und gibt die ursprüngliche Payload
|
|
// zurück. ErrInvalidToken / ErrExpiredToken bei Fehlern.
|
|
func (s *Signer) VerifyBlob(raw string) ([]byte, error) {
|
|
if raw == "" {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
dot := -1
|
|
for i := 0; i < len(raw); i++ {
|
|
if raw[i] == '.' {
|
|
dot = i
|
|
break
|
|
}
|
|
}
|
|
if dot <= 0 || dot >= len(raw)-1 {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
payload, err := base64.RawURLEncoding.DecodeString(raw[:dot])
|
|
if err != nil {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
sig, err := base64.RawURLEncoding.DecodeString(raw[dot+1:])
|
|
if err != nil {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
mac := hmac.New(sha256.New, s.Secret)
|
|
mac.Write(payload)
|
|
if subtle.ConstantTimeCompare(mac.Sum(nil), sig) != 1 {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
var env blobEnvelope
|
|
if err := json.Unmarshal(payload, &env); err != nil {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
if s.Now().Unix() >= env.Exp {
|
|
return nil, ErrExpiredToken
|
|
}
|
|
return env.Payload, nil
|
|
}
|