memstore

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 20, 2026 License: MIT Imports: 10 Imported by: 0

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

Constants

This section is empty.

Variables

View Source
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.

View Source
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

func (s *ChallengeStore) ConsumeChallenge(_ context.Context, key string) ([]byte, error)

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

func (s *ChallengeStore) SaveChallenge(_ context.Context, key string, sessionData []byte) error

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

func (s *PasskeyStore) DeleteCredential(_ context.Context, userID, id string, allowLast bool) error

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

func (s *RecoveryStore) CountCodes(_ context.Context, userID string) (int, error)

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

func (s *RecoveryStore) ReplaceCodes(_ context.Context, userID string, hashes []string) error

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

func (s *SessionStore) CreateSession(_ context.Context, session *sulis.Session) error

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

func (s *SessionStore) ListUserSessions(_ context.Context, userID string) ([]sulis.Session, error)

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

func (s *SessionStore) UpdateAuthenticatedAt(_ context.Context, id string, at time.Time) error

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 NewTOTPStore

func NewTOTPStore() *TOTPStore

NewTOTPStore returns an empty TOTPStore.

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

func (s *TOTPStore) DeleteTOTP(_ context.Context, userID string) error

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

func (s *TOTPStore) EnrollPending(_ context.Context, cred *totp.Credential) error

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

func (s *TOTPStore) GetActiveTOTP(_ context.Context, userID string) (*totp.Credential, error)

GetActiveTOTP returns a copy of userID's active (verified) credential, or totp.ErrTOTPNotEnrolled — whether or not a pending enrollment exists.

func (*TOTPStore) GetPendingTOTP

func (s *TOTPStore) GetPendingTOTP(_ context.Context, userID string) (*totp.Credential, error)

GetPendingTOTP returns a copy of userID's pending (unverified) enrollment, or totp.ErrTOTPNotEnrolled.

func (*TOTPStore) ReplacePending

func (s *TOTPStore) ReplacePending(_ context.Context, cred *totp.Credential) error

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

func (s *TOTPStore) SaveTOTP(_ context.Context, cred *totp.Credential) error

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 NewTokenStore

func NewTokenStore() *TokenStore

NewTokenStore returns an empty 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

func (s *TokenStore) CreateToken(_ context.Context, token *sulis.Token) error

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 NewUserStore

func NewUserStore() *UserStore

NewUserStore returns an empty UserStore.

func (*UserStore) CreateUser

func (s *UserStore) CreateUser(_ context.Context, user *sulis.User) error

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

func (s *UserStore) DeleteUser(_ context.Context, id string) error

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

func (s *UserStore) GetUserByEmail(_ context.Context, email string) (*sulis.User, error)

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

func (s *UserStore) GetUserByID(_ context.Context, id string) (*sulis.User, error)

GetUserByID returns a copy of the stored user, or sulis.ErrUserNotFound.

func (*UserStore) UpdateUser

func (s *UserStore) UpdateUser(_ context.Context, user *sulis.User) error

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.

Jump to

Keyboard shortcuts

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