pow

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: Apache-2.0 Imports: 12 Imported by: 0

README

github.com/ubgo/auth/plugin/pow

A proof-of-work challenge: when a rate rule (or a geo rule) with the challenge action finds a call suspicious, the client must spend CPU time solving a hashcash puzzle before the call is let through. It is the auth.RateChallenger for an app that wants a challenge with no third party in the loop: no captcha provider, no tracking, nothing leaves your server. The guide is docs/plugins/pow.md.

Install

go get github.com/ubgo/auth

The package is in the core module github.com/ubgo/auth, so it brings no extra dependency beyond github.com/ubgo/sqlb.

Wire it

Register the plugin and give a rate rule the challenge action. Under the limit nothing changes; over it, the call answers 428 auth.challenge_required until it carries a proof:

a, err := auth.New(
	auth.WithDB(db, sqlb.SQLiteDialect{}),
	auth.WithBaseURL(baseURL),
	auth.WithPlugins(email.New(email.Login(), email.Recover(), email.WithMailer(m)), password.New(), pow.New()),
	auth.WithRateRules(auth.RateRule{Match: password.EndpointSignIn, Key: auth.ByIP, Limit: 5, Window: time.Minute, Action: auth.RateChallenge}),
)

examples/pow-challenge is a runnable program with a test.

The client's side:

  1. POST /auth/pow/challenge answers {"challenge":"<hex>","difficulty":20,"algorithm":"sha256","expiresAt":"…"}.
  2. Find a decimal nonce such that SHA-256 of <challenge>:<nonce> starts with at least difficulty zero bits. About a million hashes at the default: a fraction of a second in Go, a second or two in a browser.
  3. Send the call again with the header X-Pow-Proof: <challenge>.<nonce>.

pow.Solve(ctx, ch) is the reference solver, for tests, a CLI or a desktop client; it stops when ctx ends. A browser runs the same loop in script.

The plugin also answers a geo rule (auth.WithGeoRules or auth.WithImpossibleTravel) whose Action is auth.RateChallenge. One proof serves every rule that challenges the same call (pow.AnnotationVerified is set on the call once it is accepted), so a call that a rate rule and a geo rule both challenge does not need two.

Options

Option What it does Default
pow.New(opts...) The plugin
pow.Difficulty(bits) Leading zero bits a proof needs, 1 to MaxDifficulty (32). Each bit doubles the client's expected work; the server's cost stays one hash DefaultDifficulty, 20
pow.ChallengeTTL(d) How long a challenge can be solved and used, positive and at most MaxChallengeTTL (one hour) DefaultChallengeTTL, 5 minutes
pow.WithHeader(name) Read the proof from another header; must be a valid HTTP header name X-Pow-Proof (pow.ProofHeader)

An option out of range makes auth.New fail naming the option. Registering the plugin together with plugin/captcha fails auth.New too: an app has one rate challenger.

Routes and Go API

Route Go What it does
POST /auth/pow/challenge (*Plugin).Challenge(ctx) Issues a challenge (pow.Challenge: challenge, difficulty, algorithm, expiresAt). Operation id pow.challenge (pow.EndpointChallenge); limited to 60 a minute per address, since each call writes a row
Function Use
pow.Solve(ctx, ch) Finds the proof for a challenge and returns the header value
(*Plugin).PassChallenge(ctx, call) The auth.RateChallenger method the core calls; not called by apps

Tables and columns

None of its own. A challenge is a single-use verification token in the core's store (purpose pow.challenge, found by its hash), removed by a.Sweep once it expires; a spent challenge also leaves a short-lived marker in the rate store.

Errors

Error variable Code HTTP When
auth.ErrChallengeRequired auth.challenge_required 428 A challenged call carries no proof: fetch a challenge, solve it, send the call again
ErrProofInvalid pow.proof_invalid 403 The proof is malformed or does not carry the work the challenge asked for
ErrChallengeInvalid pow.challenge_invalid 400 The challenge is unknown, expired or already used, one answer for the three; ask for a new one

Security notes

Property Pinned by
The challenge, the proof header and the retry work over HTTP on every engine TestPoW_ChallengeFlowOverHTTP (integration)
A malformed proof, a proof short of the difficulty and a reused or unknown challenge are refused TestPoW_RefusesProofsThatAreNotWorth (integration)
A proof is judged by the stricter of the configured difficulty and the one the challenge was issued at, so lowering the setting does not cheapen an issued challenge and raising it applies at once TestPoW_ChallengeIsJudgedByTheStricterDifficulty (integration)
A proof spent in a sign-in whose transaction rolled back cannot be used again TestPoW_AProofSpentInARolledBackSignInIsStillSpent (integration)
One proof serves a rate rule and a geo rule on the same call TestPoW_OneProofServesTwoChallengingRules (integration)
A direct Go call is never challenged TestPoW_AGoCallIsNotChallenged (integration)
Every operation answers on the wire with proof of work as the challenger TestWireContract_ProofOfWork (integration)

Bad proofs are cheap to refuse: the shape and the work are checked with one hash before the store is read. The challenge is 128 random bits and is looked up by its hash, never compared as plaintext.

Limits: proof of work makes a flood cost the sender CPU time per attempt; it is not a bot detector. An attacker with enough hardware pays the cost and carries on, so keep the rate limits, and use a captcha where a human check is what you need.

Example

Documentation

Overview

Package pow is a proof-of-work challenge: a client proves it spent CPU time before a call that a rate rule found suspicious is let through. It is the auth.RateChallenger of an app that wants a challenge with no third party in the loop (no captcha provider, no user tracking), so an auth.RateRule with Action auth.RateChallenge asks the client for work instead of refusing it.

The scheme is hashcash. The server issues a random challenge (POST /auth/pow/challenge, or Plugin.Challenge from Go) with a difficulty D. The client finds a decimal nonce such that SHA-256 of "<challenge>:<nonce>" starts with at least D zero bits, and sends "<challenge>.<nonce>" in the ProofHeader of the call that was answered 428 auth.challenge_required. A challenge is single use, expires (DefaultChallengeTTL) and is spent only by a proof that carries enough work; Solve does the search in Go, for tests and for a CLI or desktop client.

What it is for: it makes a flood of sign-in guesses cost the sender CPU time per guess instead of nothing, which a human behind a busy shared address barely notices once. What it is not: a bot detector. A determined attacker with a GPU farm pays the cost and carries on, so keep the rate limits and use it where a captcha is not welcome.

Index

Constants

View Source
const (
	// DefaultDifficulty is 20 zero bits: about a million hashes, a fraction of
	// a second in Go and a second or two in a browser.
	//ds:def id=pow-defaultdifficulty-2vk9wq6n owner=@khanakia stability=stable
	DefaultDifficulty = 20
	// MaxDifficulty caps Difficulty. 2^32 hashes is beyond a phone and a
	// browser tab; a higher setting is a lock-out, not a challenge.
	MaxDifficulty = 32
	// DefaultChallengeTTL is how long a challenge can be solved and used.
	//ds:def id=pow-defaultchallengettl-8nr3xk5m owner=@khanakia stability=stable
	DefaultChallengeTTL = 5 * time.Minute
	// MaxChallengeTTL caps ChallengeTTL: an unsolved challenge is a row in the
	// database until it expires or a sweep removes it.
	MaxChallengeTTL = time.Hour
)

Defaults and bounds.

View Source
const AlgorithmSHA256 = "sha256"

AlgorithmSHA256 names the only hash this plugin issues, so a client can refuse a challenge for an algorithm it does not know.

View Source
const AnnotationVerified = "pow.verified"

AnnotationVerified is the auth.Call annotation set once the call's proof was accepted: a proof is single use, so a call that two rules challenge (a rate rule and a geo rule) must not spend it twice. The value is "true", never the proof.

View Source
const EndpointChallenge auth.EndpointID = "pow.challenge"

EndpointChallenge is the operation that issues a challenge, exported so a rate rule or a hook can name it. A wire name, stable across releases.

View Source
const PluginName = "pow"

PluginName is the plugin's permanent identity and route prefix.

View Source
const ProofHeader = "X-Pow-Proof"

ProofHeader is the request header the proof travels in.

View Source
const PurposeChallenge auth.Purpose = "pow.challenge"

PurposeChallenge is the verification purpose of a challenge. Its Identifier is the difficulty it was issued at, so a proof is judged by the difficulty the challenge carried, whatever the plugin is configured with by then.

Variables

View Source
var (
	// ErrProofInvalid: the proof is malformed, or does not carry the work the
	// challenge asked for.
	ErrProofInvalid = auth.NewError("pow.proof_invalid", http.StatusForbidden, "The proof of work was not accepted.")
	// ErrChallengeInvalid: the challenge is unknown, expired or already used.
	// The three are one answer, like auth.ErrTokenInvalid; the client asks for
	// a new challenge.
	ErrChallengeInvalid = auth.NewError("pow.challenge_invalid", http.StatusBadRequest, "The challenge has expired. Request a new one.")
)

Functions

func Solve

func Solve(ctx context.Context, ch Challenge) (string, error)

Solve finds the proof for ch: it tries nonces 0, 1, 2, … until the hash has ch.Difficulty leading zero bits, and returns the value for the ProofHeader. It stops with the context's error when ctx ends, so a client can bound the search. It is the reference solver, for tests, a CLI and a desktop app; a browser uses the same loop in script.

Types

type Challenge

type Challenge struct {
	// Challenge is the random value to hash, hex text.
	Challenge string `json:"challenge"`
	// Difficulty is the number of leading zero bits the hash must have.
	Difficulty int `json:"difficulty"`
	// Algorithm is AlgorithmSHA256.
	Algorithm string `json:"algorithm"`
	// ExpiresAt is when the challenge stops working.
	ExpiresAt time.Time `json:"expiresAt"`
}

Challenge is what the server issues and the client solves.

type Option

type Option func(*Plugin)

Option configures the plugin.

func ChallengeTTL

func ChallengeTTL(d time.Duration) Option

ChallengeTTL sets how long a challenge works (default DefaultChallengeTTL), at most MaxChallengeTTL.

func Difficulty

func Difficulty(bitsRequired int) Option

Difficulty sets the leading zero bits a proof needs, 1 to MaxDifficulty (default DefaultDifficulty). Each bit doubles the client's expected work and the server's cost stays one hash.

func WithHeader

func WithHeader(name string) Option

WithHeader renames the header the proof arrives in (default ProofHeader). It must be a valid HTTP header name.

type Plugin

type Plugin struct {
	// contains filtered or unexported fields
}

Plugin is the proof-of-work challenger. Build it with New.

func New

func New(opts ...Option) *Plugin

New builds the plugin.

func (*Plugin) Challenge

func (p *Plugin) Challenge(ctx context.Context) (Challenge, error)

Challenge issues a challenge: the typed form of POST /auth/pow/challenge.

func (*Plugin) Init

func (p *Plugin) Init(a *auth.Auth) error

Init keeps the core handle the challenge and the replay marker use.

func (*Plugin) Name

func (p *Plugin) Name() string

Name returns PluginName.

func (*Plugin) Operations

func (p *Plugin) Operations() []auth.Operation

Operations lists the challenge route.

func (*Plugin) OptionErr

func (p *Plugin) OptionErr() error

OptionErr is every error the options recorded; auth.New refuses to start while it is not nil (auth.OptionChecker).

func (*Plugin) PassChallenge

func (p *Plugin) PassChallenge(ctx context.Context, c *auth.Call) error

PassChallenge is auth.RateChallenger. A Go call passes (the app's own code is not the flood); a call with no proof is auth.ErrChallengeRequired (428), so the client knows to fetch a challenge, solve it and send the call again; a proof is checked by verify.

func (*Plugin) RateLimits

func (p *Plugin) RateLimits() []auth.RateRule

RateLimits bounds challenge requests per address.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL