backupy-agent/apps/backupy-decrypt/internal/jwt/jwt.go
TronoSfera 8b0c978337 feat(initial): Backupy agent + backupy-decrypt CLI
Source ports from the TronoSfera/backupy-cloud monorepo:
- apps/agent/        — Go agent (WSS client, persistent queue, Docker
                       discovery, 5 DB drivers: PG/MySQL/Mongo/Redis/SQLite,
                       pre/post hooks, Prometheus metrics)
- apps/backupy-decrypt/ — standalone CLI for client-side decryption
- packages/proto/    — protobuf wire format (generated .pb.go committed
                       so the repo builds without protoc)
- docs/              — agent spec + wire-protocol contract

Apache-2.0 license. Image published to ghcr.io/tronosfera/backupy-agent
on every v* tag via .github/workflows/release.yml (multi-arch amd64+arm64).
2026-05-17 20:22:35 +03:00

171 lines
4.3 KiB
Go

// Package jwt parses and validates JWTs issued by the Backupy server
// without verifying the HMAC signature.
//
// Rationale: the CLI cannot know the server's HS256 signing secret, so
// any signature check would be theatre. What we DO check is the token
// structure, the issuer/audience claims, and expiry — that's enough to
// produce a clear error message if the user pastes the wrong string.
//
// If a future server release ships a public-key signature scheme we can
// move to full crypto verification here. For now this is intentionally
// minimal — see apps/server/internal/jwt for the signing side.
package jwt
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
// Sentinel errors.
var (
ErrMalformed = errors.New("jwt: malformed token")
ErrExpired = errors.New("jwt: token expired")
ErrWrongIssuer = errors.New("jwt: wrong issuer")
ErrWrongAudience = errors.New("jwt: wrong audience")
ErrMissingClaim = errors.New("jwt: missing claim")
)
// DecryptionClaims is the typed view of a decryption-token JWT.
type DecryptionClaims struct {
Issuer string
Subject string
Audience string
IssuedAt time.Time
ExpiresAt time.Time
RunID string
CompanyID string
DEKBase64 string
Algorithm string
FormatVersion int
SHA256 string
}
// Parse decodes a JWT without verifying the HMAC signature, returns the
// raw claims map. ErrMalformed is returned on any structural failure.
func Parse(token string) (map[string]any, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("%w: want 3 segments, got %d", ErrMalformed, len(parts))
}
enc := base64.RawURLEncoding
pld, err := enc.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("%w: decode payload: %v", ErrMalformed, err)
}
var claims map[string]any
if err := json.Unmarshal(pld, &claims); err != nil {
return nil, fmt.Errorf("%w: payload not JSON: %v", ErrMalformed, err)
}
return claims, nil
}
// ParseDecryption parses the token and pulls out the strongly-typed
// decryption claims. Validates iss, aud, exp and that all required
// fields are present.
//
// expectedIssuer and expectedAudience cannot be empty.
func ParseDecryption(token, expectedIssuer, expectedAudience string) (*DecryptionClaims, error) {
claims, err := Parse(token)
if err != nil {
return nil, err
}
getStr := func(k string) (string, error) {
v, ok := claims[k]
if !ok {
return "", fmt.Errorf("%w: %s", ErrMissingClaim, k)
}
s, ok := v.(string)
if !ok || s == "" {
return "", fmt.Errorf("%w: %s (not a non-empty string)", ErrMalformed, k)
}
return s, nil
}
iss, err := getStr("iss")
if err != nil {
return nil, err
}
if iss != expectedIssuer {
return nil, fmt.Errorf("%w: got %q, want %q", ErrWrongIssuer, iss, expectedIssuer)
}
aud, err := getStr("aud")
if err != nil {
return nil, err
}
if aud != expectedAudience {
return nil, fmt.Errorf("%w: got %q, want %q", ErrWrongAudience, aud, expectedAudience)
}
exp, ok := toUnix(claims["exp"])
if !ok {
return nil, fmt.Errorf("%w: exp", ErrMissingClaim)
}
iat, _ := toUnix(claims["iat"])
expiresAt := time.Unix(exp, 0).UTC()
if time.Now().UTC().After(expiresAt) {
return nil, ErrExpired
}
sub, err := getStr("sub")
if err != nil {
return nil, err
}
runID, err := getStr("run_id")
if err != nil {
return nil, err
}
companyID, err := getStr("company_id")
if err != nil {
return nil, err
}
dek, err := getStr("dek")
if err != nil {
return nil, err
}
alg, err := getStr("alg")
if err != nil {
return nil, err
}
fv, ok := toUnix(claims["format_version"])
if !ok {
return nil, fmt.Errorf("%w: format_version", ErrMissingClaim)
}
// sha256 is optional — empty is allowed (run had no recorded hash).
sha, _ := claims["sha256"].(string)
return &DecryptionClaims{
Issuer: iss,
Subject: sub,
Audience: aud,
IssuedAt: time.Unix(iat, 0).UTC(),
ExpiresAt: expiresAt,
RunID: runID,
CompanyID: companyID,
DEKBase64: dek,
Algorithm: alg,
FormatVersion: int(fv),
SHA256: sha,
}, nil
}
func toUnix(v any) (int64, bool) {
switch n := v.(type) {
case float64:
return int64(n), true
case int64:
return n, true
case int:
return int64(n), true
case json.Number:
i, err := n.Int64()
return i, err == nil
}
return 0, false
}