Documentation
¶
Overview ¶
Package gocap provides a server-side implementation of Cap, a proof-of-work based CAPTCHA alternative. It generates cryptographic challenges that clients must solve, then verifies the solutions and issues validation tokens.
Quick Start ¶
secret := []byte("32-byte-high-entropy-secret-for-signing")
store := gocap.NewMemoryStorage()
cap, err := gocap.NewCap(gocap.CapOptions{
Secret: secret,
Storage: store,
})
// Generate a challenge
challenge, err := cap.CreateChallenge(ctx)
// Send challenge.Token, challenge.Challenge (c, s, d) to the client
// Verify the client's PoW solutions
result, err := cap.RedeemChallenge(ctx, gocap.ValidateBody{
Token: challenge.Token,
Solutions: clientSolutions, // []int64 from the client
})
if errors.Is(result, gocap.ErrInvalidSolution) {
// PoW verification failed
}
// Validate the issued token
vr := cap.ValidateToken(ctx, result.Token)
if errors.Is(vr, gocap.ErrTokenNotFound) {
// Token not found or expired
}
Challenge Formats ¶
Format-1 (default): A stateless SHA-256 proof-of-work challenge. The server derives salt and target values from the token itself using FNV-1a and xorshift PRNG, so no server-side storage is required for challenge verification.
Format-2 (opt-in): A multi-protocol challenge framework supporting:
- sha256-pow: Standard PoW with random salt per item
- rsw: Time-lock puzzles based on repeated squaring (quantum-resistant)
- instrumentation: Client-side browser integrity checks
Sentinel Errors ¶
All validation failures are returned as ValidateResult values that implement the error interface. Use errors.Is() to check specific failure reasons:
if errors.Is(result, gocap.ErrExpired) { /* challenge expired */ }
if errors.Is(result, gocap.ErrInvalidSolution) { /* PoW wrong */ }
Storage ¶
The Storage interface allows plugging in custom backends for challenge and validation token persistence. A built-in MemoryStorage is provided for development and testing. For production, implement the Storage interface with Redis, PostgreSQL, or any other backend.
Index ¶
- Variables
- func VerifyInstrumentationResult(meta *InstrumentationMeta, payload *InstrumentationResult) (bool, string)
- func VerifyRswSolution(expectedYHex, claimedYHex string) bool
- type Cap
- type CapOptions
- type ChallengeResult
- type ChallengeSpec
- type GenerateOptions
- type InstrPayload
- type InstrumentationMeta
- type InstrumentationOptions
- type InstrumentationResult
- type MemoryStorage
- func (s *MemoryStorage) CleanupExpired(_ context.Context) error
- func (s *MemoryStorage) GetChallengeTokenExpiry(_ context.Context, token string) (time.Time, bool, error)
- func (s *MemoryStorage) GetValidationTokenExpiry(_ context.Context, token string) (time.Time, bool, error)
- func (s *MemoryStorage) SetChallengeToken(_ context.Context, token string, expiresAt time.Time) error
- func (s *MemoryStorage) SetValidationToken(_ context.Context, token string, expiresAt time.Time) error
- type RedeemTokenData
- type RswKeypair
- type RswMintResult
- type RswMinter
- type SerializedRswKeypair
- type Storage
- type V2Challenge
- type V2Solution
- type ValidateBody
- type ValidateOptions
- type ValidateResult
Constants ¶
This section is empty.
Variables ¶
var ( // ErrMissingToken is returned when the challenge token is empty. ErrMissingToken = errors.New("missing_token") // ErrMissingSolutions is returned when the solutions array is nil. ErrMissingSolutions = errors.New("missing_solutions") // ErrInvalidToken is returned when the JWT is malformed, tampered, or // has an invalid signature. ErrInvalidToken = errors.New("invalid_token") // ErrScopeMismatch is returned when the requested scope doesn't match // the scope embedded in the challenge token. ErrScopeMismatch = errors.New("scope_mismatch") // ErrExpired is returned when the challenge or validation token has // exceeded its time-to-live. ErrExpired = errors.New("expired") // ErrInvalidSolutions is returned when the number of solutions doesn't // match the challenge count or a solution is negative. ErrInvalidSolutions = errors.New("invalid_solutions") // ErrInvalidSolution is returned when a PoW solution doesn't produce // a hash with the required target prefix. ErrInvalidSolution = errors.New("invalid_solution") // ErrAlreadyRedeemed is returned by the ConsumeNonce callback when the // challenge token signature has already been used. ErrAlreadyRedeemed = errors.New("already_redeemed") // ErrNonceStoreError is returned when the ConsumeNonce callback itself // fails (e.g. a storage backend error). ErrNonceStoreError = errors.New("nonce_store_error") // ErrStorageNotConfigured is returned by ValidateToken when no Storage // was provided to the Cap instance. ErrStorageNotConfigured = errors.New("storage_not_configured") // ErrCleanupError is returned when the storage's CleanupExpired call fails. ErrCleanupError = errors.New("cleanup_error") // ErrStorageError is returned when a storage Get/Set operation fails. ErrStorageError = errors.New("storage_error") // ErrTokenNotFound is returned when the validation token is not found // in storage. ErrTokenNotFound = errors.New("token_not_found") // ErrTokenExpired is returned when the validation token exists but has // passed its expiration time. ErrTokenExpired = errors.New("token_expired") // ErrInstrCorrupted is returned when the instrumentation metadata // cannot be decrypted or parsed. ErrInstrCorrupted = errors.New("instr_corrupted") // ErrInstrExpired is returned when the instrumentation metadata has // exceeded its expiration time. ErrInstrExpired = errors.New("instr_expired") // ErrInstrAutomatedBrowser is returned when the instrumentation // detected an automated browser and BlockAutomatedBrowsers is enabled. ErrInstrAutomatedBrowser = errors.New("instr_automated_browser") // ErrInstrTimeout is returned when the instrumentation timed out. ErrInstrTimeout = errors.New("instr_timeout") // ErrInstrFailed is returned when the instrumentation result // verification fails unexpectedly. ErrInstrFailed = errors.New("instr_failed") // ErrInstrMissing is returned when instrumentation is required but // no instrumentation result was provided. ErrInstrMissing = errors.New("instr_missing") // ErrShortSecret is returned by NewCap when Secret is shorter than 16 bytes. ErrShortSecret = errors.New("secret must be at least 16 bytes") // ErrFormat2NoProtocols is returned by NewCap when Format is 2 but no // protocols are specified. ErrFormat2NoProtocols = errors.New("format-2 requires at least one protocol") // ErrRSWBitsMustBeEven is returned by GenerateRswKeypair when bits is odd. ErrRSWBitsMustBeEven = errors.New("rsw bits must be even") // ErrRSWKeypairRequired is returned when format-2 rsw protocol is used // without providing a keypair. ErrRSWKeypairRequired = errors.New("format-2 rsw requires opts.Keypair") )
Sentinel errors for challenge validation and token verification. Use errors.Is(result, ErrXXX) to check the failure reason.
Functions ¶
func VerifyInstrumentationResult ¶
func VerifyInstrumentationResult( meta *InstrumentationMeta, payload *InstrumentationResult, ) (bool, string)
VerifyInstrumentationResult checks whether the client's instrumentation response is valid. It verifies that the ID matches and that each variable in the client's state has the expected value.
func VerifyRswSolution ¶
VerifyRswSolution verifies that the claimed y solution matches the expected y. Comparison is case-insensitive and ignores leading zeros and optional 0x prefix.
Types ¶
type Cap ¶
type Cap struct {
// contains filtered or unexported fields
}
Cap is an instance-based API for creating and redeeming challenges. Create one via NewCap and reuse it across requests.
func NewCap ¶
func NewCap(opts CapOptions) (*Cap, error)
NewCap creates a Cap instance with the given options. Secret must be at least 16 bytes. When Format is 2, at least one protocol must be specified in Protocols.
func (*Cap) CreateChallenge ¶
func (c *Cap) CreateChallenge(ctx context.Context) (ChallengeResult, error)
CreateChallenge creates challenge parameters and a signed challenge token. When Format is 2, it returns a multi-protocol challenge; otherwise a format-1 PoW challenge.
func (*Cap) RedeemChallenge ¶
func (c *Cap) RedeemChallenge(ctx context.Context, proof ValidateBody) (ValidateResult, error)
RedeemChallenge verifies PoW solutions and returns a validation result.
On success (result.Success == true), result.Token and result.Expires are set. The token can later be verified with ValidateToken.
On validation failure (result.Success == false), result.Reason describes the error and errors.Is can identify the specific reason.
Internal errors (storage, config) are returned as the error value.
func (*Cap) ValidateToken ¶
func (c *Cap) ValidateToken(ctx context.Context, token string) ValidateResult
ValidateToken reports whether a validation token exists and is not expired. Returns a ValidateResult that can be checked with errors.Is:
vr := cap.ValidateToken(ctx, token)
if errors.Is(vr, ErrTokenNotFound) { ... }
type CapOptions ¶
type CapOptions struct {
// Secret is used to sign and verify challenge tokens via HMAC-SHA256.
// Must be at least 16 bytes; 32+ recommended.
Secret []byte
// Storage persists challenge and validation tokens. If nil, token
// verification via ValidateToken will fail with storage_not_configured.
Storage Storage
// ChallengeCount sets the number of PoW items (format-1, default 50).
ChallengeCount int
// ChallengeSize sets the salt length in hex characters (format-1,
// default 32).
ChallengeSize int
// ChallengeDifficulty sets the target prefix hex length (format-1,
// default 4).
ChallengeDifficulty int
// ChallengeTTL controls challenge token lifetime (default 10 minutes).
ChallengeTTL time.Duration
// Scope binds the challenge to a logical site or action.
Scope string
// Extra is embedded in the challenge token payload as the "x" field.
Extra map[string]any
// TokenTTL controls validation token lifetime (default 20 minutes).
TokenTTL time.Duration
// ConsumeNonce provides replay protection. See ValidateOptions.
ConsumeNonce func(ctx context.Context, signatureHex string, ttl time.Duration) (bool, error)
// SignToken overrides the default validation token format. See
// ValidateOptions.
SignToken func(data RedeemTokenData) (string, error)
// Format selects the challenge format. 0 or 1 for format-1 (default),
// 2 for the multi-protocol format-2.
Format int
// Protocols lists the protocols for format-2 challenges. Required when
// Format is 2.
Protocols []string
// Keypair is the RSA-style keypair required for the "rsw" protocol.
Keypair *RswKeypair
// RSWT is the sequential squaring count for RSW puzzles (default 75000).
RSWT int
// Instrumentation enables format-1 client-side browser checks.
Instrumentation bool
// InstrumentationOpts fine-tunes instrumentation behaviour.
InstrumentationOpts *InstrumentationOptions
}
CapOptions configures a Cap instance. All fields are optional except Secret, which must be at least 16 bytes.
type ChallengeResult ¶
type ChallengeResult struct {
// Challenge contains the format-1 PoW parameters (omitted for format-2).
Challenge ChallengeSpec `json:"challenge"`
// Token is the signed JWT carrying the challenge parameters.
Token string `json:"token"`
// Expires is the challenge expiration time in Unix milliseconds.
Expires int64 `json:"expires"`
// Instrumentation is an opaque blob for client-side browser checks
// (format-1 only, base64-encoded deflated JS in the JS reference impl).
Instrumentation string `json:"instrumentation,omitzero"`
// Format is 2 when the result uses the multi-protocol format, 0 or
// omitted for format-1.
Format int `json:"format,omitzero"`
// Challenges is the protocol-specific challenge list (format-2 only).
Challenges []V2Challenge `json:"challenges,omitzero"`
}
ChallengeResult is the challenge payload returned by CreateChallenge.
type ChallengeSpec ¶
type ChallengeSpec struct {
// C is the number of PoW items the client must solve.
C int `json:"c"`
// S is the salt length in hex characters.
S int `json:"s"`
// D is the target prefix difficulty in hex characters.
D int `json:"d"`
}
ChallengeSpec describes the format-1 PoW parameters sent to the client.
type GenerateOptions ¶
type GenerateOptions struct {
// ChallengeCount sets the number of PoW items (default 50).
ChallengeCount int
// ChallengeSize sets the salt length in hex characters (default 32).
ChallengeSize int
// ChallengeDifficulty sets the target prefix length in hex characters
// (default 4).
ChallengeDifficulty int
// Expires sets the challenge lifetime (default 10 minutes).
Expires time.Duration
// Scope binds the challenge to a logical site or action.
Scope string
// Extra is embedded in the challenge token payload under key "x".
Extra map[string]any
// Storage persists challenge and validation tokens.
Storage Storage
// Instrumentation enables format-1 client-side browser checks when true.
Instrumentation bool
// InstrumentationOpts fine-tunes instrumentation behaviour.
InstrumentationOpts *InstrumentationOptions
// Format selects the challenge format. 0 or 1 for format-1 (default),
// 2 for the multi-protocol format-2.
Format int
// Protocols lists the protocols to include in a format-2 challenge.
// Valid values: "sha256-pow", "rsw", "instrumentation".
Protocols []string
// Keypair is the RSA-style keypair needed for the "rsw" protocol.
Keypair *RswKeypair
// RSWT is the number of sequential squarings for RSW puzzles
// (default 75000).
RSWT int
}
GenerateOptions configures challenge generation. Most fields map directly to CapOptions fields. Zero values use defaults.
type InstrPayload ¶
type InstrPayload struct {
// I is the instrumentation challenge ID.
I string `json:"i"`
// State maps variable names to their computed values.
State map[string]int `json:"state"`
// Ts is the client timestamp in Unix milliseconds.
Ts int64 `json:"ts,omitzero"`
}
InstrPayload is the client-side instrumentation result payload.
type InstrumentationMeta ¶
type InstrumentationMeta struct {
ID string `json:"id"`
ExpectedVals []int `json:"expectedVals"`
Vars []string `json:"vars"`
BlockAutomatedBrowsers bool `json:"blockAutomatedBrowsers"`
Expires int64 `json:"expires"`
}
InstrumentationMeta holds the metadata needed to verify a client-side instrumentation result. It is encrypted and embedded in the JWT payload when instrumentation is enabled.
func GenerateInstrumentation ¶
func GenerateInstrumentation(opts InstrumentationOptions) (*InstrumentationMeta, string, error)
GenerateInstrumentation generates random instrumentation parameters. It returns the metadata (to be encrypted and embedded in the JWT) and a JSON blob describing the parameters for the client-side widget.
type InstrumentationOptions ¶
type InstrumentationOptions struct {
// BlockAutomatedBrowsers, when true, causes the server to reject
// requests where the instrumentation was blocked.
BlockAutomatedBrowsers bool
// ObfuscationLevel controls the complexity of client-side code
// obfuscation (1-10, default 3). Higher values produce more
// obfuscated but larger scripts.
ObfuscationLevel int
// TTLMs is the instrumentation challenge lifetime in milliseconds.
// Defaults to 5 minutes when not set.
TTLMs int64
}
InstrumentationOptions configures instrumentation generation behaviour.
type InstrumentationResult ¶
type InstrumentationResult struct {
// I is the instrumentation challenge ID, matching InstrumentationMeta.ID.
I string `json:"i"`
// State maps each variable name to its computed client-side value.
State map[string]int `json:"state"`
// Ts is the client timestamp in Unix milliseconds.
Ts int64 `json:"ts,omitzero"`
}
InstrumentationResult is the client-side instrumentation result payload, submitted back to the server for verification.
type MemoryStorage ¶
type MemoryStorage struct {
// contains filtered or unexported fields
}
MemoryStorage is an in-memory Storage implementation backed by maps. Concurrent reads use RLock; writes and cleanup use an exclusive Lock. Suitable for development and testing; for production, use a persistent backend via the Storage interface.
func NewMemoryStorage ¶
func NewMemoryStorage() *MemoryStorage
NewMemoryStorage creates a new in-memory Storage implementation.
func (*MemoryStorage) CleanupExpired ¶
func (s *MemoryStorage) CleanupExpired(_ context.Context) error
CleanupExpired removes expired entries from the in-memory store.
func (*MemoryStorage) GetChallengeTokenExpiry ¶
func (s *MemoryStorage) GetChallengeTokenExpiry( _ context.Context, token string, ) (time.Time, bool, error)
GetChallengeTokenExpiry returns a stored challenge token's expiration time.
func (*MemoryStorage) GetValidationTokenExpiry ¶
func (s *MemoryStorage) GetValidationTokenExpiry( _ context.Context, token string, ) (time.Time, bool, error)
GetValidationTokenExpiry returns a stored validation token's expiration time.
func (*MemoryStorage) SetChallengeToken ¶
func (s *MemoryStorage) SetChallengeToken( _ context.Context, token string, expiresAt time.Time, ) error
SetChallengeToken stores a challenge token in memory.
func (*MemoryStorage) SetValidationToken ¶
func (s *MemoryStorage) SetValidationToken( _ context.Context, token string, expiresAt time.Time, ) error
SetValidationToken stores a validation token in memory.
type RedeemTokenData ¶
type RedeemTokenData struct {
// Scope is the original challenge scope, or nil if unset.
Scope *string `json:"scope"`
// Expires is the redeem token expiration time in Unix milliseconds.
Expires int64 `json:"expires"`
// Iat is the original challenge issue-at time in Unix milliseconds.
Iat int64 `json:"iat,omitzero"`
}
RedeemTokenData contains the data passed to the SignToken callback.
type RswKeypair ¶
RswKeypair holds the RSA-style modulus and its prime factors for RSW time-lock puzzles. Generate once at startup with GenerateRswKeypair and reuse across requests. Keep the private factors (P, Q) secret.
func DeserializeRswKeypair ¶
func DeserializeRswKeypair(s *SerializedRswKeypair) (*RswKeypair, error)
DeserializeRswKeypair restores a keypair from its serialized form. Returns an error if the serialized data is nil or contains invalid big integer strings.
func GenerateRswKeypair ¶
func GenerateRswKeypair(bits ...int) (*RswKeypair, error)
GenerateRswKeypair generates an RSA-style keypair with the given bit size for use with RSW time-lock puzzles. bits must be even; default is 2048. Generation is expensive (hundreds of milliseconds to seconds); generate once at startup and reuse.
type RswMintResult ¶
RswMintResult is the output of a single RSW mint operation. X_hex is the challenge value sent to the client; Y_hex is the expected solution kept server-side.
type RswMinter ¶
type RswMinter struct {
N *big.Int
T int
G *big.Int
H *big.Int
ModulusBytes int
N_hex string
G_hex string
H_hex string
// contains filtered or unexported fields
}
RswMinter creates RSW time-lock puzzle challenges using repeated squaring. Build one with BuildRswMinter and reuse it for multiple Mint calls.
func BuildRswMinter ¶
func BuildRswMinter(args struct {
N *big.Int
P *big.Int
Q *big.Int
T int
G *big.Int
},
) (*RswMinter, error)
BuildRswMinter builds a minter that creates RSW puzzles using the given keypair and squaring count t. The minter uses CRT-optimized modular exponentiation for efficient puzzle generation.
func (*RswMinter) Mint ¶
func (m *RswMinter) Mint() (*RswMintResult, error)
Mint creates a new RSW puzzle and returns the challenge x_hex and expected solution y_hex. The caller sends x_hex to the client and keeps y_hex server-side for later verification.
type SerializedRswKeypair ¶
type SerializedRswKeypair struct {
N string `json:"N"`
P string `json:"p"`
Q string `json:"q"`
Bits int `json:"bits,omitzero"`
}
SerializedRswKeypair is a JSON-safe representation of RswKeypair, suitable for storing in environment variables, config files, or databases.
func SerializeRswKeypair ¶
func SerializeRswKeypair(kp *RswKeypair) *SerializedRswKeypair
SerializeRswKeypair serializes a keypair for JSON storage or transmission. The returned struct uses decimal string encoding for big integers.
type Storage ¶
type Storage interface {
// SetChallengeToken stores a challenge token with its expiration time.
SetChallengeToken(ctx context.Context, token string, expiresAt time.Time) error
// GetChallengeTokenExpiry returns the expiration time for a stored
// challenge token. The bool indicates whether the token was found.
GetChallengeTokenExpiry(ctx context.Context, token string) (time.Time, bool, error)
// SetValidationToken stores a validation token with its expiration time.
SetValidationToken(ctx context.Context, token string, expiresAt time.Time) error
// GetValidationTokenExpiry returns the expiration time for a stored
// validation token. The bool indicates whether the token was found.
GetValidationTokenExpiry(ctx context.Context, token string) (time.Time, bool, error)
// CleanupExpired removes expired challenge and validation tokens.
CleanupExpired(ctx context.Context) error
}
Storage is the persistence interface for challenge and validation tokens. Implementations must be safe for concurrent use.
All methods receive a context.Context for cancellation and tracing. Expired entries should be removed lazily via CleanupExpired.
type V2Challenge ¶
type V2Challenge struct {
// Protocol identifies the challenge type: "sha256-pow", "rsw", or
// "instrumentation".
Protocol string `json:"protocol"`
// Payload carries protocol-specific parameters (salt/target for PoW,
// N/x/t for RSW, blob for instrumentation).
Payload map[string]any `json:"payload"`
}
V2Challenge represents a single protocol-specific challenge in format-2.
type V2Solution ¶
type V2Solution struct {
// Protocol identifies the challenge type this solution is for: must
// match the corresponding V2Challenge.Protocol.
Protocol string `json:"protocol"`
// Nonce is the PoW solution nonce for "sha256-pow" protocol.
Nonce any `json:"nonce,omitzero"`
// Y is the RSW solution for the "rsw" protocol.
Y string `json:"y,omitzero"`
// Blocked indicates the instrumentation was blocked. Set for
// "instrumentation" protocol as an alternative to Instr/Timeout.
Blocked *bool `json:"blocked,omitzero"`
// Timeout indicates the instrumentation timed out. Set for
// "instrumentation" protocol as an alternative to Instr/Blocked.
Timeout *bool `json:"timeout,omitzero"`
// Instr carries the full instrumentation result for "instrumentation"
// protocol instead of using Blocked/Timeout shortcuts.
Instr any `json:"instr,omitzero"`
}
V2Solution is a protocol-specific solution for format-2. Only the fields relevant to the protocol need to be set.
type ValidateBody ¶
type ValidateBody struct {
// Token is the challenge token issued by CreateChallenge.
Token string `json:"token"`
// Solutions contains the PoW nonces for format-1 challenges.
Solutions []int64 `json:"solutions"`
// V2Sol contains protocol-specific solutions for format-2 challenges.
V2Sol []V2Solution `json:"v2Sol,omitzero"`
// Instr carries the instrumentation result payload (format-1).
Instr *InstrPayload `json:"instr,omitzero"`
// Blocked reports that instrumentation determined the page was blocked.
Blocked bool `json:"instr_blocked,omitzero"`
// Timeout reports that instrumentation timed out.
Timeout bool `json:"instr_timeout,omitzero"`
}
ValidateBody is the request body for RedeemChallenge.
type ValidateOptions ¶
type ValidateOptions struct {
// Scope must match the original challenge scope when set.
Scope string
// TokenTTL controls the lifetime of the issued validation token
// (default 20 minutes).
TokenTTL time.Duration
// Storage persists the issued validation token for later verification.
Storage Storage
// ConsumeNonce provides replay protection. The callback receives the
// hex-encoded HMAC signature of the challenge token and its remaining
// TTL. Return true to claim the nonce (first use), false for replay.
ConsumeNonce func(ctx context.Context, signatureHex string, ttl time.Duration) (bool, error)
// SignToken overrides the default validation token format. If nil, a
// random id:verToken format is used. The callback receives the scope,
// expiration, and original issue time.
SignToken func(data RedeemTokenData) (string, error)
}
ValidateOptions configures challenge validation behaviour.
type ValidateResult ¶
type ValidateResult struct {
// Success reports whether validation passed.
Success bool `json:"success"`
// Reason describes the failure reason when Success is false. It matches
// one of the ErrXXX sentinel error strings.
Reason string `json:"reason,omitzero"`
// ErrorStr carries a lower-level error message (e.g. a storage error).
ErrorStr string `json:"error,omitzero"`
// InstrErr is true when the failure is instrumentation-related.
InstrErr bool `json:"instr_error,omitzero"`
// Token is the issued validation token on success.
Token string `json:"token,omitzero"`
// TokenKey is an alternative lookup key for the default token format.
// Clients may store this instead of the full token for privacy.
TokenKey *string `json:"tokenKey,omitzero"`
// Expires is the validation token expiration in Unix milliseconds.
Expires int64 `json:"expires,omitzero"`
// Scope is the validated scope, if any.
Scope *string `json:"scope"`
// Iat is the original challenge issue-at time in Unix milliseconds.
Iat *int64 `json:"iat,omitzero"`
// contains filtered or unexported fields
}
ValidateResult is the outcome of RedeemChallenge or ValidateToken.
It implements the error interface, so callers can use errors.Is/As:
if errors.Is(result, ErrExpired) { ... }
When Success is false, Error() returns the Reason string and Unwrap() returns the corresponding sentinel error.
func (ValidateResult) Error ¶
func (r ValidateResult) Error() string
Error implements the error interface. It returns the Reason string, or "success" when the validation passed.
func (ValidateResult) Unwrap ¶
func (r ValidateResult) Unwrap() error
Unwrap returns the sentinel error, enabling errors.Is(r, ErrExpired) etc.