// 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 } // IssueWithRole returns a signed token for the given actor + role. func (s *Signer) IssueWithRole(actor, role string) (string, *Token, error) { now := s.Now() t := Token{ Actor: actor, Role: role, Iat: now.Unix(), Exp: now.Add(s.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 } // Issue is IssueWithRole with empty role. func (s *Signer) Issue(actor string) (string, *Token, error) { return s.IssueWithRole(actor, "") } // 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") )