Documentation
¶
Overview ¶
Package authn @notice Proving who a caller is. This file: Argon2id password hashing with the parameters pinned, the concurrency bounded, and the encoding self-describing so cost can be raised later without a password reset.
Index ¶
- Constants
- func ValidateBaseURL(raw string) error
- func ValidatePassword(password string) error
- type Accounts
- func (a *Accounts) AcceptInvite(ctx context.Context, db orm.DB, token, password string) error
- func (a *Accounts) ChangePassword(ctx context.Context, db orm.DB, current, next string) error
- func (a *Accounts) Invite(ctx context.Context, db orm.DB, email string) (string, error)
- func (a *Accounts) Login(ctx context.Context, db orm.DB, email, password string) (*authz.Principal, error)
- func (a *Accounts) Logout(ctx context.Context, db orm.DB) error
- func (a *Accounts) Register(ctx context.Context, db orm.DB, email, password string) error
- func (a *Accounts) RequestPasswordReset(ctx context.Context, db orm.DB, email string) error
- func (a *Accounts) ResetPassword(ctx context.Context, db orm.DB, token, password string) error
- func (a *Accounts) VerifyEmail(ctx context.Context, db orm.DB, token string) error
- type AccountsOptions
- type Hasher
- type LogMailer
- type Mailer
- type Message
- type MessageKind
- type Params
- type Purpose
Constants ¶
const ( // MinPasswordLen @notice Minimum length in characters (runes, not bytes). MinPasswordLen = 8 // MaxPasswordLen @notice Maximum length in bytes — far past ASVS's "at least 64" floor. // // @dev The cap exists only to bound the hashing input; 1024 rejects nothing a human or a // password manager produces. MaxPasswordLen = 1024 )
Password policy (ASVS 6.2, NIST 800-63B): a minimum, a generous maximum, and nothing else. No composition rules, no periodic rotation, and the password is used exactly as received — no trimming, no case folding, no Unicode normalisation that loses information. Paste and password managers must work.
Variables ¶
This section is empty.
Functions ¶
func ValidateBaseURL ¶
ValidateBaseURL @notice Checks the origin emailed links are built under: absolute, https, and no path, query or fragment.
@dev Required with no default, because the alternative — deriving it from the request Host header — is Host-header injection, and a password-reset link is the last place to accept attacker-controlled input. http:// is permitted only for loopback, so local development works without a flag that would eventually be set in production.
@param raw the configured origin @return error a description of what is wrong with it, or nil
func ValidatePassword ¶
ValidatePassword @notice Applies the policy above with a client-visible INVALID_INPUT.
@param password the candidate, checked exactly as received @return error a *kalerr.Error with CodeInvalidInput, or nil
Types ¶
type Accounts ¶
type Accounts struct {
// contains filtered or unexported fields
}
Accounts @notice The credential flows: Login, Logout, ChangePassword — and, with the token flows, registration and recovery.
@dev Methods take orm.DB like everything else in kal. Login is expected to run inside the session middleware (it reads request attribution and sets the cookie through the context); calling it without the middleware fails loudly rather than issuing a session no client will ever hold.
func NewAccounts ¶
func NewAccounts(opts AccountsOptions) (*Accounts, error)
NewAccounts @notice Validates opts and prepares the statements.
@param opts Hasher, Sessions and Mailer are required @return *Accounts ready for concurrent use @return error a missing requirement or an invalid schema name
func (*Accounts) AcceptInvite ¶
AcceptInvite @notice Consumes an invite token, sets the first password and marks the address verified — arriving through the link is proof of control of the mailbox.
@param token the raw value from the emailed link @param password the first password; policy-checked here @return error INVALID_TOKEN or INVALID_INPUT
func (*Accounts) ChangePassword ¶
ChangePassword @notice Re-verifies the current password, stores the new one, and rotates the caller's session — a privilege change never rides on the old credential (ASVS 7.2.4).
@dev Rotate, not RevokeAllForUser: a routine change keeps other devices signed in. The recovery path (password reset) is the one that kills everything, because there the password is presumed compromised. The hash write is compare-and-swap on the verified hash, so two concurrent changes cannot silently clobber each other.
@param current the password being replaced; a mismatch counts as a login failure @param next the replacement; policy-checked here @return error UNAUTHENTICATED, INVALID_CREDENTIALS, INVALID_INPUT, RATE_LIMITED, or driver
func (*Accounts) Invite ¶
Invite @notice Creates a password-less account and emails an invitation, or re-invites an existing one. Returns the invite URL for a caller that delivers links itself.
@dev Not enumeration-sensitive the way Register is: the caller here is an authenticated administrator who is allowed to know whether an address is already a member. Guard the resolver with @auth(roles: [...]) — kal will not guess which role that is.
@param email the address to invite @return string the invite URL, also sent by mail @return error driver or mailer errors
func (*Accounts) Login ¶
func (a *Accounts) Login(ctx context.Context, db orm.DB, email, password string) (*authz.Principal, error)
Login @notice Verifies the credential and, on success, issues a session and sets the cookie.
@dev The order of operations is the design:
- Backoff check, before any hashing — otherwise the throttle still costs 19 MiB and a CPU slice per rejected attempt, and the defence is the DoS.
- Account select; an unknown, deleted or password-less account burns a real dummy verification so its timing matches a wrong password (see Hasher.VerifyDummy).
- Argon2 verify inside the concurrency bound; a weaker-than-configured stored hash is re-hashed on the spot — the only moment the cleartext exists to do it with.
- Unknown user, wrong password, unverified and disabled all return the identical INVALID_CREDENTIALS; the real reason goes to the log via Internal.
- Success resets the counters, notifies on a many-failures-then-success pattern, revokes any session the request arrived with (it is being replaced), issues a fresh one and sets the cookie — issuing-new-on-login is the fixation defence at this layer.
@param email matched case-insensitively; stored form decides the mailbox @param password verified exactly as received @return *authz.Principal the caller as the next request will see them @return error INVALID_CREDENTIALS, RATE_LIMITED, or a driver/mailer failure
func (*Accounts) Logout ¶
Logout @notice Revokes the caller's session and clears the cookie. Anonymous callers just get the cookie cleared — logging out twice is not an error.
@return error a driver failure, or the missing-middleware error from SetCookie
func (*Accounts) Register ¶
Register @notice Creates an account and sends either a verification link or a "someone tried to register with your address" notice — and answers the caller identically either way.
@dev This is the enumeration boundary, and it is why kal does not use luima's crud.Create here. crud.Create classifies SQLSTATE 23505 into a client-visible "… already exists", which is exactly the oracle a signup form must not be: send an address, learn whether it is registered. That behaviour is correct for crud.Create and wrong for this table, so this inserts directly and treats a duplicate as success from the caller's point of view.
The branch is in the mailbox, not in the response. The person who owns the address learns that someone tried to register it — which is what makes "check your email" an honest answer rather than merely an opaque one — while the caller cannot tell the two cases apart.
@param email normalised to lowercase before insert @param password policy-checked, then hashed within the concurrency bound @return error INVALID_INPUT for a policy failure, otherwise only driver or mailer errors
func (*Accounts) RequestPasswordReset ¶
RequestPasswordReset @notice Issues a reset token for the address, or sends a "no account here" notice — identical response either way, and nothing on the account changes.
@dev Two rules, each of which is a real incident somewhere:
- The account is not mutated. No lock, no cleared password, no flag that changes login behaviour. Anything changed on request is a denial of service anyone can trigger against any address, and the test asserts the old password still logs in afterwards.
- The link's origin comes from Config.BaseURL, never from the request Host header. Host header injection otherwise turns a reset email into an attacker-controlled link, and there is deliberately no way to derive the origin from the request.
Note for the consumer, documented rather than solvable here: the token is in a URL, so it leaks through Referer to any third-party asset on the landing page. Serve that page with Referrer-Policy: no-referrer and no third-party assets.
@param email the address to send to; unknown addresses are indistinguishable to the caller @return error only driver or mailer errors
func (*Accounts) ResetPassword ¶
ResetPassword @notice Consumes a reset token, sets the new password and kills every session the account had.
@dev Deliberately does not sign the caller in. Sending them through normal login is what keeps the recovery path from bypassing whatever else login requires — and it is the ASVS position (6.4.3) that reset must not skip a second factor just because it feels like it happens before authentication.
RevokeAllForUser, not Rotate: a password reset presumes the old credential is compromised, so every session it could have created dies with it.
@param token the raw value from the emailed link @param password the replacement; policy-checked here @return error INVALID_TOKEN for invalid, expired or already-used, INVALID_INPUT for policy
func (*Accounts) VerifyEmail ¶
VerifyEmail @notice Consumes a verification token and marks the address confirmed.
@param token the raw value from the emailed link @return error INVALID_TOKEN for invalid, expired or already-used
type AccountsOptions ¶
type AccountsOptions struct {
Hasher *Hasher
Sessions *session.Sessions
Mailer Mailer
// BaseURL @notice The origin every emailed link is built under, e.g.
// "https://app.example.com". Required, and required to be configuration.
//
// @dev There is deliberately no way to derive this from the request. A link origin taken
// from the Host header is Host-header injection: an attacker who can set that header turns
// your password-reset email into a link to their own server.
BaseURL string
// CookieName @notice The session cookie Login and Logout manage. Default
// session.DefaultCookieName.
CookieName string
// Schema @notice Optional Postgres schema holding the auth_* tables.
Schema string
// SecretShape @notice What a submitted secret must look like. Nil means [ValidatePassword].
//
// @dev The seam client-side encryption needs: with it enabled the server never sees a
// password, so the value arriving in the password field is 32 bytes of derived entropy and a
// password policy is both meaningless and wrong. Set to e2ee.ValidateAuthSecret, this rejects
// a client that still sends the raw password — which would otherwise log in successfully over
// a vault that then never opens, with nothing erroring and nothing logged.
//
// A function field rather than an interface: there is one implementation of each shape and
// kal's existing seams of this kind (Config.ClientIP, Config.AllowIntrospection) are funcs.
SecretShape func(string) error
// AllowUnverifiedLogin @notice Lets an account log in before its email is verified. Off by
// default: the zero configuration requires verification, and turning this on is a visible,
// named decision rather than a mode.
AllowUnverifiedLogin bool
// Audit @notice Called for every security-relevant event. Nil discards them.
//
// @dev Emitted beside the existing log lines rather than instead of them: the log is the
// operator's channel and the hook is the consumer's, and a deployment that wants both should
// not have to choose.
Audit authz.Audit
}
AccountsOptions @notice Configuration for NewAccounts. Hasher, Sessions and Mailer are required; the zero value of everything else is the production posture.
type Hasher ¶
type Hasher struct {
// contains filtered or unexported fields
}
Hasher @notice Hashes and verifies passwords, with the Argon2 work bounded.
@dev The bound is the part every Argon2 tutorial omits: at m=19456 every in-flight hash holds 19 MiB, and nothing else in the stack bounds concurrent logins — luima ships no rate limiting and the connection pool is not the bottleneck. Without the semaphore, N unauthenticated requests allocate 19N MiB: the parameter that makes the hash strong is the same parameter that makes it a remote OOM primitive. Requests beyond the bound queue briefly; the acquire honours ctx, so a client that gave up does not hold a slot.
ponytail: a process-local semaphore, so the bound is per replica. Behind N replicas the real ceiling is 19·limit·N MiB — size it against the pod memory limit, not the node's.
func NewHasher ¶
NewHasher @notice Builds a Hasher from p (zero fields defaulted) and a concurrency bound.
@dev maxConcurrent ≤ 0 defaults to GOMAXPROCS: more simultaneous hashes than schedulable threads buys queueing latency, not throughput. Construction computes one throwaway hash for the unknown-user path, so it costs a single Argon2 run.
@param p cost parameters; zero fields take the OWASP defaults @param maxConcurrent the in-flight hash ceiling; ≤ 0 means GOMAXPROCS @return *Hasher safe for concurrent use @return error only a CSPRNG failure
func (*Hasher) Hash ¶
Hash @notice Derives the PHC-encoded Argon2id hash of password, within the concurrency bound.
@dev Imposes no policy of its own — call ValidatePassword first on user-chosen passwords. Keeping policy out of Hash is what keeps imports and invites possible.
@param ctx bounds the wait for a hashing slot; carries the request deadline @param password hashed exactly as received — no trimming, no normalisation @return string the $argon2id$… PHC string for the password_hash column @return error the bound rejecting the attempt, or a CSPRNG failure
func (*Hasher) Verify ¶
Verify @notice Checks password against encoded, reporting the match and whether the stored hash should be upgraded.
@dev Two encodings are accepted. $argon2id$… is what this package writes. $2…$ is legacy bcrypt — verified so an imported user base can migrate, never minted, because bcrypt's 72-byte truncation is incompatible with "verify exactly as received"; rehash is always true on a bcrypt match, and rehash-on-login is the entire migration path.
The comparison is subtle.ConstantTimeCompare on the derived key. Its sharp edge, for anyone copying this elsewhere: it returns immediately when the lengths differ. Irrelevant here — the key length is fixed by the encoded parameters — but anywhere secrets vary in length, hash both sides first.
@param ctx bounds the wait for a hashing slot; carries the request deadline @param password the candidate, verified exactly as received @param encoded the stored hash, PHC argon2id or bcrypt @return ok whether password matches @return rehash whether the stored hash is weaker than the configured parameters @return error the bound rejecting the attempt, or a malformed stored hash
func (*Hasher) VerifyDummy ¶
VerifyDummy @notice Burns a real verification against an unmatchable hash. Call it when the account does not exist, then return the same INVALID_CREDENTIALS a wrong password gets.
@dev The timing-equalization half of the enumeration defence; the identical error text is the other half. See the dummy comment in NewHasher for why this is a hash and not a sleep.
@param ctx bounds the wait for a hashing slot @param password the candidate submitted for the account that does not exist @return error only the bound rejecting the attempt
type LogMailer ¶
type LogMailer struct{}
LogMailer @notice A development Mailer that writes messages — including live token URLs — to the process log. The name is the warning: do not ship it.
type Mailer ¶
Mailer @notice Delivers kal's transactional messages. One method.
@dev kal ships no SMTP client and no template engine, deliberately: bundling email rendering is how auth libraries become unadoptable — the avatar-store-as-required-dependency story one step earlier. Config.Mailer is required with no default, because the silent alternative is password reset failing at 3am with nothing in any log.
type Message ¶
type Message struct {
Kind MessageKind
Subject string
Text string
URL string // pre-built from Config.BaseURL; empty for kinds that carry no link
}
Message @notice What to send: a subject, a plain-text body with any URL already built, and the URL separately so a consumer can re-template without parsing.
type MessageKind ¶
type MessageKind string
MessageKind @notice Which transactional message this is, so a consumer's Mailer can pick a template without parsing the subject line.
const ( KindVerify MessageKind = "verify" KindReset MessageKind = "reset" KindInvite MessageKind = "invite" KindAttemptedRegister MessageKind = "attempted-register" KindResetNoAccount MessageKind = "reset-no-account" KindPasswordChanged MessageKind = "password-changed" KindSuspiciousLogin MessageKind = "suspicious-login" )
The kinds kal sends. AttemptedRegister and ResetNoAccount are the two everyone skips: they are what makes "check your email" an honest answer instead of merely an opaque one — the person who owns the address learns what happened, and the response to the caller stays byte-identical either way.
type Params ¶
type Params struct {
Memory uint32 // KiB held for the duration of one hash
Time uint32 // passes
Parallelism uint8 // lanes; see above
SaltLen uint32 // bytes
KeyLen uint32 // bytes
}
Params @notice Argon2id cost parameters, encoded into every hash they produce.
@dev The defaults are one of OWASP's listed equivalent-security configurations (19 MiB, t=2, p=1) — the best balance of that set for a web login path.
Parallelism is pinned to 1 and must never be runtime.NumCPU(). The wrapper everyone reaches for defaults to NumCPU, which makes the cost of hashing a password depend on which machine happened to serve the request: a 4-vCPU box and a 64-vCPU box do different work for the same password, capacity planning becomes impossible, and an autoscaler silently changes the security posture. Verification still works either way — p is encoded in the stored hash — which is precisely why that bug survives review, and most of why this file exists instead of a dependency.
type Purpose ¶
type Purpose string
Purpose @notice What an emailed token is for. Stored on the row and matched on consumption, so a verification link cannot be redeemed as a password reset.
const ( // PurposeVerify @notice Proves control of the address. 24 hours. PurposeVerify Purpose = "verify" // PurposeReset @notice Password recovery. 15 minutes — OWASP says at most an hour, ideally // far less, and this token is the account. PurposeReset Purpose = "reset" // PurposeInvite @notice An account created by someone else, awaiting its first password. // 7 days, because invites sit in inboxes over weekends. PurposeInvite Purpose = "invite" )