feat: tunnel JWT auth

Tunnel method now requires Bearer JWT (HMAC-HS256) in gRPC metadata.
   - internal/auth: pure-stdlib verifier + issuer + revoked-jti list
   - internal/tunnel: per-target rate limiter + egress allowlist with
     wildcard support + crypto/rand frame padding to {1KB, 4KB, 16KB}
   - cmd/server reads /etc/stats-gateway/{jwt.key,tunnel.yaml,revoked.txt}
     via --config-dir flag
   - cmd/issue-token Go binary wraps auth.Issue
   - configs/tunnel.yaml example with projectshitpost wildcard


Tests: 13 PASS across 5 packages.
This commit is contained in:
2026-05-05 21:28:37 +05:00
parent 0e3a3b4b0e
commit 731b60863a
12 changed files with 571 additions and 11 deletions
+154
View File
@@ -0,0 +1,154 @@
package auth
import (
"bufio"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"
)
// Claims holds the JWT payload fields used by this service.
type Claims struct {
Subject string `json:"sub"`
Scope string `json:"scope"`
IssuedAt int64 `json:"iat"`
Expires int64 `json:"exp"`
JTI string `json:"jti"`
}
// Verifier validates HMAC-HS256 tokens against a secret and optional revocation list.
type Verifier struct {
secret []byte
revoked map[string]bool
}
// NewVerifier creates a Verifier. If revoked is nil an empty map is used.
func NewVerifier(secret []byte, revoked map[string]bool) *Verifier {
if revoked == nil {
revoked = map[string]bool{}
}
return &Verifier{secret: secret, revoked: revoked}
}
// Verify checks signature, expiry, scope=="tunnel", and revocation list.
// Returns parsed Claims on success.
func (v *Verifier) Verify(token string) (*Claims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, errors.New("malformed token")
}
signingInput := parts[0] + "." + parts[1]
expectedSig, err := base64URLDecode(parts[2])
if err != nil {
return nil, fmt.Errorf("decode sig: %w", err)
}
mac := hmac.New(sha256.New, v.secret)
mac.Write([]byte(signingInput))
if !hmac.Equal(mac.Sum(nil), expectedSig) {
return nil, errors.New("bad signature")
}
headerJSON, err := base64URLDecode(parts[0])
if err != nil {
return nil, fmt.Errorf("decode header: %w", err)
}
var header struct {
Alg string `json:"alg"`
Typ string `json:"typ"`
}
if err := json.Unmarshal(headerJSON, &header); err != nil {
return nil, fmt.Errorf("header: %w", err)
}
if header.Alg != "HS256" {
return nil, fmt.Errorf("alg %q not allowed", header.Alg)
}
payload, err := base64URLDecode(parts[1])
if err != nil {
return nil, fmt.Errorf("decode payload: %w", err)
}
var c Claims
if err := json.Unmarshal(payload, &c); err != nil {
return nil, fmt.Errorf("payload: %w", err)
}
now := time.Now().Unix()
if c.Expires < now {
return nil, errors.New("token expired")
}
if c.Scope != "tunnel" {
return nil, fmt.Errorf("scope %q not allowed", c.Scope)
}
if v.revoked[c.JTI] {
return nil, errors.New("token revoked")
}
return &c, nil
}
// Issue creates a new HS256 token. ttl <= 0 produces an already-expired token (useful for tests).
func Issue(secret []byte, subject, scope string, ttl time.Duration) (string, error) {
jtiBytes := make([]byte, 8)
if _, err := rand.Read(jtiBytes); err != nil {
return "", err
}
return IssueWithJTI(secret, subject, scope, ttl, fmt.Sprintf("%x", jtiBytes))
}
// IssueWithJTI creates a token with a caller-supplied jti (useful for revocation tests).
func IssueWithJTI(secret []byte, subject, scope string, ttl time.Duration, jti string) (string, error) {
now := time.Now().Unix()
c := Claims{
Subject: subject,
Scope: scope,
IssuedAt: now,
Expires: now + int64(ttl.Seconds()),
JTI: jti,
}
header := `{"alg":"HS256","typ":"JWT"}`
payload, err := json.Marshal(c)
if err != nil {
return "", err
}
h := base64URLEncode([]byte(header))
p := base64URLEncode(payload)
signingInput := h + "." + p
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(signingInput))
sig := base64URLEncode(mac.Sum(nil))
return signingInput + "." + sig, nil
}
// LoadRevokedFile reads one jti per line from path. Lines starting with '#'
// or empty are ignored. Missing file → empty map (no error).
func LoadRevokedFile(path string) (map[string]bool, error) {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return map[string]bool{}, nil
}
return nil, err
}
defer f.Close()
out := map[string]bool{}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
out[line] = true
}
return out, sc.Err()
}
func base64URLEncode(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
func base64URLDecode(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }