diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..cbea127 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: [hyp3rd] diff --git a/.yamllint b/.yamllint index f3febc7..0810667 100644 --- a/.yamllint +++ b/.yamllint @@ -12,6 +12,7 @@ rules: ignore: | .golangci.yaml .github/workflows/* + .github/FUNDING.yml buf.gen.yaml buf.yaml **/vendor/** diff --git a/README.md b/README.md index a2af0ee..1aeae83 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ Security-focused Go helpers for file I/O, in-memory handling of sensitive data, - JWT/PASETO helpers with strict validation and safe defaults - Password hashing presets for argon2id/bcrypt with rehash detection - Email and URL validation with optional DNS/redirect/reputation checks +- Random token generation and validation with entropy/length caps +- Bounded base64/hex encoding and strict JSON decoding - HTML/Markdown sanitization, SQL/NoSQL input guards, and filename sanitizers - Safe integer conversion helpers with overflow/negative guards @@ -234,6 +236,52 @@ func main() { } ``` +### Tokens + +```go +package main + +import ( + "github.com/hyp3rd/sectools/pkg/tokens" +) + +func main() { + generator, err := tokens.NewGenerator() + if err != nil { + panic(err) + } + + validator, err := tokens.NewValidator() + if err != nil { + panic(err) + } + + token, _ := generator.Generate() + _, _ = validator.Validate(token) +} +``` + +### Encoding + +```go +package main + +import ( + "github.com/hyp3rd/sectools/pkg/encoding" +) + +func main() { + encoded, _ := encoding.EncodeBase64([]byte("secret")) + _, _ = encoding.DecodeBase64(encoded) + + type payload struct { + Name string `json:"name"` + } + + _ = encoding.DecodeJSON([]byte(`{"name":"alpha"}`), &payload{}) +} +``` + ### Sanitization ```go diff --git a/cspell.json b/cspell.json index e93e8e7..6f33296 100644 --- a/cspell.json +++ b/cspell.json @@ -34,6 +34,8 @@ "Atoi", "aud", "autobuild", + "base64", + "base64url", "bcrypt", "behaviour", "benchmem", @@ -59,6 +61,7 @@ "embeddedstructfieldcheck", "EnforceFileMode", "EnforceMode", + "entropy", "envoyproxy", "errcheck", "ewrap", @@ -73,6 +76,7 @@ "gid", "gitleaks", "gocache", + "goccy", "gofiber", "GOFILES", "gofmt", @@ -87,6 +91,7 @@ "gosec", "GOTOOLCHAIN", "govulncheck", + "hex", "honnef", "hostnames", "HS256", diff --git a/docs/security-checklist.md b/docs/security-checklist.md index be4f2ef..4ca0abd 100644 --- a/docs/security-checklist.md +++ b/docs/security-checklist.md @@ -25,6 +25,16 @@ This checklist is a quick reference for teams using sectools in production. - Use `WithJWTVerificationKeys` with `kid` for key rotation and enforce short expirations. - Prefer PASETO v4 local/public helpers for new tokens and keep issuer/audience rules consistent. +## Random Tokens + +- Use `pkg/tokens` to generate API keys or reset tokens with sufficient entropy. +- Set a max token length and reject tokens containing whitespace. + +## Encoding + +- Use `pkg/encoding` for base64/hex decoding with length caps. +- Keep JSON decoding strict and set `WithJSONMaxBytes` for untrusted payloads. + ## Passwords - Use Argon2id presets unless you need bcrypt compatibility. diff --git a/docs/usage.md b/docs/usage.md index 90d25a5..7f84c1a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -9,6 +9,8 @@ supporting implementations in `internal/`. - `pkg/auth`: JWT and PASETO helpers with strict validation. - `pkg/password`: password hashing helpers. - `pkg/validate`: email and URL validation helpers. +- `pkg/tokens`: random token generation and validation helpers. +- `pkg/encoding`: bounded encoding and decoding helpers. - `pkg/sanitize`: HTML/Markdown sanitizers, SQL input guards, and filename sanitizers. - `pkg/memory`: secure in-memory buffers. - `pkg/converters`: safe numeric conversions. @@ -307,6 +309,54 @@ Behavior: - Optional redirect checks with `WithURLCheckRedirects` and an HTTP client. - Optional reputation checks with `WithURLReputationChecker`. +## pkg/tokens + +### Token generation and validation + +```go +func NewGenerator(opts ...TokenOption) (*TokenGenerator, error) +func (g *TokenGenerator) Generate() (string, error) +func NewValidator(opts ...TokenOption) (*TokenValidator, error) +func (v *TokenValidator) Validate(token string) ([]byte, error) +``` + +Behavior: + +- Generates cryptographically secure random tokens (base64url by default). +- Enforces minimum entropy (bits) and optional minimum byte length. +- Rejects tokens over the configured max length or with whitespace. + +## pkg/encoding + +### Base64/Hex encoding + +```go +func EncodeBase64(data []byte, opts ...Base64Option) (string, error) +func DecodeBase64(input string, opts ...Base64Option) ([]byte, error) +func EncodeHex(data []byte, opts ...HexOption) (string, error) +func DecodeHex(input string, opts ...HexOption) ([]byte, error) +``` + +Behavior: + +- Enforces max input length and rejects whitespace. +- Uses base64url without padding by default. + +### JSON decoding + +```go +func EncodeJSON(value any, opts ...JSONOption) ([]byte, error) +func DecodeJSON(data []byte, value any, opts ...JSONOption) error +func DecodeJSONReader(reader io.Reader, value any, opts ...JSONOption) error +``` + +Behavior: + +- Uses go-json under the hood for encoding/decoding. +- Disallows unknown fields by default; can be toggled. +- Rejects payloads larger than the configured max size. +- Rejects trailing data after the first JSON value. + ## pkg/sanitize ### HTML sanitization diff --git a/go.mod b/go.mod index 993c56c..f88e731 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.5 require ( aidanwoods.dev/go-paseto v1.6.0 + github.com/goccy/go-json v0.10.5 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/hyp3rd/ewrap v1.3.5 github.com/hyp3rd/hyperlogger v0.0.8 @@ -15,7 +16,6 @@ require ( require ( aidanwoods.dev/go-result v0.3.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/goccy/go-json v0.10.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect diff --git a/pkg/encoding/base64.go b/pkg/encoding/base64.go new file mode 100644 index 0000000..532faa5 --- /dev/null +++ b/pkg/encoding/base64.go @@ -0,0 +1,148 @@ +package encoding + +import ( + "encoding/base64" + "strings" +) + +const ( + base64DefaultMaxLength = 4096 +) + +// Base64Encoding identifies a base64 encoding variant. +type Base64Encoding int + +const ( + // Base64EncodingRawURL uses URL-safe base64 without padding. + Base64EncodingRawURL Base64Encoding = iota + // Base64EncodingRawStd uses standard base64 without padding. + Base64EncodingRawStd + // Base64EncodingURL uses URL-safe base64 with padding. + Base64EncodingURL + // Base64EncodingStd uses standard base64 with padding. + Base64EncodingStd +) + +// Base64Option configures base64 encoding and decoding. +type Base64Option func(*base64Options) error + +type base64Options struct { + encoding *base64.Encoding + maxLength int +} + +// EncodeBase64 encodes data using the configured base64 encoding. +func EncodeBase64(data []byte, opts ...Base64Option) (string, error) { + cfg, err := resolveBase64Options(opts) + if err != nil { + return "", err + } + + if cfg.encoding.EncodedLen(len(data)) > cfg.maxLength { + return "", ErrBase64TooLong + } + + return cfg.encoding.EncodeToString(data), nil +} + +// DecodeBase64 decodes a base64-encoded string. +func DecodeBase64(input string, opts ...Base64Option) ([]byte, error) { + cfg, err := resolveBase64Options(opts) + if err != nil { + return nil, err + } + + if strings.TrimSpace(input) == "" { + return nil, ErrBase64Empty + } + + if containsWhitespace(input) { + return nil, ErrBase64Invalid + } + + if len(input) > cfg.maxLength { + return nil, ErrBase64TooLong + } + + decoded, err := cfg.encoding.DecodeString(input) + if err != nil { + return nil, ErrBase64Invalid + } + + return decoded, nil +} + +// WithBase64Encoding sets the base64 encoding variant. +func WithBase64Encoding(encoding Base64Encoding) Base64Option { + return func(cfg *base64Options) error { + enc, err := base64Encoding(encoding) + if err != nil { + return err + } + + cfg.encoding = enc + + return nil + } +} + +// WithBase64MaxLength sets the maximum accepted base64 string length. +func WithBase64MaxLength(maxLength int) Base64Option { + return func(cfg *base64Options) error { + if maxLength <= 0 { + return ErrInvalidBase64Config + } + + cfg.maxLength = maxLength + + return nil + } +} + +func resolveBase64Options(opts []Base64Option) (base64Options, error) { + cfg := base64Options{ + encoding: base64.RawURLEncoding, + maxLength: base64DefaultMaxLength, + } + + for _, opt := range opts { + if opt == nil { + continue + } + + err := opt(&cfg) + if err != nil { + return base64Options{}, err + } + } + + err := validateBase64Options(cfg) + if err != nil { + return base64Options{}, err + } + + return cfg, nil +} + +func validateBase64Options(cfg base64Options) error { + if cfg.encoding == nil || cfg.maxLength <= 0 { + return ErrInvalidBase64Config + } + + return nil +} + +func base64Encoding(encoding Base64Encoding) (*base64.Encoding, error) { + switch encoding { + case Base64EncodingRawURL: + return base64.RawURLEncoding, nil + case Base64EncodingRawStd: + return base64.RawStdEncoding, nil + case Base64EncodingURL: + return base64.URLEncoding, nil + case Base64EncodingStd: + return base64.StdEncoding, nil + default: + return nil, ErrInvalidBase64Config + } +} diff --git a/pkg/encoding/doc.go b/pkg/encoding/doc.go new file mode 100644 index 0000000..b4778bf --- /dev/null +++ b/pkg/encoding/doc.go @@ -0,0 +1,2 @@ +// Package encoding provides bounded encoding and decoding helpers. +package encoding diff --git a/pkg/encoding/encoding_test.go b/pkg/encoding/encoding_test.go new file mode 100644 index 0000000..7334578 --- /dev/null +++ b/pkg/encoding/encoding_test.go @@ -0,0 +1,165 @@ +package encoding + +import ( + "errors" + "strings" + "testing" +) + +func TestBase64EncodeDecode(t *testing.T) { + input := []byte("hello") + + encoded, err := EncodeBase64(input) + if err != nil { + t.Fatalf("expected encoded, got %v", err) + } + + decoded, err := DecodeBase64(encoded) + if err != nil { + t.Fatalf("expected decoded, got %v", err) + } + + if string(decoded) != string(input) { + t.Fatalf("expected %q, got %q", input, decoded) + } +} + +func TestBase64InvalidWhitespace(t *testing.T) { + _, err := DecodeBase64("a b") + if !errors.Is(err, ErrBase64Invalid) { + t.Fatalf("expected ErrBase64Invalid, got %v", err) + } +} + +func TestBase64MaxLength(t *testing.T) { + _, err := EncodeBase64([]byte("hello"), WithBase64MaxLength(4)) + if !errors.Is(err, ErrBase64TooLong) { + t.Fatalf("expected ErrBase64TooLong, got %v", err) + } +} + +func TestHexEncodeDecode(t *testing.T) { + input := []byte("hello") + + encoded, err := EncodeHex(input) + if err != nil { + t.Fatalf("expected encoded, got %v", err) + } + + decoded, err := DecodeHex(encoded) + if err != nil { + t.Fatalf("expected decoded, got %v", err) + } + + if string(decoded) != string(input) { + t.Fatalf("expected %q, got %q", input, decoded) + } +} + +func TestHexInvalid(t *testing.T) { + _, err := DecodeHex("zz") + if !errors.Is(err, ErrHexInvalid) { + t.Fatalf("expected ErrHexInvalid, got %v", err) + } +} + +func TestHexMaxLength(t *testing.T) { + _, err := EncodeHex([]byte("hello"), WithHexMaxLength(4)) + if !errors.Is(err, ErrHexTooLong) { + t.Fatalf("expected ErrHexTooLong, got %v", err) + } +} + +func TestDecodeJSON(t *testing.T) { + type payload struct { + Name string `json:"name"` + } + + data, err := EncodeJSON(payload{Name: "alpha"}) + if err != nil { + t.Fatalf("expected json, got %v", err) + } + + var result payload + if err := DecodeJSON(data, &result); err != nil { + t.Fatalf("expected decoded, got %v", err) + } + + if result.Name != "alpha" { + t.Fatalf("expected alpha, got %q", result.Name) + } +} + +func TestDecodeJSONUnknownField(t *testing.T) { + type payload struct { + Name string `json:"name"` + } + + var result payload + + err := DecodeJSON([]byte(`{"name":"alpha","extra":true}`), &result) + if !errors.Is(err, ErrJSONInvalid) { + t.Fatalf("expected ErrJSONInvalid, got %v", err) + } +} + +func TestDecodeJSONAllowUnknown(t *testing.T) { + type payload struct { + Name string `json:"name"` + } + + var result payload + + err := DecodeJSON( + []byte(`{"name":"alpha","extra":true}`), + &result, + WithJSONAllowUnknownFields(true), + ) + if err != nil { + t.Fatalf("expected decoded, got %v", err) + } +} + +func TestDecodeJSONTrailingData(t *testing.T) { + type payload struct { + Name string `json:"name"` + } + + var result payload + + err := DecodeJSON([]byte(`{"name":"alpha"}{"name":"beta"}`), &result) + if !errors.Is(err, ErrJSONTrailingData) { + t.Fatalf("expected ErrJSONTrailingData, got %v", err) + } +} + +func TestDecodeJSONMaxBytes(t *testing.T) { + type payload struct { + Name string `json:"name"` + } + + var result payload + + err := DecodeJSON([]byte(`{"name":"alpha"}`), &result, WithJSONMaxBytes(4)) + if !errors.Is(err, ErrJSONTooLarge) { + t.Fatalf("expected ErrJSONTooLarge, got %v", err) + } +} + +func TestDecodeJSONReader(t *testing.T) { + type payload struct { + Name string `json:"name"` + } + + reader := strings.NewReader(`{"name":"alpha"}`) + + var result payload + err := DecodeJSONReader(reader, &result) + if err != nil { + t.Fatalf("expected decoded, got %v", err) + } + + if result.Name != "alpha" { + t.Fatalf("expected alpha, got %q", result.Name) + } +} diff --git a/pkg/encoding/errors.go b/pkg/encoding/errors.go new file mode 100644 index 0000000..14c2e13 --- /dev/null +++ b/pkg/encoding/errors.go @@ -0,0 +1,32 @@ +package encoding + +import "github.com/hyp3rd/ewrap" + +var ( + // ErrInvalidBase64Config indicates an invalid base64 configuration. + ErrInvalidBase64Config = ewrap.New("invalid base64 config") + // ErrBase64Empty indicates the base64 input is empty. + ErrBase64Empty = ewrap.New("base64 input is empty") + // ErrBase64TooLong indicates the base64 input exceeds the configured max length. + ErrBase64TooLong = ewrap.New("base64 input too long") + // ErrBase64Invalid indicates the base64 input is invalid. + ErrBase64Invalid = ewrap.New("base64 input is invalid") + + // ErrInvalidHexConfig indicates an invalid hex configuration. + ErrInvalidHexConfig = ewrap.New("invalid hex config") + // ErrHexEmpty indicates the hex input is empty. + ErrHexEmpty = ewrap.New("hex input is empty") + // ErrHexTooLong indicates the hex input exceeds the configured max length. + ErrHexTooLong = ewrap.New("hex input too long") + // ErrHexInvalid indicates the hex input is invalid. + ErrHexInvalid = ewrap.New("hex input is invalid") + + // ErrInvalidJSONConfig indicates an invalid JSON configuration. + ErrInvalidJSONConfig = ewrap.New("invalid json config") + // ErrJSONTooLarge indicates the JSON input exceeds the configured max length. + ErrJSONTooLarge = ewrap.New("json input too large") + // ErrJSONInvalid indicates the JSON input is invalid. + ErrJSONInvalid = ewrap.New("json input is invalid") + // ErrJSONTrailingData indicates trailing JSON data was found. + ErrJSONTrailingData = ewrap.New("json trailing data detected") +) diff --git a/pkg/encoding/helpers.go b/pkg/encoding/helpers.go new file mode 100644 index 0000000..b521c64 --- /dev/null +++ b/pkg/encoding/helpers.go @@ -0,0 +1,10 @@ +package encoding + +import ( + "strings" + "unicode" +) + +func containsWhitespace(value string) bool { + return strings.IndexFunc(value, unicode.IsSpace) >= 0 +} diff --git a/pkg/encoding/hex.go b/pkg/encoding/hex.go new file mode 100644 index 0000000..cc542b3 --- /dev/null +++ b/pkg/encoding/hex.go @@ -0,0 +1,103 @@ +package encoding + +import ( + "encoding/hex" + "strings" +) + +const ( + hexDefaultMaxLength = 4096 +) + +// HexOption configures hex encoding and decoding. +type HexOption func(*hexOptions) error + +type hexOptions struct { + maxLength int +} + +// EncodeHex encodes data into a hex string. +func EncodeHex(data []byte, opts ...HexOption) (string, error) { + cfg, err := resolveHexOptions(opts) + if err != nil { + return "", err + } + + if hex.EncodedLen(len(data)) > cfg.maxLength { + return "", ErrHexTooLong + } + + return hex.EncodeToString(data), nil +} + +// DecodeHex decodes a hex string into bytes. +func DecodeHex(input string, opts ...HexOption) ([]byte, error) { + cfg, err := resolveHexOptions(opts) + if err != nil { + return nil, err + } + + if strings.TrimSpace(input) == "" { + return nil, ErrHexEmpty + } + + if containsWhitespace(input) { + return nil, ErrHexInvalid + } + + if len(input) > cfg.maxLength { + return nil, ErrHexTooLong + } + + decoded, err := hex.DecodeString(input) + if err != nil { + return nil, ErrHexInvalid + } + + return decoded, nil +} + +// WithHexMaxLength sets the maximum accepted hex string length. +func WithHexMaxLength(maxLength int) HexOption { + return func(cfg *hexOptions) error { + if maxLength <= 0 { + return ErrInvalidHexConfig + } + + cfg.maxLength = maxLength + + return nil + } +} + +func resolveHexOptions(opts []HexOption) (hexOptions, error) { + cfg := hexOptions{ + maxLength: hexDefaultMaxLength, + } + + for _, opt := range opts { + if opt == nil { + continue + } + + err := opt(&cfg) + if err != nil { + return hexOptions{}, err + } + } + + err := validateHexOptions(cfg) + if err != nil { + return hexOptions{}, err + } + + return cfg, nil +} + +func validateHexOptions(cfg hexOptions) error { + if cfg.maxLength <= 0 { + return ErrInvalidHexConfig + } + + return nil +} diff --git a/pkg/encoding/json.go b/pkg/encoding/json.go new file mode 100644 index 0000000..9875264 --- /dev/null +++ b/pkg/encoding/json.go @@ -0,0 +1,202 @@ +package encoding + +import ( + "bytes" + "errors" + "fmt" + "io" + + "github.com/goccy/go-json" + "github.com/hyp3rd/ewrap" +) + +const ( + jsonDefaultMaxBytes = 1 << 20 +) + +// JSONOption configures JSON encoding and decoding. +type JSONOption func(*jsonOptions) error + +type jsonOptions struct { + maxBytes int + allowUnknownFields bool + useNumber bool +} + +// EncodeJSON marshals a value using go-json with size bounds. +func EncodeJSON(value any, opts ...JSONOption) ([]byte, error) { + cfg, err := resolveJSONOptions(opts) + if err != nil { + return nil, err + } + + data, err := json.Marshal(value) + if err != nil { + return nil, ewrap.Wrapf(err, "%v:", ErrJSONInvalid) + } + + if len(data) > cfg.maxBytes { + return nil, ErrJSONTooLarge + } + + return data, nil +} + +// DecodeJSON decodes JSON from a byte slice with size bounds. +func DecodeJSON(data []byte, value any, opts ...JSONOption) error { + cfg, err := resolveJSONOptions(opts) + if err != nil { + return err + } + + if value == nil || len(data) == 0 { + return ErrJSONInvalid + } + + if len(data) > cfg.maxBytes { + return ErrJSONTooLarge + } + + return decodeJSONBytes(data, value, cfg) +} + +// DecodeJSONReader decodes JSON from a reader with size bounds. +func DecodeJSONReader(reader io.Reader, value any, opts ...JSONOption) error { + cfg, err := resolveJSONOptions(opts) + if err != nil { + return err + } + + if reader == nil || value == nil { + return ErrJSONInvalid + } + + data, err := readJSONInput(reader, cfg.maxBytes) + if err != nil { + return err + } + + if len(data) == 0 { + return ErrJSONInvalid + } + + return decodeJSONBytes(data, value, cfg) +} + +// WithJSONMaxBytes sets the maximum JSON payload size. +func WithJSONMaxBytes(maxBytes int) JSONOption { + return func(cfg *jsonOptions) error { + if maxBytes <= 0 { + return ErrInvalidJSONConfig + } + + cfg.maxBytes = maxBytes + + return nil + } +} + +// WithJSONAllowUnknownFields allows unknown fields during decode. +func WithJSONAllowUnknownFields(allow bool) JSONOption { + return func(cfg *jsonOptions) error { + cfg.allowUnknownFields = allow + + return nil + } +} + +// WithJSONUseNumber enables json.Number decoding for numbers. +func WithJSONUseNumber(useNumber bool) JSONOption { + return func(cfg *jsonOptions) error { + cfg.useNumber = useNumber + + return nil + } +} + +func resolveJSONOptions(opts []JSONOption) (jsonOptions, error) { + cfg := jsonOptions{ + maxBytes: jsonDefaultMaxBytes, + allowUnknownFields: false, + useNumber: false, + } + + for _, opt := range opts { + if opt == nil { + continue + } + + err := opt(&cfg) + if err != nil { + return jsonOptions{}, err + } + } + + err := validateJSONOptions(cfg) + if err != nil { + return jsonOptions{}, err + } + + return cfg, nil +} + +func validateJSONOptions(cfg jsonOptions) error { + if cfg.maxBytes <= 0 { + return ErrInvalidJSONConfig + } + + return nil +} + +func decodeJSONBytes(data []byte, value any, cfg jsonOptions) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + if !cfg.allowUnknownFields { + decoder.DisallowUnknownFields() + } + + if cfg.useNumber { + decoder.UseNumber() + } + + err := decoder.Decode(value) + if err != nil { + return fmt.Errorf("%w: %w", ErrJSONInvalid, err) + } + + err = ensureNoTrailingJSON(decoder) + if err != nil { + return err + } + + return nil +} + +func ensureNoTrailingJSON(decoder *json.Decoder) error { + var extra any + + err := decoder.Decode(&extra) + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + + return fmt.Errorf("%w: %w", ErrJSONTrailingData, err) + } + + return ErrJSONTrailingData +} + +func readJSONInput(reader io.Reader, maxBytes int) ([]byte, error) { + limited := io.LimitReader(reader, int64(maxBytes)+1) + + data, err := io.ReadAll(limited) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrJSONInvalid, err) + } + + if len(data) > maxBytes { + return nil, ErrJSONTooLarge + } + + return data, nil +} diff --git a/pkg/tokens/doc.go b/pkg/tokens/doc.go new file mode 100644 index 0000000..8bf51ba --- /dev/null +++ b/pkg/tokens/doc.go @@ -0,0 +1,2 @@ +// Package tokens provides safe random token generation and validation. +package tokens diff --git a/pkg/tokens/errors.go b/pkg/tokens/errors.go new file mode 100644 index 0000000..b8a6da9 --- /dev/null +++ b/pkg/tokens/errors.go @@ -0,0 +1,18 @@ +package tokens + +import "github.com/hyp3rd/ewrap" + +var ( + // ErrInvalidTokenConfig indicates an invalid token configuration. + ErrInvalidTokenConfig = ewrap.New("invalid token config") + // ErrTokenEmpty indicates the token is empty. + ErrTokenEmpty = ewrap.New("token is empty") + // ErrTokenTooLong indicates the token exceeds the configured max length. + ErrTokenTooLong = ewrap.New("token is too long") + // ErrTokenTooShort indicates the token is shorter than the minimum bytes. + ErrTokenTooShort = ewrap.New("token is too short") + // ErrTokenInvalid indicates the token is malformed or has invalid encoding. + ErrTokenInvalid = ewrap.New("token is invalid") + // ErrTokenInsufficientEntropy indicates the token lacks required entropy. + ErrTokenInsufficientEntropy = ewrap.New("token entropy is insufficient") +) diff --git a/pkg/tokens/tokens.go b/pkg/tokens/tokens.go new file mode 100644 index 0000000..e63b968 --- /dev/null +++ b/pkg/tokens/tokens.go @@ -0,0 +1,301 @@ +package tokens + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + "unicode" + + "github.com/hyp3rd/ewrap" +) + +const ( + bitsPerByte = 8 + tokenDefaultMinEntropyBits = 128 + tokenDefaultMaxLength = 4096 +) + +// TokenEncoding defines the string encoding for tokens. +type TokenEncoding int + +const ( + // TokenEncodingBase64URL encodes tokens using base64 URL encoding without padding. + TokenEncodingBase64URL TokenEncoding = iota + // TokenEncodingHex encodes tokens using hexadecimal encoding. + TokenEncodingHex +) + +// TokenOption configures token generation and validation. +type TokenOption func(*tokenOptions) error + +type tokenOptions struct { + encoding TokenEncoding + minEntropyBits int + minBytes int + maxLength int +} + +// TokenGenerator generates cryptographically secure tokens. +// Instances of TokenGenerator contain only immutable configuration and can be safely +// used concurrently by multiple goroutines. +type TokenGenerator struct { + opts tokenOptions +} + +// TokenValidator validates token strings. +// Instances of TokenValidator contain only immutable configuration and can be safely +// used concurrently by multiple goroutines. +type TokenValidator struct { + opts tokenOptions +} + +// NewGenerator constructs a token generator with safe defaults. +func NewGenerator(opts ...TokenOption) (*TokenGenerator, error) { + cfg := defaultTokenOptions() + + for _, opt := range opts { + if opt == nil { + continue + } + + err := opt(&cfg) + if err != nil { + return nil, err + } + } + + err := validateTokenOptions(cfg) + if err != nil { + return nil, err + } + + return &TokenGenerator{opts: cfg}, nil +} + +// NewValidator constructs a token validator with safe defaults. +func NewValidator(opts ...TokenOption) (*TokenValidator, error) { + cfg := defaultTokenOptions() + + for _, opt := range opts { + if opt == nil { + continue + } + + err := opt(&cfg) + if err != nil { + return nil, err + } + } + + err := validateTokenOptions(cfg) + if err != nil { + return nil, err + } + + return &TokenValidator{opts: cfg}, nil +} + +// WithTokenEncoding sets the token encoding. +func WithTokenEncoding(encoding TokenEncoding) TokenOption { + return func(cfg *tokenOptions) error { + if encoding != TokenEncodingBase64URL && encoding != TokenEncodingHex { + return ErrInvalidTokenConfig + } + + cfg.encoding = encoding + + return nil + } +} + +// WithTokenMinEntropyBits sets the minimum entropy bits required. +func WithTokenMinEntropyBits(bits int) TokenOption { + return func(cfg *tokenOptions) error { + if bits <= 0 { + return ErrInvalidTokenConfig + } + + cfg.minEntropyBits = bits + + return nil + } +} + +// WithTokenMinBytes sets the minimum decoded token length in bytes. +func WithTokenMinBytes(minBytes int) TokenOption { + return func(cfg *tokenOptions) error { + if minBytes <= 0 { + return ErrInvalidTokenConfig + } + + cfg.minBytes = minBytes + + return nil + } +} + +// WithTokenMaxLength sets the maximum accepted token length in characters. +func WithTokenMaxLength(maxLength int) TokenOption { + return func(cfg *tokenOptions) error { + if maxLength <= 0 { + return ErrInvalidTokenConfig + } + + cfg.maxLength = maxLength + + return nil + } +} + +// Generate produces a new token encoded as a string. +func (g *TokenGenerator) Generate() (string, error) { + raw, err := g.GenerateBytes() + if err != nil { + return "", err + } + + token, err := encodeToken(raw, g.opts.encoding) + if err != nil { + return "", err + } + + return token, nil +} + +// GenerateBytes produces raw token bytes. +func (g *TokenGenerator) GenerateBytes() ([]byte, error) { + length := requiredBytes(g.opts) + if length <= 0 { + return nil, ErrInvalidTokenConfig + } + + raw := make([]byte, length) + + _, err := rand.Read(raw) + if err != nil { + return nil, fmt.Errorf("generate token: %w", err) + } + + return raw, nil +} + +// Validate checks a token string and returns the decoded bytes. +func (v *TokenValidator) Validate(token string) ([]byte, error) { + if strings.TrimSpace(token) == "" { + return nil, ErrTokenEmpty + } + + if len(token) > v.opts.maxLength { + return nil, ErrTokenTooLong + } + + if containsSpace(token) { + return nil, ErrTokenInvalid + } + + decoded, err := decodeToken(token, v.opts.encoding) + if err != nil { + return nil, ErrTokenInvalid + } + + if v.opts.minBytes > 0 && len(decoded) < v.opts.minBytes { + return nil, ErrTokenTooShort + } + + if len(decoded)*bitsPerByte < v.opts.minEntropyBits { + return nil, ErrTokenInsufficientEntropy + } + + return decoded, nil +} + +func defaultTokenOptions() tokenOptions { + return tokenOptions{ + encoding: TokenEncodingBase64URL, + minEntropyBits: tokenDefaultMinEntropyBits, + maxLength: tokenDefaultMaxLength, + } +} + +func validateTokenOptions(cfg tokenOptions) error { + if cfg.minEntropyBits <= 0 || cfg.maxLength <= 0 || cfg.minBytes < 0 { + return ErrInvalidTokenConfig + } + + if cfg.encoding != TokenEncodingBase64URL && cfg.encoding != TokenEncodingHex { + return ErrInvalidTokenConfig + } + + required := requiredBytes(cfg) + if required <= 0 { + return ErrInvalidTokenConfig + } + + if encodedLength(cfg.encoding, required) > cfg.maxLength { + return ErrInvalidTokenConfig + } + + return nil +} + +func requiredBytes(cfg tokenOptions) int { + required := cfg.minEntropyBits / bitsPerByte + if cfg.minEntropyBits%bitsPerByte != 0 { + required++ + } + + if cfg.minBytes > required { + required = cfg.minBytes + } + + return required +} + +func encodedLength(encoding TokenEncoding, bytes int) int { + switch encoding { + case TokenEncodingBase64URL: + return base64.RawURLEncoding.EncodedLen(bytes) + case TokenEncodingHex: + return hex.EncodedLen(bytes) + default: + return 0 + } +} + +func encodeToken(raw []byte, encoding TokenEncoding) (string, error) { + switch encoding { + case TokenEncodingBase64URL: + return base64.RawURLEncoding.EncodeToString(raw), nil + case TokenEncodingHex: + return hex.EncodeToString(raw), nil + default: + return "", ErrInvalidTokenConfig + } +} + +func decodeToken(token string, encoding TokenEncoding) ([]byte, error) { + switch encoding { + case TokenEncodingBase64URL: + data, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return nil, ewrap.Wrap(err, "failed to decode base64 URL token") + } + + return data, nil + case TokenEncodingHex: + data, err := hex.DecodeString(token) + if err != nil { + return nil, ewrap.Wrap(err, "failed to decode hex token") + } + + return data, nil + default: + return nil, ErrInvalidTokenConfig + } +} + +func containsSpace(value string) bool { + return strings.IndexFunc(value, unicode.IsSpace) >= 0 +} diff --git a/pkg/tokens/tokens_test.go b/pkg/tokens/tokens_test.go new file mode 100644 index 0000000..0a3edcb --- /dev/null +++ b/pkg/tokens/tokens_test.go @@ -0,0 +1,117 @@ +package tokens + +import ( + "encoding/base64" + "errors" + "testing" +) + +func TestTokenGenerateAndValidateBase64(t *testing.T) { + generator, err := NewGenerator() + if err != nil { + t.Fatalf("expected generator, got %v", err) + } + + validator, err := NewValidator() + if err != nil { + t.Fatalf("expected validator, got %v", err) + } + + token, err := generator.Generate() + if err != nil { + t.Fatalf("expected token, got %v", err) + } + + decoded, err := validator.Validate(token) + if err != nil { + t.Fatalf("expected token valid, got %v", err) + } + + if len(decoded) < 16 { + t.Fatalf("expected at least 16 bytes, got %d", len(decoded)) + } +} + +func TestTokenGenerateHex(t *testing.T) { + generator, err := NewGenerator(WithTokenEncoding(TokenEncodingHex)) + if err != nil { + t.Fatalf("expected generator, got %v", err) + } + + validator, err := NewValidator(WithTokenEncoding(TokenEncodingHex)) + if err != nil { + t.Fatalf("expected validator, got %v", err) + } + + token, err := generator.Generate() + if err != nil { + t.Fatalf("expected token, got %v", err) + } + + decoded, err := validator.Validate(token) + if err != nil { + t.Fatalf("expected token valid, got %v", err) + } + + if len(decoded) < 16 { + t.Fatalf("expected at least 16 bytes, got %d", len(decoded)) + } +} + +func TestTokenValidateMaxLength(t *testing.T) { + validator, err := NewValidator( + WithTokenMaxLength(4), + WithTokenMinEntropyBits(8), + ) + if err != nil { + t.Fatalf("expected validator, got %v", err) + } + + _, err = validator.Validate("aaaaa") + if !errors.Is(err, ErrTokenTooLong) { + t.Fatalf("expected ErrTokenTooLong, got %v", err) + } +} + +func TestTokenValidateInsufficientEntropy(t *testing.T) { + validator, err := NewValidator(WithTokenMinEntropyBits(128)) + if err != nil { + t.Fatalf("expected validator, got %v", err) + } + + // Note: make([]byte, 8) produces an all-zero token. This test exercises the + // length-based entropy check (8 bytes = 64 bits) rather than measuring actual + // randomness of the token bytes. + short := base64.RawURLEncoding.EncodeToString(make([]byte, 8)) + + _, err = validator.Validate(short) + if !errors.Is(err, ErrTokenInsufficientEntropy) { + t.Fatalf("expected ErrTokenInsufficientEntropy, got %v", err) + } +} + +func TestTokenValidateMinBytes(t *testing.T) { + validator, err := NewValidator(WithTokenMinBytes(32)) + if err != nil { + t.Fatalf("expected validator, got %v", err) + } + + short := base64.RawURLEncoding.EncodeToString(make([]byte, 16)) + + _, err = validator.Validate(short) + if !errors.Is(err, ErrTokenTooShort) { + t.Fatalf("expected ErrTokenTooShort, got %v", err) + } +} + +func TestTokenValidateWhitespace(t *testing.T) { + validator, err := NewValidator() + if err != nil { + t.Fatalf("expected validator, got %v", err) + } + + _, err = validator.Validate("token with space") + if !errors.Is(err, ErrTokenInvalid) { + t.Fatalf("expected ErrTokenInvalid, got %v", err) + } +}