Documentation
¶
Overview ¶
Package memstore is the reference in-memory implementation of every store interface sulis defines.
It exists to be read. Each store interface in this module documents atomicity, scoping, and monotonicity requirements that a real implementation has to satisfy with transactions, conditional statements, or locks; the types here satisfy them with a single mutex each, held across the whole check-and-mutate, which makes the required boundary visible in a dozen lines instead of a page of SQL. When adapting one of these to a database, the mutex is what your transaction or your single conditional statement has to replace — not something you can drop.
Every type here passes the whole storetest conformance suite, which is how that claim is kept honest rather than asserted; see memstore_test.go.
Not for production ¶
These stores keep everything in process memory. Nothing survives a restart, nothing is shared between processes, and nothing is bounded: CleanExpired, DeleteExpiredTokens, and the delete methods are the only things that ever release memory, so a long-lived process that never calls them grows without limit. Use them for tests, examples, and local development.
Concurrency and isolation ¶
Every method is safe for concurrent use. Values returned to callers are always copies, and values handed in are always copied before being stored, so a caller mutating a struct it passed in or got back can never reach inside the store.
"Copy" here means deep enough that nothing mutable is shared: the maps (sulis.User.Metadata, sulis.Session.Metadata), the slices (passkey.Credential.CredentialID, PublicKey, AAGUID, Transports, and challenge session data), and the pointers (sulis.User.EmailVerifiedAt, passkey.Credential.LastUsedAt) are all cloned on the way in and on the way out. A plain struct copy would not be: it copies a map header, not the map, which would leave a caller able to rewrite a persisted row without going through UpdateUser — precisely what sulis.User.Version exists to prevent. The one documented limit is that maps are cloned one level deep, so a caller that stores a map or a slice as a Metadata *value* still shares that inner value with the store.
This is not a memstore quirk. storetest enforces the same property on every conforming implementation.
Index ¶
- Variables
- type ChallengeStore
- type PasskeyStore
- func (s *PasskeyStore) DeleteCredential(_ context.Context, userID, id string, allowLast bool) error
- func (s *PasskeyStore) DeleteCredentialsByUserID(_ context.Context, userID string) error
- func (s *PasskeyStore) GetCredentialByID(_ context.Context, credentialID []byte) (*passkey.Credential, error)
- func (s *PasskeyStore) GetCredentialsByUserID(_ context.Context, userID string) ([]passkey.Credential, error)
- func (s *PasskeyStore) RenameCredential(_ context.Context, id, name string) error
- func (s *PasskeyStore) SaveCredential(_ context.Context, cred *passkey.Credential) error
- func (s *PasskeyStore) UpdateCredentialAfterLogin(_ context.Context, credentialID []byte, signCount uint32, backupState bool, ...) error
- type RecoveryStore
- func (s *RecoveryStore) ConsumeCode(_ context.Context, userID, hash string) error
- func (s *RecoveryStore) CountCodes(_ context.Context, userID string) (int, error)
- func (s *RecoveryStore) DeleteCodes(_ context.Context, userID string) error
- func (s *RecoveryStore) ReplaceCodes(_ context.Context, userID string, hashes []string) error
- type SessionStore
- func (s *SessionStore) CleanExpired(_ context.Context) error
- func (s *SessionStore) CreateSession(_ context.Context, session *sulis.Session) error
- func (s *SessionStore) DeleteSession(_ context.Context, userID, id string) error
- func (s *SessionStore) DeleteUserSessions(_ context.Context, userID string) error
- func (s *SessionStore) DeleteUserSessionsExcept(_ context.Context, userID, keepSessionID string) error
- func (s *SessionStore) GetSessionByTokenHash(_ context.Context, tokenHash string) (*sulis.Session, error)
- func (s *SessionStore) Len() int
- func (s *SessionStore) ListUserSessions(_ context.Context, userID string) ([]sulis.Session, error)
- func (s *SessionStore) TouchSession(_ context.Context, id string, lastSeen time.Time, idleExpires *time.Time) error
- func (s *SessionStore) UpdateAuthenticatedAt(_ context.Context, id string, at time.Time) error
- type TOTPStore
- func (s *TOTPStore) ConfirmEnrollment(_ context.Context, userID, pendingID string, counter uint64) (*totp.Credential, error)
- func (s *TOTPStore) DeleteTOTP(_ context.Context, userID string) error
- func (s *TOTPStore) EnrollPending(_ context.Context, cred *totp.Credential) error
- func (s *TOTPStore) GetActiveTOTP(_ context.Context, userID string) (*totp.Credential, error)
- func (s *TOTPStore) GetPendingTOTP(_ context.Context, userID string) (*totp.Credential, error)
- func (s *TOTPStore) ReplacePending(_ context.Context, cred *totp.Credential) error
- func (s *TOTPStore) SaveTOTP(_ context.Context, cred *totp.Credential) error
- type TokenStore
- func (s *TokenStore) ConsumeToken(_ context.Context, hash string, purpose sulis.TokenPurpose) (*sulis.Token, error)
- func (s *TokenStore) CreateToken(_ context.Context, token *sulis.Token) error
- func (s *TokenStore) DeleteExpiredTokens(_ context.Context) error
- func (s *TokenStore) DeleteUserTokens(_ context.Context, userID string, purpose sulis.TokenPurpose) error
- type UserStore
- func (s *UserStore) CreateUser(_ context.Context, user *sulis.User) error
- func (s *UserStore) DeleteUser(_ context.Context, id string) error
- func (s *UserStore) GetUserByEmail(_ context.Context, email string) (*sulis.User, error)
- func (s *UserStore) GetUserByID(_ context.Context, id string) (*sulis.User, error)
- func (s *UserStore) UpdateUser(_ context.Context, user *sulis.User) error
Constants ¶
This section is empty.
Variables ¶
var ErrChallengeNotFound = errors.New("memstore: challenge not found")
ErrChallengeNotFound is returned by ChallengeStore.ConsumeChallenge when no challenge is stored under the key — already consumed, or never saved. passkey.ChallengeStore leaves this error implementation-defined; the caller normalizes whatever comes back to passkey.ErrChallengeExpired.
var ErrTOTPCounterRegressed = errors.New("memstore: TOTP counter would regress")
ErrTOTPCounterRegressed is returned by TOTPStore.SaveTOTP when a save would lower LastUsedCounter for the active credential with the same ID. totp.Store requires such a save to be rejected but does not name the error; failing closed is the point, since a counter that can regress is a code that can be replayed.
Functions ¶
This section is empty.
Types ¶
type ChallengeStore ¶
type ChallengeStore struct {
// contains filtered or unexported fields
}
ChallengeStore is an in-memory passkey.ChallengeStore.
It does not expire entries. A production implementation should, after roughly five minutes — the lifetime of a WebAuthn ceremony — which is one line in Redis and a scheduled delete in SQL.
func NewChallengeStore ¶
func NewChallengeStore() *ChallengeStore
NewChallengeStore returns an empty ChallengeStore.
func (*ChallengeStore) ConsumeChallenge ¶
ConsumeChallenge fetches and deletes the challenge stored under key in one operation, so two concurrent finishes of the same ceremony can never both receive it — the in-memory equivalent of Redis GETDEL or SQL "DELETE ... RETURNING". Returns ErrChallengeNotFound if there is nothing under key.
func (*ChallengeStore) SaveChallenge ¶
SaveChallenge stores a copy of sessionData under key, replacing anything already there. The copy matters: a caller reusing its buffer must not be able to rewrite a challenge it has already handed over.
type PasskeyStore ¶
type PasskeyStore struct {
// contains filtered or unexported fields
}
PasskeyStore is an in-memory passkey.Store.
Credentials are held in one map keyed by passkey.Credential.ID, the store's own opaque identifier, and looked up by raw WebAuthn credential ID with a scan. A real store indexes both columns; keeping one map here means there is no second index to fall out of step with the first, which is the bug that makes a passkey unusable after a rename or a sign-count update.
func NewPasskeyStore ¶
func NewPasskeyStore() *PasskeyStore
NewPasskeyStore returns an empty PasskeyStore.
func (*PasskeyStore) DeleteCredential ¶
DeleteCredential removes the credential named by id if it belongs to userID, refusing with passkey.ErrLastCredential when it is the user's only remaining credential and allowLast is false, and reporting passkey.ErrPasskeyNotFound when id names no credential of that user's.
The ownership check, the remaining-count check, and the removal all happen while holding s.mu, which is the whole point. Split them and two goroutines each deleting one of a user's last two credentials both see count == 2, both pass the guard, and both succeed — leaving the user locked out through the path that exists to prevent exactly that. A SQL store gets the same effect from one conditional DELETE, or from a transaction that locks the user's credential rows before counting them.
func (*PasskeyStore) DeleteCredentialsByUserID ¶
func (s *PasskeyStore) DeleteCredentialsByUserID(_ context.Context, userID string) error
DeleteCredentialsByUserID removes every credential owned by userID. The last-credential guard deliberately does not apply: deleting a whole account is a stronger action the caller has already gated, and leaving one credential behind because it happened to be last would be surprising.
func (*PasskeyStore) GetCredentialByID ¶
func (s *PasskeyStore) GetCredentialByID(_ context.Context, credentialID []byte) (*passkey.Credential, error)
GetCredentialByID returns a copy of the credential whose raw WebAuthn credential ID is credentialID, or passkey.ErrPasskeyNotFound.
func (*PasskeyStore) GetCredentialsByUserID ¶
func (s *PasskeyStore) GetCredentialsByUserID(_ context.Context, userID string) ([]passkey.Credential, error)
GetCredentialsByUserID returns copies of every credential owned by userID. A user with no credentials is not an error: it is how a caller learns the user has no passkey enrolled.
func (*PasskeyStore) RenameCredential ¶
func (s *PasskeyStore) RenameCredential(_ context.Context, id, name string) error
RenameCredential sets the caller-supplied display name on the credential named by id, or returns passkey.ErrPasskeyNotFound. The name is stored verbatim: passkey never generates, infers, or validates it.
func (*PasskeyStore) SaveCredential ¶
func (s *PasskeyStore) SaveCredential(_ context.Context, cred *passkey.Credential) error
SaveCredential stores a deep copy of cred, keyed by cred.ID.
func (*PasskeyStore) UpdateCredentialAfterLogin ¶
func (s *PasskeyStore) UpdateCredentialAfterLogin(_ context.Context, credentialID []byte, signCount uint32, backupState bool, lastUsedAt time.Time) error
UpdateCredentialAfterLogin persists the three fields that must change together on every successful assertion: SignCount, BackupState, and LastUsedAt. go-webauthn re-reads all three on the next ceremony, so a store that persists only some of them breaks the credential's next login rather than this one.
type RecoveryStore ¶
type RecoveryStore struct {
// contains filtered or unexported fields
}
RecoveryStore is an in-memory recovery.Store.
Codes are held per user as a set of hashes. The plaintext codes are never stored — recovery hashes them before they reach any store, and this one has nothing to hash back.
func NewRecoveryStore ¶
func NewRecoveryStore() *RecoveryStore
NewRecoveryStore returns an empty RecoveryStore.
func (*RecoveryStore) ConsumeCode ¶
func (s *RecoveryStore) ConsumeCode(_ context.Context, userID, hash string) error
ConsumeCode finds and deletes the code matching userID and hash in one step, returning recovery.ErrCodeNotFound when there is no such unused code for that user. A recovery code bypasses every other factor, so a store that looked the code up and then deleted it would let two concurrent presentations of the same code both authenticate.
func (*RecoveryStore) CountCodes ¶
CountCodes reports how many unused codes userID has left. A user with none counts zero rather than erroring.
func (*RecoveryStore) DeleteCodes ¶
func (s *RecoveryStore) DeleteCodes(_ context.Context, userID string) error
DeleteCodes removes every code for userID.
func (*RecoveryStore) ReplaceCodes ¶
ReplaceCodes swaps userID's whole code set for hashes in one step, so a regeneration never leaves a caller looking at a half-replaced set — some codes from the old batch and some from the new. An empty or nil slice clears the set.
type SessionStore ¶
type SessionStore struct {
// contains filtered or unexported fields
}
SessionStore is an in-memory sulis.SessionStore.
func NewSessionStore ¶
func NewSessionStore() *SessionStore
NewSessionStore returns an empty SessionStore.
func (*SessionStore) CleanExpired ¶
func (s *SessionStore) CleanExpired(_ context.Context) error
CleanExpired removes every session whose ExpiresAt is in the past.
func (*SessionStore) CreateSession ¶
CreateSession stores a copy of session, keyed by its ID.
func (*SessionStore) DeleteSession ¶
func (s *SessionStore) DeleteSession(_ context.Context, userID, id string) error
DeleteSession removes the session identified by id only if it belongs to userID, and returns sulis.ErrSessionNotFound when nothing matched — whether id names no session at all or one owned by somebody else. This is the equivalent of "DELETE FROM sessions WHERE id = ? AND user_id = ?" plus a check of the affected-row count, and it is what stops a leaked or guessed session ID from revoking another user's session through Sulis.RevokeSession.
func (*SessionStore) DeleteUserSessions ¶
func (s *SessionStore) DeleteUserSessions(_ context.Context, userID string) error
DeleteUserSessions removes every session belonging to userID. Matching nothing is not an error.
func (*SessionStore) DeleteUserSessionsExcept ¶
func (s *SessionStore) DeleteUserSessionsExcept(_ context.Context, userID, keepSessionID string) error
DeleteUserSessionsExcept removes every session belonging to userID except the one identified by keepSessionID. keepSessionID naming a session that doesn't exist, or one belonging to someone else, is not an error — every other session for userID is removed regardless. See sulis.SessionStore.DeleteUserSessionsExcept's doc comment.
func (*SessionStore) GetSessionByTokenHash ¶
func (s *SessionStore) GetSessionByTokenHash(_ context.Context, tokenHash string) (*sulis.Session, error)
GetSessionByTokenHash returns a copy of the session whose TokenHash is tokenHash, or sulis.ErrSessionNotFound. Only the hash is ever stored or compared; sulis never hands a store the raw token.
func (*SessionStore) Len ¶
func (s *SessionStore) Len() int
Len reports how many sessions are stored. It is not part of sulis.SessionStore; it exists so a test or an example can assert that a flow created no session at all, which is otherwise unobservable through the interface.
func (*SessionStore) ListUserSessions ¶
ListUserSessions returns a copy of every session belonging to userID. Matching nothing is not an error — a nil slice and a nil error.
func (*SessionStore) TouchSession ¶
func (s *SessionStore) TouchSession(_ context.Context, id string, lastSeen time.Time, idleExpires *time.Time) error
TouchSession stamps the session identified by id with a fresh lastSeen and idleExpires, leaving every other field untouched, and returns sulis.ErrSessionNotFound if id does not exist. A nil idleExpires clears any previously-stored deadline. This is the write path behind sulis.Sulis.ValidateSession's throttled liveness touch.
func (*SessionStore) UpdateAuthenticatedAt ¶
UpdateAuthenticatedAt stamps the session identified by id with at, leaving every other field untouched, and returns sulis.ErrSessionNotFound if id does not exist. This is the write path behind sulis.Sulis.ReAuthenticate.
type TOTPStore ¶
type TOTPStore struct {
// contains filtered or unexported fields
}
TOTPStore is an in-memory totp.Store.
The two slots the interface describes are two maps: at most one active (verified) credential and at most one pending (unverified) enrollment per user. Keeping them separate is what stops a stray or racing enrollment from replacing a working second factor; the mutex is what makes each transition between them a single step.
func (*TOTPStore) ConfirmEnrollment ¶
func (s *TOTPStore) ConfirmEnrollment(_ context.Context, userID, pendingID string, counter uint64) (*totp.Credential, error)
ConfirmEnrollment promotes userID's pending enrollment to active, but only while it is still the one named by pendingID — the enrollment whose secret the caller just matched a code against. A mismatch means it was already promoted, or superseded by a racing enrollment, or never existed: totp.ErrTOTPNotEnrolled, and nothing is touched.
The comparison and the promotion are one critical section, which is what closes the clobber race: otherwise an EnrollPending landing between Service reading the pending enrollment and this write would leave the store promoting a secret nobody validated, or discarding a fresh enrollment, without either caller finding out.
counter is the time step the code was matched at. When an active credential already exists — a replacement rather than a first enrollment — the promoted credential keeps whichever counter is higher, so swapping factors can never roll the user's replay-protection clock backwards.
func (*TOTPStore) DeleteTOTP ¶
DeleteTOTP removes userID's active credential and any pending enrollment, both in one step. Removing them one after the other would let a concurrent ConfirmEnrollment promotion land in the gap, leaving the just-promoted credential behind as an active factor the caller believed it had removed.
func (*TOTPStore) EnrollPending ¶
EnrollPending stores cred as userID's pending enrollment, but only if the user has no active credential: otherwise it returns totp.ErrTOTPAlreadyEnrolled and writes nothing. Callers that mean to supersede a working factor use ReplacePending instead.
The check and the write are one critical section. Split them and a concurrent ConfirmEnrollment could promote a different pending enrollment to active in the gap, only for this write to land undetected right after — silently replacing a factor the user is relying on.
func (*TOTPStore) GetActiveTOTP ¶
GetActiveTOTP returns a copy of userID's active (verified) credential, or totp.ErrTOTPNotEnrolled — whether or not a pending enrollment exists.
func (*TOTPStore) GetPendingTOTP ¶
GetPendingTOTP returns a copy of userID's pending (unverified) enrollment, or totp.ErrTOTPNotEnrolled.
func (*TOTPStore) ReplacePending ¶
ReplacePending is EnrollPending without the active-credential guard: it stores cred as userID's pending enrollment whatever else is on file, and leaves any active credential completely alone, so codes keep validating against the old factor until a later ConfirmEnrollment promotes this one.
func (*TOTPStore) SaveTOTP ¶
SaveTOTP persists an update to an existing active credential — in practice the LastUsedCounter bump after a code is accepted. A save that would lower the counter for the active credential with the same ID is refused with ErrTOTPCounterRegressed rather than applied, so of two racing validations only one can advance the clock and the loser cannot rewind it.
type TokenStore ¶
type TokenStore struct {
// contains filtered or unexported fields
}
TokenStore is an in-memory sulis.TokenStore.
func (*TokenStore) ConsumeToken ¶
func (s *TokenStore) ConsumeToken(_ context.Context, hash string, purpose sulis.TokenPurpose) (*sulis.Token, error)
ConsumeToken implements the atomic find-and-mark documented on sulis.TokenStore.ConsumeToken: the lookup, the Used check, and the write all happen while holding s.mu, so of any number of concurrent callers presenting the same token exactly one is handed it and the rest see ErrTokenAlreadyUsed. A SQL store replaces this lock with a single conditional statement (UPDATE ... WHERE hash = ? AND purpose = ? AND used = false) and distinguishes "no match" from "already used" by re-reading the row when zero rows are affected.
func (*TokenStore) CreateToken ¶
CreateToken stores a copy of token, keyed by its ID.
func (*TokenStore) DeleteExpiredTokens ¶
func (s *TokenStore) DeleteExpiredTokens(_ context.Context) error
DeleteExpiredTokens removes every token whose ExpiresAt is in the past.
func (*TokenStore) DeleteUserTokens ¶
func (s *TokenStore) DeleteUserTokens(_ context.Context, userID string, purpose sulis.TokenPurpose) error
DeleteUserTokens removes every token belonging to userID with the given purpose. Matching nothing is not an error.
type UserStore ¶
type UserStore struct {
// contains filtered or unexported fields
}
UserStore is an in-memory sulis.UserStore.
It enforces both of the requirements the interface places on a real implementation: e-mail uniqueness on every write path, and optimistic concurrency through sulis.User.Version. A SQL store gets the first from a UNIQUE index on the normalized e-mail column and the second from "UPDATE ... WHERE id = $1 AND version = $2"; a map needs the enclosing mutex to stand in for both, which is why every method here takes it.
func (*UserStore) CreateUser ¶
CreateUser stores a copy of user, rejecting an e-mail address another user already holds with sulis.ErrUserAlreadyExists. The check and the write happen under one lock, so of any number of concurrent creates for the same address exactly one lands — the guarantee a UNIQUE index gives a SQL store and that no caller-side pre-check can give.
A duplicate ID is rejected the same way: two users cannot share a primary key, and silently overwriting one would lose an account.
func (*UserStore) DeleteUser ¶
DeleteUser removes the user with the given ID. Deleting a user who is not there is not an error: the caller's intent is already satisfied.
func (*UserStore) GetUserByEmail ¶
GetUserByEmail returns a copy of the user whose live e-mail address is email, or sulis.ErrUserNotFound. The address is compared exactly: sulis normalizes before it ever reaches a store, so a store that lowercased again here would only hide a caller passing an unnormalized address.
func (*UserStore) GetUserByID ¶
GetUserByID returns a copy of the stored user, or sulis.ErrUserNotFound.
func (*UserStore) UpdateUser ¶
UpdateUser applies user only while the stored row's Version still matches the one the caller read, returning sulis.ErrConcurrentUpdate otherwise and discarding the write, and rejects an e-mail address another user holds with sulis.ErrUserAlreadyExists. On success the stored Version advances by one.
Both checks and the write are one critical section. Splitting them is the bug the contract exists to rule out: two flows that each read-modify-write the whole row would otherwise clobber each other, and the dangerous direction restores a password hash the user just rotated away from.