731b60863a
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.
155 lines
4.0 KiB
Go
155 lines
4.0 KiB
Go
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) }
|