Documentation
¶
Overview ¶
Package gate holds the four things a link can put in front of its destination: a password, a signature, a one-time budget and a click ceiling.
It exists as a package rather than as more methods on the redirect handler because M36 reuses the budget counter, and because the shape of each gate is a decision worth reading in one place rather than inferred from where it is called. Everything here is consulted **only** for a link whose cached snapshot says it is gated; a link with no gates never reaches this package at all, which is what keeps the 20ms budget the property of the ungated path it has always been.
Index ¶
- Constants
- Variables
- func ClickLimit(oneTime bool, maxClicks *int64) (int64, bool)
- func NewSecret() ([]byte, error)
- func Sign(secret []byte, domainID uuid.UUID, alias string, expires time.Time) (url.Values, error)
- func StripSignature(raw string) string
- func Verify(secret []byte, domainID uuid.UUID, alias string, q url.Values, now time.Time) error
- type Config
- type Service
- func (s *Service) Budget(ctx context.Context, linkID uuid.UUID) (consumed int64, exhaustedAt *time.Time, err error)
- func (s *Service) Consume(ctx context.Context, linkID, workspaceID uuid.UUID, limit int64) (bool, error)
- func (s *Service) EnsureSecret(ctx context.Context, workspaceID uuid.UUID) ([]byte, error)
- func (s *Service) PeekRotation(ctx context.Context, linkID, workspaceID uuid.UUID) (int64, error)
- func (s *Service) Rotate(ctx context.Context, linkID, workspaceID uuid.UUID) (int64, error)
- func (s *Service) Secret(ctx context.Context, workspaceID uuid.UUID) ([]byte, error)
- func (s *Service) VerifyPassword(ctx context.Context, linkID uuid.UUID, password string) (bool, error)
Constants ¶
const ( // SigParam and ExpParam are the two query parameters a signed URL carries. // Both are stripped before the query is forwarded to the destination: they // are addressed to this server, and leaking a workspace's signature to // whoever runs the destination would hand them a URL they can replay until // it expires. SigParam = "sig" ExpParam = "exp" // SecretLength is how many random bytes a workspace secret carries. 32, the // output size of the hash it keys: more would be folded by HMAC's own // padding, less would be the weakest part of the construction. SecretLength = 32 )
The signed-URL format, and it is documented here because docs/SECURITY.md and the OpenAPI description both point at this comment rather than restating it.
https://<link host>/<alias>?exp=<unix seconds>&sig=<signature>
`sig` is the base64url encoding, unpadded, of HMAC-SHA256 over
"lc1\n" + <domain uuid> + "\n" + <canonical alias> + "\n" + <exp>
keyed by the workspace's signing secret.
Four things are in the message and each closes something.
The **version tag** means a later format can be introduced without the old one being reinterpreted under the new rules — a signature is a capability, and a capability whose meaning can change is not one.
The **domain id** binds the signature to the hostname it was minted for. Without it, a workspace serving the same alias on two domains — which is what M40's custom domains are for — would find a signature issued for one working on the other.
The **canonical alias** is the same spelling the resolver looked the link up under, so a signature cannot be made to verify by re-casing or re-encoding the path.
The **expiry** is inside the MAC rather than beside it. A signature whose expiry could be edited by whoever holds the URL expires when they say it does, which is to say never.
const DefaultDBTimeout = 250 * time.Millisecond
DefaultDBTimeout bounds one gate query when the caller configures nothing.
**Every query in this file is on the redirect path, and until F96 none of them was bounded by anything.** `RequestTimeout` wraps the application handler only; the redirect tree is mounted bare, deliberately, because `http.TimeoutHandler` buffers the response and would break the `Location` write and swallow the challenge page. There is no `statement_timeout` anywhere in this tree, and the pool sets connect and lifetime limits rather than per-query ones. So a query that stalled ran for as long as Postgres let it, holding a connection while requests queued behind it.
The bound is per call and it lives here rather than around the handler, which is the shape `redirect.Resolver` already uses one package over — for the reason stated there: a query still running after the budget is not going to produce a useful answer, it is going to hold a connection while more requests queue. The number matches `REDIRECT_TIMEOUT`, which the resolver takes for the same path, and `Config.DBTimeout` is how the process passes it in.
**None of these bounds detaches from the request context**, and that is the half worth stating. `Consume` writes: a client that disconnects mid-consume must not have spent a one-time link's only click on a redirect nobody received, so the cancellation has to reach Postgres. The resolver detaches because its result is shared by every waiter on a singleflight; nothing here is shared with anyone.
const DefaultSecretTTL = time.Minute
DefaultSecretTTL is how long a workspace signing secret is trusted in process.
Short, and the number is a revocation bound rather than a performance one. An operator who clears the column to invalidate every signature a workspace has issued — the only revocation there is, and docs/SECURITY.md says so — has to wait for each replica's copy to expire. One minute is a wait somebody can sit through; caching for an hour would make the revocation something nobody could rely on having happened.
Variables ¶
var ( ErrNoSignature = errors.New("gate: request carries no signature") ErrBadSignature = errors.New("gate: signature does not verify") ErrExpired = errors.New("gate: signature has expired") ErrNoSecret = errors.New("gate: workspace has no signing secret") )
Signature errors. Distinguished so the handler can log the cause while answering the same thing to the client either way — a caller that learns *which* way its signature was wrong learns something about the secret.
var ErrNoPassword = errors.New("gate: link has no password")
ErrNoPassword means the link carries no password hash, so there is nothing to verify against. Distinct from a mismatch: the caller answers 405 rather than re-serving the challenge, because a POST to a link with no password is a request the route does not accept rather than a wrong guess.
Functions ¶
func ClickLimit ¶
ClickLimit is the number of clicks a snapshot's gates permit, and whether there is a limit at all.
One function rather than a branch at the call site, because "one-time" and "max 5" are the same gate with different numbers and the redirect path should not have to know that. Both set is the smaller of the two: a one-time link with max_clicks=10 is a one-time link, and reading it any other way would let the wider setting quietly widen the narrower one.
func NewSecret ¶
NewSecret returns fresh key material for a workspace.
crypto/rand, and the error is returned rather than swallowed: a signing secret drawn from a source that failed would verify signatures nobody had to know a key to make.
func Sign ¶
Sign returns the query parameters a signed URL for this alias must carry.
The caller assembles the URL, because what the public origin is depends on configuration this package does not read.
func StripSignature ¶
StripSignature removes the signature parameters from a raw query string.
Applied before the query is forwarded to the destination (M8's forward_query), so a signed URL that also forwards its query does not hand the destination a replayable capability. Returns the query unchanged when it carries neither parameter, which is every request to every ungated link.
func Verify ¶
Verify checks the signature on an incoming request's query.
Nothing here reads the database, allocates beyond the MAC, or depends on Redis: the secret arrives from the caller's in-process keyring and the rest is a hash over four short strings. That is what makes this affordable on the hot path for the links that ask for it.
Types ¶
type Config ¶
type Config struct {
// Hasher verifies link passwords. Required for the password gate; a nil
// Hasher makes every password check fail closed rather than pass.
Hasher *auth.Hasher
// SecretTTL overrides DefaultSecretTTL.
SecretTTL time.Duration
// DBTimeout overrides DefaultDBTimeout. The process passes REDIRECT_TIMEOUT.
DBTimeout time.Duration
// Now overrides the clock, for tests.
Now func() time.Time
}
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service answers the three questions a gated redirect asks Postgres: does this password match, does this workspace's key verify this signature, and is there any budget left.
Postgres and not Redis, for all three, and the reason is the inherited rule rather than a preference. The cache is optional: an instance with Redis switched off must still refuse the second visit to a one-time link and must still reject a wrong password. A counter that disappears with the cache re-opens every spent link at once, which is not a degraded mode — it is the gate not existing.
func (*Service) Budget ¶
func (s *Service) Budget(ctx context.Context, linkID uuid.UUID) (consumed int64, exhaustedAt *time.Time, err error)
Budget reports how much of a link's allowance has been spent, without spending any of it.
Two callers, and the second one is why this is worth a sentence. The dashboard reads it to show a ceiling's remainder. The redirect path reads it for **HEAD only** — a request asking whether the link is alive, which must be answered 410 when the budget is gone and must not consume the budget in order to find out. A GET never calls it: Consume answers the same question by writing, and a read in front of that write would be a query on every gated redirect for information the write already returns.
func (*Service) Consume ¶
func (s *Service) Consume(ctx context.Context, linkID, workspaceID uuid.UUID, limit int64) (bool, error)
Consume spends one click of a link's durable budget, reporting whether there was one to spend.
False means the link has been followed as often as it may be, and the caller answers 410 — the alias existed and is now spent, which is exactly what Gone is for and exactly what a crawler should stop retrying.
Errors are **not** treated as exhaustion. A database that cannot answer is a failure of ours, and refusing a link over it would turn a blip into a permanent-looking 410 that link checkers act on; the caller answers 503 instead. The direction is deliberate and it is the opposite of the fail-open choice the rate limiter makes, because the thing being protected is different: a limiter that under-counts costs an attacker a little less work, while a budget that miscounts either sends somebody to a destination they should not see or destroys a link that was fine.
func (*Service) EnsureSecret ¶
EnsureSecret returns the workspace's signing secret, minting one on first use.
Lazy rather than at workspace creation, so the column stays NULL for every workspace that never signs anything and the presence of a secret is itself a statement that somebody asked for one.
**Not bounded by DBTimeout, and it is the only method here that is not.** This is the one call on the management path — minting a key is `links.update` through the API — so it is already inside `RequestTimeout`, and applying the redirect path's budget to it would give a dashboard write a 250ms ceiling it never had.
func (*Service) PeekRotation ¶
PeekRotation reports the position the next GET would get, without advancing.
HEAD only, and the reasoning is Budget's below: a request asking *what does this link do* must be answered with what a visitor would get, and must not change it. A link checker probing a sequentially split link used to advance the durable counter on every probe, re-phasing every subsequent visitor's arm with no click recorded to explain it (F100).
No row means nothing has clicked this link yet, which is position 1 — the first arm. `pgx.ErrNoRows` is the ordinary case here rather than a failure.
func (*Service) Rotate ¶
Rotate advances a link's sequential split and returns the position it advanced to, counting from one.
The same durable counter argument as Consume, in a different column and without a limit: a rotation is not spent, it advances, and there is nothing for it to run out of. It lives in this service because the row it writes is the row Consume writes — one table, one upsert shape, one place where "a counter Redis cannot hold" is implemented.
An error is returned rather than swallowed, and the caller answers 503. The alternative — choosing an arm anyway — would make the order approximate, which is the one property D8 refused.
func (*Service) Secret ¶
Secret returns a workspace's signing secret, from the in-process cache when it is fresh.
A workspace that has never minted one yields ErrNoSecret, and the negative is cached like the positive: an instance where nothing is signed must not answer a scanner's `?sig=` with a database query per request.
func (*Service) VerifyPassword ¶
func (s *Service) VerifyPassword(ctx context.Context, linkID uuid.UUID, password string) (bool, error)
VerifyPassword checks a submitted password against the link's stored hash.
**The hash is read here and nowhere else.** It is deliberately absent from the cached snapshot — which carries a bare `HasPassword` boolean — so that whatever can read the cache cannot walk away with an offline cracking target for every password link on the instance. The cost of that decision is this query, and it lands only on a submitted password: rendering the challenge never runs it.