domain

package
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Oct 27, 2026 License: AGPL-3.0 Imports: 8 Imported by: 0

Documentation

Overview

Package domain holds the identity bounded context's entities, value objects, and ports (interfaces): Household, Member with the unified owner/adult/child Role vocabulary, and Credential — the password half of a member's login. Nothing here imports either app; the ports are implemented by identity/adapter and consumed by identity/app.

household and member ARE the identity domain here, not a reference to Nestova's existing internal/household or internal/auth packages, which stay app-side. See identity/migrate's package doc for the schema this package's adapters run against.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrInvalidCredentials is returned by FindByEmail when no matching
	// credential is found. It is intentionally generic — callers must not
	// distinguish "user not found" from "wrong password" to prevent user
	// enumeration.
	ErrInvalidCredentials = errors.New("identity: invalid credentials")
	// ErrEmailAlreadyInUse is returned by SetCredential when the email is
	// already assigned to a different member (the email column is
	// unique).
	ErrEmailAlreadyInUse = errors.New("identity: email already in use")
)

Domain errors returned by CredentialRepository implementations.

View Source
var (
	// ErrMemberNotFound is returned when a member does not exist.
	ErrMemberNotFound = errors.New("identity: member not found")
	// ErrDuplicateMember is returned when adding a member whose display
	// name already exists (case-insensitively) within the household.
	ErrDuplicateMember = errors.New("identity: duplicate member display name in household")
	// ErrLastActiveOwner is returned by MemberWriter when a role change or
	// deactivation would leave a household with no active owner. It is
	// never returned for reactivation — reactivating only ever adds an
	// active owner back, so it cannot violate this invariant.
	ErrLastActiveOwner = errors.New("identity: household must keep at least one active owner")
)

Domain errors returned by MemberRepository implementations.

View Source
var (
	// ErrMFAAlreadyEnrolled is returned by BeginEnrollment and
	// ConfirmEnrollmentWithCodes when the member already has a CONFIRMED
	// TOTP enrollment: it must be disabled or disenrolled before a new one
	// can be confirmed. An UNCONFIRMED enrollment does NOT trigger this —
	// re-enrolling before confirming simply replaces it.
	ErrMFAAlreadyEnrolled = errors.New("identity: mfa already enrolled")
	// ErrMFANotEnrolled is returned by ConfirmEnrollmentWithCodes and
	// DeleteEnrollment when the member has no enrollment (confirmed or
	// not) on file, and by app.MFAService methods that require a
	// CONFIRMED enrollment when one does not exist.
	ErrMFANotEnrolled = errors.New("identity: mfa not enrolled")
	// ErrInvalidTOTPCode is returned when a submitted TOTP code does not
	// validate against the member's stored secret, including a
	// replayed (already-accepted) step — see MFARepository.RecordLoginStep.
	ErrInvalidTOTPCode = errors.New("identity: invalid totp code")
	// ErrRecoveryCodeInvalid is returned when a submitted recovery code
	// does not match any unused code on file for the member.
	ErrRecoveryCodeInvalid = errors.New("identity: invalid recovery code")
	// ErrMFAVerificationRequired is returned by app.MFAService.Disenroll
	// when neither a TOTP code nor a recovery code was submitted.
	ErrMFAVerificationRequired = errors.New("identity: a current totp code or recovery code is required")
)

MFA domain errors.

View Source
var (
	// ErrWebAuthnCredentialNotFound is returned by Rename and Delete when
	// no credential matches the given id, member, and household —
	// including when the id is valid but belongs to a DIFFERENT member or
	// household (reported identically, so neither action leaks which one
	// occurred).
	ErrWebAuthnCredentialNotFound = errors.New("identity: webauthn credential not found")
	// ErrWebAuthnVerificationFailed is returned by
	// app.WebAuthnService.FinishRegistration and FinishLogin when the
	// browser's response fails verification against the pending
	// challenge — a wrong RP ID, a challenge mismatch, an expired
	// challenge, an unresolvable user handle, or a replayed (already
	// consumed) challenge are all reported identically, so a caller
	// cannot distinguish which one occurred.
	ErrWebAuthnVerificationFailed = errors.New("identity: webauthn verification failed")
	// ErrWebAuthnCredentialExists is returned by Create when
	// cred.CredentialID is already registered — the WebAuthn credential id
	// an authenticator generates is globally unique by construction, so
	// this indicates the same physical credential is being registered a
	// second time (defense in depth: the caller is expected to exclude a
	// member's own existing credentials from a registration ceremony via
	// WithExclusions, so this should not normally be reachable).
	ErrWebAuthnCredentialExists = errors.New("identity: webauthn credential already registered")
)

WebAuthn domain errors.

View Source
var ErrHouseholdNotFound = errors.New("identity: household not found")

ErrHouseholdNotFound is returned when a household does not exist.

Functions

func GenerateRecoveryCode added in v0.3.0

func GenerateRecoveryCode() (string, error)

GenerateRecoveryCode returns one raw, human-typable recovery code (16 Crockford-Base32 characters — recoveryCodeBytes' full 80 bits, see recoveryCodeEncoding's doc — split into four dash-separated groups of four, e.g. "ABCD-EFGH-JKMN-PQRS"). The raw code is returned to the caller exactly once — only its hash is ever persisted.

func NormalizeRecoveryCode added in v0.3.0

func NormalizeRecoveryCode(s string) string

NormalizeRecoveryCode uppercases and folds visually-confusable characters a member might type (O→0, I/L→1), then keeps only symbols from recoveryCodeAlphabet — an allow-list rather than stripping just the ASCII dash and space a member is expected to type: a code pasted from a password manager, a PDF, or a mobile keyboard's autocorrect can carry a tab, a non-breaking space, or an en/em dash instead, and a deny-list of only "-"/" " would leave those in place and silently fail to match the stored hash. ToUpper must run before the alias fold, so a lowercase o/i/l is still folded.

Types

type Credential

type Credential struct {
	MemberID     MemberID
	PasswordHash string
}

Credential pairs a MemberID with the stored argon2id password hash. It is looked up by email (the login form's identifier) and written against a known member id (the add-credential / change-password flow), backed by identity.member's own email and password_hash columns — see identity/migrate's baseline migration, not a separate credential table.

type CredentialRepository

type CredentialRepository interface {
	// FindByEmail looks up the credential for the given email address. It
	// returns ErrInvalidCredentials when no member with that email and a
	// password_hash exists, or when that member has been deactivated
	// (Member.Active false), so callers cannot distinguish "no account"
	// from "wrong password" from "deactivated".
	FindByEmail(ctx context.Context, email string) (*Credential, error)

	// SetCredential stores (or replaces) the email and password hash on
	// the member row identified by memberID. Returns ErrMemberNotFound
	// when the member does not exist.
	SetCredential(ctx context.Context, memberID MemberID, email, passwordHash string) error
}

CredentialRepository is the outbound port for looking up and writing login credentials. Implementations live in identity/adapter.

Error contracts:

  • FindByEmail returns ErrInvalidCredentials when no member with that email and a password_hash exists, and also when that member's Active is false (no user enumeration either way).
  • SetCredential returns ErrMemberNotFound when the member id does not exist, and ErrEmailAlreadyInUse when the email belongs to another member.

type Household

type Household struct {
	ID        HouseholdID
	Name      string
	CreatedAt time.Time
	UpdatedAt time.Time
}

Household is the aggregate root for the identity bounded context. Presentation fields (e.g. Nestova's quiet hours, Nestorage's own per-app settings) stay out of this type — see identity/migrate's package doc for the app-side presentation boundary this schema enforces.

type HouseholdID

type HouseholdID uuid.UUID

HouseholdID uniquely identifies a household.

func NewHouseholdID

func NewHouseholdID() HouseholdID

NewHouseholdID returns a new time-ordered (UUIDv7) household id, which gives better B-tree index locality than random v4 ids. uuid.NewV7 only errors if the crypto random source is unavailable — the same failure under which uuid.New itself panics — so Must is appropriate here.

func ParseHouseholdID

func ParseHouseholdID(s string) (HouseholdID, error)

ParseHouseholdID parses a canonical UUID string into a HouseholdID.

func (HouseholdID) String

func (id HouseholdID) String() string

String returns the canonical UUID string.

type HouseholdRepository

type HouseholdRepository interface {
	CreateHousehold(ctx context.Context, h *Household) error
	GetHousehold(ctx context.Context, id HouseholdID) (*Household, error)
}

HouseholdRepository is the outbound port for persisting and retrieving households. Implementations live in identity/adapter.

Error contracts:

  • CreateHousehold expects h.ID and h.Name set; it populates CreatedAt/UpdatedAt on h and surfaces any other failure (e.g. an id collision) as a wrapped error, not a sentinel.
  • GetHousehold returns ErrHouseholdNotFound when id is unknown.

type MFAEnrollment added in v0.3.0

type MFAEnrollment struct {
	MemberID      MemberID
	HouseholdID   HouseholdID
	TOTPSecretEnc []byte
	ConfirmedAt   *time.Time
	// LastTOTPStep is the RFC 6238 step of the most recently accepted
	// LOGIN TOTP code, or nil when the member has never completed login
	// MFA verification. It is the durable replay guard
	// MFARepository.RecordLoginStep maintains — see that method's doc.
	LastTOTPStep *int64
	CreatedAt    time.Time
	UpdatedAt    time.Time
}

MFAEnrollment is a member's TOTP enrollment — at most one per member. The secret is stored encrypted at rest (TOTPSecretEnc); it is never persisted or logged in plaintext. ConfirmedAt is nil until the member proves control of their authenticator app by submitting one valid code back (app.MFAService.ConfirmEnrollment); an unconfirmed enrollment is inert — ignored by every check that requires an active enrollment.

func (*MFAEnrollment) Confirmed added in v0.3.0

func (e *MFAEnrollment) Confirmed() bool

Confirmed reports whether e represents an active enrollment — i.e. the member has proven control of their authenticator app.

type MFARepository added in v0.3.0

type MFARepository interface {
	GetEnrollment(ctx context.Context, memberID MemberID) (*MFAEnrollment, error)
	BeginEnrollment(ctx context.Context, memberID MemberID, householdID HouseholdID, secretEnc []byte) error
	ConfirmEnrollmentWithCodes(ctx context.Context, householdID HouseholdID, memberID MemberID, recoveryCodeHashes []string) error
	DeleteEnrollment(ctx context.Context, householdID HouseholdID, memberID MemberID) error
	ListUnusedRecoveryCodes(ctx context.Context, memberID MemberID) ([]RecoveryCode, error)
	MarkRecoveryCodeUsed(ctx context.Context, codeID RecoveryCodeID) error
	RecordLoginStep(ctx context.Context, memberID MemberID, step int64) error
}

MFARepository is the outbound port for persisting a member's TOTP enrollment and recovery codes. Implementations live in identity/adapter.

Error contracts:

  • GetEnrollment returns ErrMFANotEnrolled when the member has no enrollment row (confirmed or not).
  • BeginEnrollment upserts an UNCONFIRMED enrollment for memberID, replacing any existing unconfirmed row in place. It returns ErrMFAAlreadyEnrolled when the existing row is already CONFIRMED (the caller must disable/disenroll first), and ErrMemberNotFound when memberID does not belong to householdID (no such row exists yet), when an existing row belongs to a DIFFERENT household than householdID (a tenant-consistency guard: implementations must never touch another household's row), AND when householdID itself does not exist at all — all three reported identically as ErrMemberNotFound so neither leaks which one occurred.
  • ConfirmEnrollmentWithCodes atomically, in a single transaction, sets confirmed_at = now on the member's existing unconfirmed row AND replaces their recovery codes with one fresh row per hash — the two writes MUST be atomic (not two separate calls) so that two concurrent callers racing to confirm the SAME still-unconfirmed enrollment can never both "win": the loser's hashes are never persisted, and it receives ErrMFAAlreadyEnrolled rather than silently returning raw codes to its caller that were never actually stored. Scoped to householdID as a defense-in-depth tenant check, mirroring DeleteEnrollment's own: this method must never confirm a row belonging to a DIFFERENT household. Returns ErrMFANotEnrolled when no row exists in that household, and ErrMFAAlreadyEnrolled when the row is already confirmed (including by a racing, now-committed, concurrent call to this same method).
  • DeleteEnrollment removes the member's enrollment (confirmed or not), cascading its recovery codes, scoped to householdID as a defense-in-depth tenant check. Returns ErrMFANotEnrolled when no row exists in that household.
  • ListUnusedRecoveryCodes returns every not-yet-used recovery code for memberID (never used ones), for verifying a submitted code against.
  • MarkRecoveryCodeUsed sets used_at = now on the given code id, IF AND ONLY IF the code is still unused — implementations must apply this as a single conditional UPDATE (not a read-then-write), so two concurrent redemptions of the same code can never both succeed. Returns ErrRecoveryCodeInvalid when the code does not exist or has already been used. It is the caller's responsibility to have already matched the code's hash.
  • RecordLoginStep durably persists step as memberID's last-accepted login TOTP step, IF AND ONLY IF the stored value is still nil or strictly less than step — an atomic, race-safe replay guard (implementations must apply this as a single conditional UPDATE, not a read-then-write). Returns ErrInvalidTOTPCode when the guard fails: either because step has already been used or superseded (a replay, including one that lost a race to a concurrent call for a later step) or because memberID has no member_mfa row — both reported identically, since the caller has always already confirmed enrollment via GetEnrollment before calling this.

type Member

type Member struct {
	ID          MemberID
	HouseholdID HouseholdID
	DisplayName string
	Role        Role
	// Active is the IDENTITY-level deactivation flag: false cuts a
	// member's access to every app sharing this schema. This package only
	// reads and writes it as a plain field; the deactivation guards that
	// consume it belong to NSTR-111.
	Active    bool
	CreatedAt time.Time
	UpdatedAt time.Time
}

Member is a person in a household. It is a child entity of the Household aggregate root. Email and password-hash credential state are deliberately not part of this type — they are Credential's concern, looked up and written through CredentialRepository, mirroring the household/auth split this schema's design is ported from.

type MemberID

type MemberID uuid.UUID

MemberID uniquely identifies a member.

func NewMemberID

func NewMemberID() MemberID

NewMemberID returns a new time-ordered (UUIDv7) member id.

func ParseMemberID

func ParseMemberID(s string) (MemberID, error)

ParseMemberID parses a canonical UUID string into a MemberID.

func (MemberID) String

func (id MemberID) String() string

String returns the canonical UUID string.

type MemberRepository

type MemberRepository interface {
	CreateMember(ctx context.Context, m *Member) error
	GetMember(ctx context.Context, id MemberID) (*Member, error)
	ListMembers(ctx context.Context, householdID HouseholdID) ([]*Member, error)
}

MemberRepository persists members and looks them up. It depends only on HouseholdID/MemberID from this same package (ISP): callers needing credential state depend on CredentialRepository instead, not on this port.

Persistence contracts:

  • CreateMember expects m.ID, m.HouseholdID, m.DisplayName, and a valid m.Role set; it populates CreatedAt/UpdatedAt on m. Active defaults to true at the database, so a freshly created member reads back Active true regardless of the zero value passed in.

Error contracts:

  • CreateMember returns ErrDuplicateMember when the display name collides (case-insensitively) within the household, and ErrHouseholdNotFound when m.HouseholdID does not exist.
  • GetMember returns ErrMemberNotFound when id is unknown.
  • ListMembers returns an empty slice (not an error) for an unknown household.

type MemberWriter added in v0.3.0

type MemberWriter interface {
	UpdateMemberProfile(ctx context.Context, householdID HouseholdID, id MemberID, displayName string, role Role) error
	SetMemberActive(ctx context.Context, householdID HouseholdID, id MemberID, active bool) error
}

MemberWriter is the outbound port for the member lifecycle mutations (NSTR-111): rename, role change, deactivate, and reactivate. It is kept separate from MemberRepository (ISP) so the existing read-side fakes this package's consumers already have keep compiling unchanged — they were never asked to implement writes.

Both methods scope by (householdID, id) together, not id alone: a mismatched pair (an id that exists but belongs to a different household) is indistinguishable from an unknown id, exactly like MemberRepository's own GetMember would behave if it were household-scoped.

Error contracts:

  • UpdateMemberProfile returns ErrMemberNotFound when id does not exist in householdID, ErrDuplicateMember when displayName collides case-insensitively with another member in the household, and ErrLastActiveOwner when the role change would leave the household with no active owner (i.e. id is currently the household's sole active owner and role is not RoleOwner).
  • SetMemberActive returns ErrMemberNotFound when id does not exist in householdID, and ErrLastActiveOwner when active is false and id is the household's sole active owner. Reactivation (active true) is never refused by the guard.

type RecoveryCode added in v0.3.0

type RecoveryCode struct {
	ID        RecoveryCodeID
	MemberID  MemberID
	CodeHash  string
	UsedAt    *time.Time
	CreatedAt time.Time
}

RecoveryCode is one single-use recovery code. Only CodeHash (an argon2id PHC string, the same format and KDF as member passwords) is ever persisted; the raw code is shown to the member exactly once, at generation time, and never stored.

func (RecoveryCode) Used added in v0.3.0

func (c RecoveryCode) Used() bool

Used reports whether the code has already been consumed.

type RecoveryCodeID added in v0.3.0

type RecoveryCodeID uuid.UUID

RecoveryCodeID uniquely identifies one MFA recovery code row.

func NewRecoveryCodeID added in v0.3.0

func NewRecoveryCodeID() RecoveryCodeID

NewRecoveryCodeID returns a new time-ordered (UUIDv7) recovery code id.

func ParseRecoveryCodeID added in v0.3.0

func ParseRecoveryCodeID(s string) (RecoveryCodeID, error)

ParseRecoveryCodeID parses a canonical UUID string into a RecoveryCodeID.

func (RecoveryCodeID) String added in v0.3.0

func (id RecoveryCodeID) String() string

String returns the canonical UUID string.

type Role

type Role string

Role is a member's role within a household, unified across Nestova and Nestorage (epic NSTR-112): apps derive their own admin-vs-member behavior from these three values and never store a role vocabulary of their own — an admin/member vocabulary would flatten child into adult and lose that distinction permanently. Stored as text, validated here.

const (
	RoleOwner Role = "owner"
	RoleAdult Role = "adult"
	RoleChild Role = "child"
)

Member roles.

func ParseRole

func ParseRole(s string) (Role, error)

ParseRole validates and returns a Role, or an error for an unknown value.

func (Role) CanAdminister

func (r Role) CanAdminister() bool

CanAdminister reports whether r carries household-admin privileges (owner or adult) — the derivation Nestorage (and any future consumer) uses instead of storing its own admin/member flag.

func (Role) String

func (r Role) String() string

String returns the role's stored value.

func (Role) Valid

func (r Role) Valid() bool

Valid reports whether r is a known role.

type WebAuthnCredential added in v0.3.0

type WebAuthnCredential struct {
	ID          WebAuthnCredentialID
	MemberID    MemberID
	HouseholdID HouseholdID
	// CredentialID is the WebAuthn credential id the authenticator
	// itself generated — an opaque byte handle, globally unique by
	// construction.
	CredentialID []byte
	// PublicKey is the CBOR-encoded credential public key. Not encrypted
	// at rest: a public key is not a secret.
	PublicKey []byte
	// SignCount is the authenticator's signature counter as of the last
	// successful ceremony (registration sets it once; a login/step-up
	// assertion updates it thereafter for clone detection).
	SignCount uint32
	// Transports are the authenticator's advertised transport hints
	// (e.g. "internal", "hybrid") — advisory only, never a security
	// boundary.
	Transports []string
	// AAGUID identifies the authenticator model, when the authenticator
	// reports one; nil for a model that reports none.
	AAGUID *uuid.UUID
	// Nickname is the member-chosen label shown in a "Your devices"
	// list.
	Nickname string
	// UserHandle is this member's stable, HMAC-derived WebAuthn user
	// handle (app.WebAuthnUserHandleDeriver), stored redundantly on
	// every one of the member's credential rows so a usernameless login
	// lookup is a single indexed equality query against this table
	// alone.
	UserHandle []byte
	CreatedAt  time.Time
	// LastUsedAt is nil until a login or step-up ceremony first uses
	// this credential.
	LastUsedAt *time.Time
}

WebAuthnCredential is one member's registered passkey. A member may register several (phone, laptop, security key); each row is independent — revoking one never affects the others.

type WebAuthnCredentialID added in v0.3.0

type WebAuthnCredentialID uuid.UUID

WebAuthnCredentialID uniquely identifies one registered WebAuthn credential row. Distinct from the credential's own id (WebAuthnCredential.CredentialID, an opaque handle the authenticator itself generates) — this is identity's own row identifier, used for rename/revoke.

func NewWebAuthnCredentialID added in v0.3.0

func NewWebAuthnCredentialID() WebAuthnCredentialID

NewWebAuthnCredentialID returns a new time-ordered (UUIDv7) credential row id.

func ParseWebAuthnCredentialID added in v0.3.0

func ParseWebAuthnCredentialID(s string) (WebAuthnCredentialID, error)

ParseWebAuthnCredentialID parses a canonical UUID string into a WebAuthnCredentialID.

func (WebAuthnCredentialID) String added in v0.3.0

func (id WebAuthnCredentialID) String() string

String returns the canonical UUID string.

type WebAuthnCredentialRepository added in v0.3.0

type WebAuthnCredentialRepository interface {
	// ListByMember returns every credential registered by memberID within
	// householdID, oldest first.
	ListByMember(ctx context.Context, householdID HouseholdID, memberID MemberID) ([]WebAuthnCredential, error)

	// Create persists a newly registered credential. The caller supplies
	// a fully populated WebAuthnCredential (ID already assigned via
	// NewWebAuthnCredentialID).
	Create(ctx context.Context, householdID HouseholdID, cred *WebAuthnCredential) error

	// Rename updates the nickname on the credential identified by id,
	// scoped to memberID and householdID.
	Rename(ctx context.Context, householdID HouseholdID, memberID MemberID, id WebAuthnCredentialID, nickname string) error

	// Delete removes the credential identified by id, scoped to
	// memberID and householdID — revoking it immediately.
	Delete(ctx context.Context, householdID HouseholdID, memberID MemberID, id WebAuthnCredentialID) error

	// FindByUserHandle resolves a stable WebAuthn user handle (the value
	// WebAuthnUserHandleDeriver.Derive computes for a member, stored
	// redundantly on every one of that member's credential rows) to the
	// member that owns it and every one of that member's registered
	// credentials — the lookup a usernameless login performs after the
	// browser selects a discoverable credential and the assertion
	// response reports its userHandle.
	FindByUserHandle(ctx context.Context, handle []byte) (MemberID, []WebAuthnCredential, error)

	// UpdateAfterAssertion persists the authenticator's new signature
	// counter and last-used timestamp on the credential identified by
	// credentialID (the raw WebAuthn credential id — globally unique by
	// construction — not this row's own WebAuthnCredentialID). Called
	// after EVERY successful login or step-up assertion, regardless of
	// whether the new count triggered a clone-suspicion flag: the
	// caller (app.WebAuthnService) always advances the stored count to
	// the authenticator's latest reported value so the NEXT assertion's
	// comparison is against up-to-date state, not a stale one that
	// would otherwise re-flag a legitimate, already-seen count forever.
	//
	// The write is monotonically guarded on usedAt: when two assertions
	// on the SAME credential complete concurrently, a later-completing
	// but OLDER (smaller usedAt) one must never overwrite a newer
	// sign_count/last_used_at pair a faster concurrent assertion
	// already recorded — doing so would silently regress state
	// clone-detection depends on. Losing that race is NOT an error (see
	// the interface doc's own error contract): the assertion was
	// already verified before this call, so the caller's login/step-up
	// still succeeds either way.
	UpdateAfterAssertion(ctx context.Context, credentialID []byte, signCount uint32, usedAt time.Time) error
}

WebAuthnCredentialRepository is the outbound port for persisting and retrieving a member's registered WebAuthn credentials. Implementations live in identity/adapter.

Error contracts:

  • ListByMember never returns ErrWebAuthnCredentialNotFound for a member with no credentials — it returns an empty slice. Scoped to BOTH memberID and householdID, mirroring Rename/Delete's own defense-in-depth tenant check: a memberID belonging to a DIFFERENT household than householdID must return an empty slice, never that other household's credentials.
  • Create returns ErrHouseholdNotFound when householdID does not exist, ErrMemberNotFound when cred.MemberID does not belong to householdID (FK violations), and ErrWebAuthnCredentialExists when cred.CredentialID is already registered (unique violation).
  • Rename and Delete return ErrWebAuthnCredentialNotFound when no row matches id scoped to BOTH memberID and householdID — a defense-in-depth tenant check.
  • FindByUserHandle returns ErrMemberNotFound when no credential row carries handle at all — an unknown, forged, or stale handle, or a handle for a member whose last credential was since revoked (member_credential's composite tenant FK cascades on member deletion, so a resolvable handle always names a member that still exists). Implementations must also return an error (never silently pick one) if handle's rows resolve to more than one DISTINCT member — user_handle carries no uniqueness constraint (it is a plain index, see the migration's own doc), so this is the defense-in-depth guard against a handle collision (an HMAC-derived handle colliding across members, or a derivation defect) silently authenticating the wrong member.
  • UpdateAfterAssertion returns ErrWebAuthnCredentialNotFound only when credentialID matches no row at all — defense-in-depth only; a caller only ever supplies a credentialID that a preceding FindByUserHandle or ListByMember call in the SAME request just returned. When the row exists but a monotonic guard skipped the write (a concurrent, more recently issued assertion already recorded fresher state), this returns nil, not an error.

Jump to

Keyboard shortcuts

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