service

package
v1.0.4 Latest Latest
Warning

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

Go to latest
Published: Aug 21, 2026 License: MIT Imports: 42 Imported by: 0

Documentation

Overview

Package service implements the business logic layer for the Vault auth service, including user authentication, token issuance and rotation, MFA policy enforcement, and HIBP breach checking.

Index

Constants

View Source
const (
	// ConsentSourceRegistration — an explicit boolean supplied by a frontend at
	// sign-up. This is the only source that is unambiguously affirmative consent.
	ConsentSourceRegistration = "registration"
	// ConsentSourceProfile — the user changed the preference on their profile.
	ConsentSourceProfile = "profile"
	// ConsentSourceUnsubscribe — withdrawal via the one-click unsubscribe link.
	ConsentSourceUnsubscribe = "unsubscribe"
	// ConsentSourceImport — carried over from a migrated system. NOT affirmative
	// consent on its own: the value may be a default the user never saw. Records
	// with this source keep the imported value but are flagged for re-permission.
	ConsentSourceImport = "import"
	// ConsentSourceLegacy — the profile predates consent provenance. The value is
	// known, the origin is not; recorded honestly rather than backfilled.
	ConsentSourceLegacy = "legacy"
)

Consent sources. These are recorded verbatim so a later audit can tell an affirmative opt-in apart from a value that was merely carried over.

View Source
const (
	MethodPassword   = "password"    // memorized secret (something you know)
	MethodTOTP       = "totp"        // time-based one-time password authenticator
	MethodEmailOTP   = "email_otp"   // one-time code delivered out-of-band by email
	MethodBackupCode = "backup_code" // pre-shared single-use recovery code
	MethodWebAuthn   = "webauthn"    // WebAuthn / FIDO2 phishing-resistant authenticator
	// MethodFederated is an assertion from an upstream identity provider. It is
	// a first factor like a password — the IdP verified the user by some means
	// vault42 did not observe — and it is never a second one.
	MethodFederated = "federated"
)

Authenticator method identifiers as surfaced in MFAStatus.Methods. These are the canonical strings used to describe a completed factor.

View Source
const AdminSessionSweepInterval = time.Hour

AdminSessionSweepInterval is how often expired admin sessions are reaped. Admin sessions are short-lived and few (one row per admin login), so an hour is frequent enough to keep the table from accumulating expired rows without pinning the sweep to a shorter cadence than the data warrants.

View Source
const GlobalSubject = "_global"

GlobalSubject is the sentinel path segment for documents that belong to a service rather than to any user: feature flags, per-service settings.

It is a sentinel rather than a NULL subject because Postgres treats NULLs as distinct in a unique index, so a nullable column would silently permit duplicate (client_id, NULL, doc_key) rows. It cannot collide with a real subject: svcDocSubjectCharset requires a subject to start with an alphanumeric, and this one starts with an underscore.

View Source
const MintedTokenType = "mint"

MintedTokenType is the token_type claim on a minted token.

The value matters: vault42's own auth middleware accepts "Bearer" and, on the 2FA verify routes, "2fa_challenge". Anything else is rejected with invalid_token_type. Minting into a type outside that allow-list is what stops a minted subject assertion from being replayed against vault42's own authenticated endpoints.

View Source
const RecoverySweepInterval = 6 * time.Hour

RecoverySweepInterval is how often the escrow retention sweeper runs. Retention horizons are measured in days, so sweeping more often than daily buys nothing; sweeping exactly daily would pin the purge to whenever the process last restarted.

View Source
const RefreshTokenSweepInterval = time.Hour

RefreshTokenSweepInterval is how often spent and expired refresh tokens are reaped. Refresh tokens are short-lived relative to an hour and there is one row per rotation, so hourly keeps the table flat without making the sweep a load of its own.

View Source
const SweepMaxBatches = 20

SweepMaxBatches bounds one tick.

PruneLocked deletes at most one batch per call, so a sweep loops. The loop needs a ceiling for the same reason the audit sweeper has one: a tick that keeps going until the horizon is empty is a tick with no end, and the remainder is not urgent — the next tick picks it up. At the repository's batch size this is 40 000 rows per tick, four times a day.

Variables

View Source
var (
	ErrInvalidInput       = errors.New("invalid input")
	ErrEmailTaken         = errors.New("email already registered")
	ErrInvalidCredentials = errors.New("invalid credentials")
	ErrAccountLocked      = errors.New("account locked")
	ErrAccountBanned      = errors.New("account banned")
	ErrAccountDisabled    = errors.New("account disabled")
	ErrPasswordBreached   = errors.New("password found in breach database")
	ErrPasswordTooShort   = errors.New("password too short")
	ErrPasswordReused     = errors.New("password recently used")
	ErrTokenExpired       = errors.New("token expired")
	ErrTokenUsed          = errors.New("token already used")
	ErrTokenInvalid       = errors.New("invalid token")
	ErrReplayDetected     = errors.New("refresh token replay detected")
	// ErrEmailOTPNotAllowed is returned when email-OTP is requested for a user
	// who has a stronger enrolled factor (TOTP/WebAuthn). Email-OTP is only a
	// fallback for accounts with no second factor when MFA is required.
	ErrEmailOTPNotAllowed = errors.New("email OTP not permitted for this account")
	ErrMFARequired        = errors.New("MFA verification required")
	// ErrPasswordResetRequired is returned when the account carries
	// must_reset_password: its stored password may not be used to sign in, a
	// reset link has been mailed out of band, and no session was issued.
	//
	// It reaches a caller only when LoginInput.DiscloseStatus is set, which the
	// transport sets only for a client that authenticated with client
	// credentials carrying the login:status scope. Every other caller is
	// answered with ErrInvalidCredentials from the same branch, after the same
	// work, because a distinct outcome on an unauthenticated login says the
	// address is registered (ASVS V2.1.1).
	ErrPasswordResetRequired = errors.New("password reset required")
	ErrChallengeConsumed     = errors.New("challenge token already consumed")
	ErrTooManySessions       = errors.New("maximum concurrent sessions reached")
	// ErrSessionExpired is returned when a refresh-token family has reached the
	// absolute session lifetime and must reauthenticate regardless of activity
	// (NIST SP 800-63B-4 §2.2.3). It wraps ErrTokenExpired so every transport that
	// already maps an expired refresh token keeps its status code and its
	// cookie-clearing behavior, and so the outcome is indistinguishable from an
	// ordinary expiry to the client.
	ErrSessionExpired = fmt.Errorf("session exceeded maximum lifetime: %w", ErrTokenExpired)
	// ErrSessionAgeUnknown is the fail-closed outcome when a session bound is
	// configured but the age it measures cannot be established — the family's
	// origin for the absolute lifetime, or the presented token's issuance instant
	// for the inactivity timeout. It wraps ErrTokenInvalid: an unbounded session
	// must not be issued because a lookup failed.
	ErrSessionAgeUnknown = fmt.Errorf("session age could not be determined: %w", ErrTokenInvalid)
	// ErrSessionIdle is returned when a refresh-token family has gone unused for
	// longer than the inactivity timeout and must reauthenticate (ASVS V7.3.1,
	// NIST SP 800-63B-4 §2.2.3 and §5.2, NIST SP 800-53 Rev 5 AC-12). It wraps
	// ErrTokenExpired for the same reason ErrSessionExpired does: every transport
	// that already maps an expired refresh token keeps its status code and its
	// cookie-clearing behavior, and the outcome stays indistinguishable from an
	// ordinary expiry to the client.
	ErrSessionIdle = fmt.Errorf("session exceeded the inactivity timeout: %w", ErrTokenExpired)
)

Sentinel errors returned by AuthService methods.

View Source
var (
	ErrBlobTooSmall  = errors.New("blob too small")
	ErrBlobTooLarge  = errors.New("blob too large")
	ErrQuotaExceeded = errors.New("storage quota exceeded")
	ErrBlobNotFound  = errors.New("blob not found")
)

Sentinel errors returned by BlobService methods.

View Source
var (
	// ErrMintSubjectInvalid is returned for a missing or malformed subject.
	ErrMintSubjectInvalid = errors.New("invalid mint subject")
	// ErrMintRoleNotPermitted is returned when a requested role is outside the
	// allow-list or is an admin-tier name.
	ErrMintRoleNotPermitted = errors.New("mint role not permitted")
	// ErrMintScopeNotPermitted is returned when a requested scope is outside the
	// allow-list or is a vault42 capability scope.
	ErrMintScopeNotPermitted = errors.New("mint scope not permitted")
	// ErrMintTTLInvalid is returned for a requested lifetime above the ceiling.
	ErrMintTTLInvalid = errors.New("invalid mint ttl")
	// ErrMintUnavailable is returned when no signing key is available.
	ErrMintUnavailable = errors.New("mint signing key unavailable")
)

Sentinel errors returned by MintService.

View Source
var (
	// ErrSvcDocInvalidKey is returned for a document key outside the charset.
	ErrSvcDocInvalidKey = errors.New("invalid document key")
	// ErrSvcDocInvalidSubject is returned for a subject outside the charset.
	ErrSvcDocInvalidSubject = errors.New("invalid document subject")
	// ErrSvcDocInvalidDocument is returned for a body that is not a JSON object,
	// is too deeply nested, carries duplicate keys, or is not valid UTF-8.
	ErrSvcDocInvalidDocument = errors.New("invalid document")
	// ErrSvcDocTooLarge is returned when a document exceeds the per-document cap.
	ErrSvcDocTooLarge = errors.New("document too large")
	// ErrSvcDocQuotaExceeded is returned when a write would breach the document
	// count or the per-subject byte quota.
	ErrSvcDocQuotaExceeded = errors.New("document quota exceeded")
	// ErrSvcDocNotFound is returned when no readable document exists.
	ErrSvcDocNotFound = errors.New("document not found")
	// ErrSvcDocAmbiguous is returned when more than one other client publishes a
	// shared document at the requested key and the caller named no owner.
	ErrSvcDocAmbiguous = errors.New("ambiguous document")
	// ErrSvcDocSharedDisabled is returned when a shared write is attempted while
	// the shared visibility tier is switched off.
	ErrSvcDocSharedDisabled = errors.New("shared visibility disabled")
	// ErrSvcDocUnknownOwner is returned when a named owner is not a registered client.
	ErrSvcDocUnknownOwner = errors.New("unknown document owner")
)

Sentinel errors returned by DocumentService. The handler maps these to status codes; nothing else about a failure reaches the caller.

View Source
var ErrConcurrentUpdate = errors.New("identity profile changed concurrently")

ErrConcurrentUpdate is returned when a profile kept changing underneath a read-modify-write for more than consentUpdateAttempts tries.

View Source
var (
	// ErrInvalidProfile is returned when identity data fails validation.
	ErrInvalidProfile = errors.New("invalid identity profile")
)
View Source
var ErrUserNotFound = errors.New("user not found")

ErrUserNotFound is returned by DeleteAccount when the target user does not exist (or was already erased).

Functions

func ACRForAAL added in v1.0.3

func ACRForAAL(aal AuthenticatorAssuranceLevel) string

ACRForAAL renders an assurance level as the OIDC Core §2 "acr" value.

OIDC leaves the acr value space to the issuer ("parties using this claim will need to agree upon the meanings of the values used"), so this is vault42's own URN and it means the NIST SP 800-63B AAL of the same number. It is deliberately not one of the idmanagement.gov URLs, which belong to a US federal assurance program vault42 has not been assessed under.

func AMRForMethods added in v1.0.3

func AMRForMethods(methods []string, userVerified bool) []string

AMRForMethods renders the completed methods as RFC 8176 "amr" values.

Each value describes something this server verified. A federated first factor contributes none: the upstream provider performed that authentication and RFC 8176 registers no value for "an assertion from another issuer", so claiming one would describe a check vault42 did not make. "mfa" is appended whenever the combination reaches AAL2 or above, per RFC 8176 §2, which allows the specific methods to be listed alongside it.

func CanonicalSubject added in v1.0.3

func CanonicalSubject(subject string) string

CanonicalSubject folds a subject to the single spelling every pseudonym is taken over.

The accepted charset admits mixed case and '@', so an email and a differently-cased identifier are both legal subjects, and the pseudonym used to be an HMAC over the caller's raw string. "Alice@example.com" and "alice@example.com" were therefore two disjoint namespaces for one human, each with its own quota, its own documents and its own erasure outcome — and the erasure cascade, which derives its pseudonym from the account's user id, missed every document filed under any other spelling of it while reporting success.

Case folding is the whole canonicalisation, and it is sufficient only because svcDocSubjectCharset is ASCII: a Unicode subject would need NFC normalisation first, since two byte sequences can spell one character. TestSubjectCharsetIsASCIIOnly pins that premise, so widening the charset fails there rather than silently reintroducing the split.

The global sentinel is returned verbatim. It is already lowercase, and folding it would be a no-op, but naming it here keeps the sentinel's identity a decision rather than an accident of its spelling.

func ClearAccountLockout added in v1.0.3

func ClearAccountLockout(ctx context.Context, c cache.Cache, userID string) error

ClearAccountLockout retires every lockout counter standing against a user, and is what a completed password reset calls.

A free function over a cache rather than a method, because the caller is handler.PasswordHandler, which holds a cache and has no reason to hold an AuthService. What it must not do is what that handler used to do: build the key itself from a literal. There are three pieces of lockout state and a hand-rolled "lockout:"+id reached exactly one of them.

The account-wide counter is deleted outright. The per-source counters cannot be — one key per address, nothing to enumerate them — so the generation is advanced instead, which makes every one of them unaddressable at once. The durable failed_login_count is the caller's to reset; it lives in the user row, not the cache.

func MintTTLFromSeconds added in v1.0.3

func MintTTLFromSeconds(seconds int) (time.Duration, error)

MintTTLFromSeconds converts a caller-supplied lifetime in seconds into a Duration, refusing any value outside the range a minted token could ever be granted.

The bound has to be applied to the seconds, before the multiply, and not left to the range check on the resulting Duration. A Duration is int64 nanoseconds and time.Second is 1e9 = 2^9 * 1953125; the odd factor is invertible modulo a power of two, so seconds values differing by 2^55 multiply to the identical nanosecond count. 36028797018964268 seconds is about 1.1 billion years and converts to exactly five minutes. A ceiling check applied after the multiply sees an ordinary lifetime and grants it, so the endpoint answers an out-of-range request with a signed subject assertion instead of the refusal the contract promises, and neither the caller nor the audit row shows that anything was out of range.

The bound is the hard ceiling rather than the configured MaxTTL, because MaxTTL is the operator's policy and belongs to Mint; this function's job is only to make the conversion exact for everything it lets through.

func ParseVisibility added in v1.0.3

func ParseVisibility(s string) (repository.ServiceDocumentVisibility, bool)

ParseVisibility maps the wire form to the stored tier. An empty value is private: the default on every write is the closed one.

func ValidateDocKey added in v1.0.3

func ValidateDocKey(key string) error

ValidateDocKey checks a document key against the same shape the migration's CHECK constraint enforces, so a bad key is a 400 rather than a constraint violation surfacing as a 500.

func ValidateDocumentStructure added in v1.0.3

func ValidateDocumentStructure(raw []byte) error

ValidateDocumentStructure walks a document's token stream and rejects anything the store will not hold.

The walk exists because encoding/json has no depth limit and no duplicate-key rejection. A 64 KiB body of '[' characters is roughly 32 thousand nesting levels; unmarshalling it recurses that deep and takes the process down. And a document with a repeated key decodes last-wins, so it round-trips differently than it was submitted, which is a correctness bug on its own and a signature-bypass primitive if anything downstream ever verifies a body it also parses.

json.Decoder.Token keeps its own iterative parse state, so reading the stream costs no stack; only this function's own recursion does, and that is bounded by the depth check below at svcDocMaxDepth frames.

func ValidateMintSubject added in v1.0.3

func ValidateMintSubject(subject string) error

ValidateMintSubject checks the caller-asserted subject.

func ValidateSubject added in v1.0.3

func ValidateSubject(subject string) error

ValidateSubject checks a subject path segment. The global sentinel is accepted verbatim; anything else must match the narrow subject charset.

func VisibilityName added in v1.0.3

VisibilityName renders the wire form of a visibility tier. The wire form is a string enum, not a boolean, so a later tier is an added value rather than a changed field type.

Types

type AdminSessionRetention added in v1.0.3

type AdminSessionRetention struct {
	// contains filtered or unexported fields
}

AdminSessionRetention reaps expired admin sessions on a timer.

AdminSessionRepo.DeleteExpired removes every row past its expiry, but nothing called it: the admin gateway ran only its HTTPS listener, so expired sessions accumulated with their token hash, IP and user-agent long after they could authenticate. This runs the sweep the same way RecoveryRetention runs the escrow purge, with the same guards, so the reaper actually reaps.

func NewAdminSessionRetention added in v1.0.3

func NewAdminSessionRetention(repo repository.AdminSessionRepository) *AdminSessionRetention

NewAdminSessionRetention builds a sweeper over the admin-session repository.

func (*AdminSessionRetention) Done added in v1.0.3

func (r *AdminSessionRetention) Done() <-chan struct{}

Done is closed once the sweep loop has exited, whether via Stop or a canceled context, so a caller that closes the database pool on return does not race a sweep mid-DELETE. It never closes if Start was not called.

func (*AdminSessionRetention) Start added in v1.0.3

func (r *AdminSessionRetention) Start(ctx context.Context)

Start runs the sweeper until Stop is called. It sweeps once immediately so a process that restarts more often than the interval still reaps. Calling it more than once starts nothing further: two loops would share one doneCh, and the second to exit would close an already-closed channel.

func (*AdminSessionRetention) Stop added in v1.0.3

func (r *AdminSessionRetention) Stop()

Stop terminates the sweep loop and blocks until it has exited, so the sweeper cannot outlive the pool its caller closes on return. Safe to call more than once and safe on a sweeper that was never started.

func (*AdminSessionRetention) Sweep added in v1.0.3

func (r *AdminSessionRetention) Sweep(ctx context.Context) (int64, error)

Sweep deletes every expired admin session and returns how many rows went.

type AuthContext added in v1.0.3

type AuthContext struct {
	ACR      string
	AMR      []string
	AuthTime time.Time
}

AuthContext carries the OIDC Core §2 authentication-event claims onto an issued access token: which assurance level the login reached (acr), which authenticators it presented (amr), and when it happened (auth_time).

A zero value emits none of them, which is what an issuance that observed no authentication event — a client-credentials token — should say.

func NewAuthContext added in v1.0.3

func NewAuthContext(at time.Time, methods []string, userVerified bool) AuthContext

NewAuthContext derives the authentication-event claims from the factors a login completed with. userVerified is the WebAuthn UV flag; it is false for every other authenticator.

type AuthService

type AuthService struct {
	// contains filtered or unexported fields
}

AuthService handles registration, login, and token operations.

func NewAuthService

func NewAuthService(
	users repository.UserRepository,
	tokens repository.RefreshTokenRepository,
	devices repository.DeviceRepository,
	pwHistory repository.PasswordHistoryRepository,
	tokenSvc *TokenService,
	mfaSvc *MFAService,
	auditLog *audit.Logger,
	hibp *HIBPClient,
	c cache.Cache,
	emailSender vaultemail.Sender,
	origin, appName, pepper string,
	minPwLength int,
	hibpEnabled bool,
	hmacSecret []byte,
) *AuthService

NewAuthService creates a new auth service.

func (*AuthService) ChallengeFingerprintMatches added in v0.8.0

func (s *AuthService) ChallengeFingerprintMatches(ctx context.Context, userID, challengeFP, requestFP, ip, ua string) bool

ChallengeFingerprintMatches reports whether the device fingerprint embedded in a 2fa_challenge token matches the fingerprint recomputed from the redeeming request. An empty challengeFP (legacy token without the claim) is treated as a match so in-flight challenges aren't bricked. On mismatch it records a FingerprintAnomaly audit event — the device/network-switch signal the claim was added to detect, kept consistent with the refresh path (audit M1).

func (*AuthService) CheckSessionLimit added in v1.0.3

func (s *AuthService) CheckSessionLimit(ctx context.Context, userID string) error

CheckSessionLimit applies the concurrent-session-family cap on behalf of a login path that mints a family outside Login and CompleteMFALogin.

The OAuth/social callback issues a pair and writes the refresh-token row itself, so without this call the cap is enforced on the password path and not on the social one, and a user is capped or uncapped depending on how they chose to sign in. The client-credentials grant is deliberately not a caller: it returns an access token only and never writes a refresh-token row, so it creates no family for CountActiveFamilies to count.

This is the same soft pre-check Login runs before storeRefreshToken. The hard bound is CreateWithinCap on the insert, which the OAuth callback must also use: a pre-check alone is the race documented on checkSessionLimit.

func (*AuthService) CompleteMFALogin

func (s *AuthService) CompleteMFALogin(ctx context.Context, userID, fingerprint, ip, ua, jti string, completion MFACompletion) (*LoginResult, error)

CompleteMFALogin issues tokens after successful MFA verification. Called by TOTP verify and WebAuthn verify handlers when a 2fa_challenge token is presented. The jti parameter enforces single-use: once consumed, the same challenge token is rejected.

func (*AuthService) FindOrCreateDevice added in v1.0.3

func (s *AuthService) FindOrCreateDevice(ctx context.Context, userID, fp, ip, ua string) string

FindOrCreateDevice resolves the device a session belongs to on behalf of a login path that mints its refresh-token family outside Login and CompleteMFALogin.

The OAuth/social callback writes the refresh-token row itself, so it needs the same device binding the password path applies. Without it the row carries a NULL device_id, the session never shows in GET /user/sessions (which lists devices), and RevokeByDeviceID cannot reach it because its WHERE device_id = $1 never matches a NULL. Semantics match the password path, including the non-critical, log-but-do-not-fail behavior of findOrCreateDevice.

func (*AuthService) Login

func (s *AuthService) Login(ctx context.Context, input LoginInput, ip, ua string) (result *LoginResult, err error)

Login authenticates a user and issues tokens.

func (*AuthService) Logout

func (s *AuthService) Logout(ctx context.Context, userID, ip, ua string) error

Logout revokes all refresh tokens for a user.

func (*AuthService) MFAVerifyLocked added in v0.8.0

func (s *AuthService) MFAVerifyLocked(ctx context.Context, userID string) bool

MFAVerifyLocked reports whether the account is locked out from further auth attempts by the caller's source. MFA verify endpoints MUST call this before checking a second factor: the per-IP rate limit alone is defeated by IP rotation, so without a per-account gate the second factor is brute-forceable within the challenge window (audit H2).

The source address comes from the request context rather than the signature, so the four MFA handlers did not have to change. A context with no address (a CLI call, a test) still gets a gate: it reads the shared unknown-source bucket, never an exemption.

func (*AuthService) MaxSessionsPerUser added in v1.0.3

func (s *AuthService) MaxSessionsPerUser() int

MaxSessionsPerUser returns the configured concurrent-family cap, or 0 when the bound is disabled. Login paths that persist a family themselves (the OAuth callback) pass this into CreateWithinCap so the insert, not only the CheckSessionLimit pre-check, is the hard bound.

func (*AuthService) RecordMFAFailure added in v0.8.0

func (s *AuthService) RecordMFAFailure(ctx context.Context, userID, ip, ua string)

RecordMFAFailure counts a failed second-factor attempt toward the lockout for this (account, source) pair and toward the account-wide distributed counter, so password and MFA failures from one address still trip the same lockoutThreshold within lockoutDuration. Reset on success via clearLockout in CompleteMFALogin (audit H2).

func (*AuthService) Refresh

func (s *AuthService) Refresh(ctx context.Context, refreshToken, ip, ua string, fpInput vaultcrypto.FingerprintInput) (*RefreshResult, error)

Refresh exchanges a refresh token for a new token pair.

func (*AuthService) Register

func (s *AuthService) Register(ctx context.Context, input RegisterInput, ip string) (*RegisterResult, error)

Register creates a new user account.

func (*AuthService) RevokeAllTokensForUser

func (s *AuthService) RevokeAllTokensForUser(ctx context.Context, userID string) error

RevokeAllTokensForUser nukes every active refresh-token family for the given user. Used by post-incident security paths (e.g. WebAuthn clone-warning, administrative lock) where the SDK suspects a credential has been compromised. Caller is responsible for emitting any incident-specific audit log; this method does not.

func (*AuthService) SendEmailOTP

func (s *AuthService) SendEmailOTP(ctx context.Context, userID, emailAddr string) error

SendEmailOTP generates a 6-digit code, HMACs it, caches the signature, and sends the code via email.

func (*AuthService) SendSignupVerification added in v1.0.3

func (s *AuthService) SendSignupVerification(ctx context.Context, to, userID, redirectTo string)

SendSignupVerification mails a verification link for an account that was just created, and returns immediately. Delivery outlives the request and is best effort: the row is already committed when this is called, so a mailer outage must not turn a completed signup into a failure the caller reports.

Every signup path goes through here so none of them can drift into its own error convention. Password registration is one. A social login whose provider does not vouch for the address is the other, and that one is not optional: GET /auth/verify-email consumes a token it never issues, so an unverified OAuth account that received no mail can never become verified, and because the address is taken a later login through a provider that does verify it is refused with 409.

redirectTo is a sanitized relative path the verification link returns the user to, or empty for the default landing page.

func (*AuthService) SetHoneypotAlerter

func (s *AuthService) SetHoneypotAlerter(a *honeypot.Alerter)

SetHoneypotAlerter configures the honeypot alerter for trap user detection. When set, login attempts with trap user credentials return fake tokens instead of real authentication failures.

func (*AuthService) SetIPIntel added in v1.0.3

func (s *AuthService) SetIPIntel(db *ipintel.DB)

SetIPIntel configures the IP-intelligence handle used to derive a coarse country signal (and, in the rate limiter, VPN/hosting/Tor flags) from a client address. When nil — the default — the new-location notice no-ops entirely; the whole feature is gated on this handle being present.

func (*AuthService) SetLoginCountryRepo added in v1.0.3

func (s *AuthService) SetLoginCountryRepo(r repository.LoginCountryRepository)

SetLoginCountryRepo configures the store that remembers which countries a user has logged in from, backing the new-location notice. Without it (the default) the notice no-ops: there is nothing to compare a login's country against.

func (*AuthService) SetMailer added in v0.9.0

func (s *AuthService) SetMailer(m *vaultemail.Mailer)

SetMailer replaces the email mailer to enable per-app white-label branding and template overrides (resolved through an email.OverrideStore). Called once at wiring time; a nil mailer is ignored.

func (*AuthService) SetMaxSessionsPerUser

func (s *AuthService) SetMaxSessionsPerUser(n int)

SetMaxSessionsPerUser configures the maximum concurrent refresh token families allowed per user. When the limit is reached, new logins are rejected with ErrTooManySessions. A value of 0 disables the check.

func (*AuthService) SetMetrics

func (s *AuthService) SetMetrics(m *metrics.Collector)

SetMetrics configures the metrics collector for login/token counters.

func (*AuthService) SetRateLimitRepo

func (s *AuthService) SetRateLimitRepo(r repository.RateLimitRepository)

SetRateLimitRepo configures the PostgreSQL-backed rate limit repository used as a fallback for IP lockout when the cache is unavailable.

func (*AuthService) SetRoleCatalog added in v0.8.0

func (s *AuthService) SetRoleCatalog(c *RoleCatalog)

SetRoleCatalog enables catalog-aware role validation. When set, JWT issuance keeps only roles present in the auth.app_roles catalog (in addition to the admin-reserved filter). Nil (the default) preserves the prior behavior.

func (*AuthService) SetStrictSessionLimit added in v0.8.0

func (s *AuthService) SetStrictSessionLimit(strict bool)

SetStrictSessionLimit controls checkSessionLimit's behavior on a count-query error: true fails closed (rejects login + audits), false (default) fails open.

func (*AuthService) VerifyEmailOTP

func (s *AuthService) VerifyEmailOTP(ctx context.Context, userID, code string) error

VerifyEmailOTP verifies a 6-digit email OTP code. Single-use via atomic GetAndDelete.

type AuthenticatorAssuranceLevel added in v0.8.0

type AuthenticatorAssuranceLevel int

AuthenticatorAssuranceLevel models the NIST SP 800-63B authenticator assurance levels (AAL, §4) reached by a completed authentication. The level is a function of which authenticator combination the user presented; these constants give callers a stable vocabulary for reasoning about that mapping (see AALForMethods). They are descriptive only and impose no policy on their own.

const (
	// AAL1 — single-factor authentication (e.g. password alone). Provides some
	// assurance that the claimant controls an authenticator bound to the
	// account. NIST SP 800-63B §4.1.
	AAL1 AuthenticatorAssuranceLevel = 1

	// AAL2 — multi-factor authentication: a memorized secret (password) plus a
	// second factor such as a TOTP authenticator or an email one-time code.
	// Proves possession and control of two distinct factors. NIST SP 800-63B
	// §4.2 / §5.2.4.
	AAL2 AuthenticatorAssuranceLevel = 2

	// AAL3 — multi-factor authentication using a hardware-based, phishing-
	// resistant authenticator. Requires proof of possession of a key via a
	// cryptographic protocol and verifier impersonation resistance. NIST SP
	// 800-63B §4.3.
	//
	// vault42 never reaches this level and AALForMethods never returns it. The
	// constant stays because the vocabulary is what makes the ceiling legible:
	// see AALForMethods for what would have to be built first.
	AAL3 AuthenticatorAssuranceLevel = 3
)

func AALForMethods added in v0.8.0

func AALForMethods(methods []string, userVerified bool) AuthenticatorAssuranceLevel

AALForMethods maps a set of completed authenticator methods to the NIST SP 800-63B assurance level they satisfy, applying the §5.2.4 combination rules:

  • WebAuthn with the authenticator's user-verification flag set → AAL2. That is a multi-factor cryptographic authenticator on its own: possession of the key plus the PIN or biometric that unlocked it.
  • a first factor (password or an upstream IdP assertion) plus any possession factor → AAL2.
  • anything else → AAL1.

This function returns AAL3 for nothing, and the ceiling is deliberate rather than an omission.

It used to return AAL3 for user-verified WebAuthn, and that level was rendered into the acr claim of every issued access token (ACRForAAL, NewAuthContext, token.go). A signed token is the strongest form a claim can take, and vault42 cannot support this one. SP 800-63B-4 §2.2.4 requires an AAL3 authenticator to be hardware-based and verifier-impersonation-resistant, and §5.2.4 requires the verifier to establish that it is. This service requests "none" attestation (internal/handler/webauthn.go:218 says so, and adoptUnknownCredentialFlags at :603 depends on it), stores no AAGUID and no attestation statement, and has no metadata service: nothing anywhere in the tree can distinguish a FIDO2 security key from a passkey synced through a consumer cloud account. Both present exactly one self-asserted UV bit. So AAL3 was asserted on evidence that does not separate it from AAL2, over a synced software credential, while docs/COMPLIANCE.md recorded AAL3 as claimed nowhere and the register carried 63B-4 §3.2.4 (Attestation) as Not Applicable.

The ceiling lifts when the evidence exists, not before: persist the AAGUID and attestation statement at registration, verify it against FIDO MDS, and gate a third branch on the result. Until then AAL2 is the true answer, and it is what a relying party reading the token gets.

userVerified is the WebAuthn UV flag from the assertion that completed the login, and it is the reason this takes two arguments. A discoverable- credential assertion with UV clear proves possession of a key and nothing else: it is single-factor, and reporting it as multi-factor would assert a second factor no ceremony performed.

type BillingInfo

type BillingInfo struct {
	AddressLine1 string `json:"address_line_1,omitempty"`
	AddressLine2 string `json:"address_line_2,omitempty"`
	City         string `json:"city,omitempty"`
	PostalCode   string `json:"postal_code,omitempty"`
	Country      string `json:"country,omitempty"`
	VATID        string `json:"vat_id,omitempty"`
}

BillingInfo represents billing address fields.

type BlobConfig

type BlobConfig struct {
	MinBlobSize     int // min single blob size in bytes
	MaxBlobSize     int // max single blob size in bytes
	MaxBlobsPerUser int // max number of blobs per user
	QuotaBytes      int // total storage quota per user in bytes
}

BlobConfig holds blob storage limits.

type BlobMeta

type BlobMeta struct {
	ID          string    `json:"id"`
	Label       string    `json:"label,omitempty"`
	Named       bool      `json:"named"`
	SizeBytes   int       `json:"size_bytes"`
	StoredBytes int       `json:"stored_bytes"`
	Checksum    string    `json:"checksum"`
	CreatedAt   time.Time `json:"created_at"`
}

BlobMeta is the decrypted metadata returned to clients.

type BlobQuotaInfo

type BlobQuotaInfo struct {
	UsedBytes int `json:"used_bytes"`
	MaxBytes  int `json:"max_bytes"`
	UsedCount int `json:"used_count"`
	MaxCount  int `json:"max_count"`
}

BlobQuotaInfo summarizes quota usage.

type BlobService

type BlobService struct {
	// contains filtered or unexported fields
}

BlobService manages encrypted blob storage.

func NewBlobService

func NewBlobService(repo repository.BlobRepository, masterKey, hmacSecret []byte, cfg BlobConfig) *BlobService

NewBlobService creates a new blob service.

func (*BlobService) Delete

func (s *BlobService) Delete(ctx context.Context, userID, blobID string) error

Delete removes a blob by ID for a user.

func (*BlobService) DeleteNamed

func (s *BlobService) DeleteNamed(ctx context.Context, userID, name string) error

DeleteNamed removes a blob by reference name for a user.

func (*BlobService) Download

func (s *BlobService) Download(ctx context.Context, userID, blobID string) (data []byte, label string, checksum string, err error)

Download retrieves, decrypts, and decompresses a blob by ID.

func (*BlobService) DownloadNamed

func (s *BlobService) DownloadNamed(ctx context.Context, userID, name string) (data []byte, label string, checksum string, err error)

DownloadNamed retrieves, decrypts, and decompresses a blob by reference name.

func (*BlobService) List

func (s *BlobService) List(ctx context.Context, userID string) ([]*BlobMeta, *BlobQuotaInfo, error)

List returns blob metadata for a user.

func (*BlobService) Pseudonym

func (s *BlobService) Pseudonym(userID string) string

Pseudonym derives the key a user's blobs are stored under. It is the only link between a user id and their objects, and it is one-way: the blobs table holds the pseudonym rather than the id, so a reader of that table alone cannot enumerate which users hold objects. The ":objects" suffix keeps this value distinct from the same user's identity pseudonym, so a leak of one table's keys does not join it to the other's.

func (*BlobService) Upload

func (s *BlobService) Upload(ctx context.Context, userID string, data []byte, label string) (*model.Blob, error)

Upload compresses, encrypts, and stores a blob for a user.

func (*BlobService) UploadNamed

func (s *BlobService) UploadNamed(ctx context.Context, userID string, data []byte, name string) (*model.Blob, error)

UploadNamed compresses, encrypts, and stores a named blob for a user. If a blob with the same name already exists, it is replaced (delete + insert).

type ConsentRecord added in v0.9.0

type ConsentRecord struct {
	Granted bool      `json:"granted"`
	At      time.Time `json:"at"`
	Source  string    `json:"source"`
	// Origin optionally names the system a ConsentSourceImport record came from
	// (e.g. "beon3"), so an imported list can be re-permissioned selectively.
	Origin string `json:"origin,omitempty"`
}

ConsentRecord captures a single consent decision and where it came from.

func (*ConsentRecord) Affirmative added in v0.9.0

func (c *ConsentRecord) Affirmative() bool

Affirmative reports whether the record can be relied on as consent under Art. 7. Imported and legacy values carry a preference but not a demonstrable act of consent, so they are not affirmative regardless of the flag.

type DocumentConfig added in v1.0.3

type DocumentConfig struct {
	// MaxDocumentBytes caps one document's canonical encoded size.
	MaxDocumentBytes int
	// MaxDocsPerSubject caps how many documents one client holds for one subject.
	MaxDocsPerSubject int
	// QuotaBytesPerSubject caps the stored bytes one client holds for one
	// subject. It is enforced per (client, subject): the budget is charged
	// against the caller's own footprint so a write by one service cannot fill a
	// budget every other service then fails against.
	QuotaBytesPerSubject int
	// SharedEnabled gates the shared visibility tier. Off means a service can
	// keep private state but no service can read another's documents, which is a
	// separate operator decision from enabling the store at all.
	SharedEnabled bool
}

DocumentConfig holds the operator-tunable limits.

type DocumentExport added in v1.0.3

type DocumentExport struct {
	Key        string          `json:"key"`
	Owner      string          `json:"owner,omitempty"`
	OwnerID    string          `json:"owner_id"`
	Visibility string          `json:"visibility"`
	SizeBytes  int             `json:"size_bytes"`
	Document   json.RawMessage `json:"document"`
	CreatedAt  time.Time       `json:"created_at"`
	UpdatedAt  time.Time       `json:"updated_at"`
}

DocumentExport is one document as it appears in a data export: the decrypted body, plus who wrote it.

type DocumentMeta added in v1.0.3

type DocumentMeta struct {
	Key         string    `json:"key"`
	Owner       string    `json:"owner,omitempty"`
	OwnerID     string    `json:"owner_id"`
	Visibility  string    `json:"visibility"`
	SizeBytes   int       `json:"size_bytes"`
	StoredBytes int       `json:"stored_bytes"`
	Mine        bool      `json:"mine"`
	CreatedAt   time.Time `json:"created_at"`
	UpdatedAt   time.Time `json:"updated_at"`
}

DocumentMeta is the metadata view of a document. It never carries the body.

type DocumentMetrics added in v1.0.3

type DocumentMetrics interface {
	RecordSvcDocWrite()
	RecordSvcDocRead()
	RecordSvcDocRejected()
}

DocumentMetrics is the subset of the metrics collector this service records to. It is an interface so the service does not depend on the collector, and so a deployment with metrics disabled passes nil.

type DocumentQuota added in v1.0.3

type DocumentQuota struct {
	UsedBytes int `json:"used_bytes"`
	MaxBytes  int `json:"max_bytes"`
	UsedCount int `json:"used_count"`
	MaxCount  int `json:"max_count"`
}

DocumentQuota summarizes a subject's document usage, mirroring the blob quota shape.

type DocumentService added in v1.0.3

type DocumentService struct {
	// contains filtered or unexported fields
}

DocumentService stores and retrieves encrypted, service-scoped JSON documents.

func NewDocumentService added in v1.0.3

func NewDocumentService(
	repo repository.ServiceDocumentRepository,
	clients repository.ClientRepository,
	masterKey, hmacSecret []byte,
	cfg DocumentConfig,
	metrics DocumentMetrics,
) *DocumentService

NewDocumentService creates a service document service. clients may be nil, in which case owner names are omitted from listings and exports rather than the operation failing. metrics may be nil.

func (*DocumentService) Delete added in v1.0.3

func (s *DocumentService) Delete(ctx context.Context, clientID, subject, docKey string) error

Delete removes the caller's own document. A client can never delete another client's row, shared or not.

func (*DocumentService) DeleteAllForSubject added in v1.0.3

func (s *DocumentService) DeleteAllForSubject(ctx context.Context, userID string) error

DeleteAllForSubject removes every document held about a user, across every owning service. Called by the erasure cascade; idempotent.

func (*DocumentService) ExportForSubject added in v1.0.3

func (s *DocumentService) ExportForSubject(ctx context.Context, userID string) ([]*DocumentExport, error)

ExportForSubject returns every document held for a user, decrypted, for the Art. 15 export.

It returns bodies rather than metadata, which is a deliberate divergence from the blob section of the export. Blobs are opaque files the user uploaded themselves; service documents are bounded structured records a service wrote ABOUT the user, which is squarely the personal data undergoing processing. Private documents are included: a service's privacy from other services is not privacy from the data subject. Global documents are excluded: they are not attached to any subject and exporting them would hand one service's configuration to every user who asks.

A document that fails to decrypt is skipped rather than failing the whole export: one unreadable row must not deny a subject the rest of their data.

func (*DocumentService) Get added in v1.0.3

func (s *DocumentService) Get(ctx context.Context, clientID, subject, docKey, owner string) (json.RawMessage, *DocumentMeta, error)

Get returns a readable document body.

Resolution order is the caller's own document first, then a shared document published by another client. owner optionally names the publishing client so a caller can disambiguate; without it, two clients sharing the same key produce ErrSvcDocAmbiguous rather than an arbitrary pick.

A document owned by another client that is NOT shared is reported as absent, never as forbidden. The alternative turns the store into an oracle for "does service X hold a record at key K about user U", which is exactly the question the pseudonymised subject exists to make unanswerable.

func (*DocumentService) List added in v1.0.3

func (s *DocumentService) List(ctx context.Context, clientID, subject string) ([]*DocumentMeta, *DocumentQuota, error)

List returns metadata for the caller's own documents plus the shared documents other clients hold for the same subject, and the subject's quota position. Bodies are never returned by a listing.

used_bytes reports the CALLER'S own footprint for the subject, never a cross-client total. A cross-client total would answer "does another service hold data about this subject, and how much" for any svcdoc:read caller, which is the presence oracle the pseudonymised subject exists to deny; it is also the counterpart of the per-(client, subject) byte budget the write path now enforces.

Shared rows are only listed while the shared tier is enabled. With it off, rows shared before it was disabled are treated as private to their owner, so disabling the tier stops other clients seeing them here as well as on the read path.

func (*DocumentService) MaxDocumentBytes added in v1.0.3

func (s *DocumentService) MaxDocumentBytes() int

MaxDocumentBytes exposes the per-document cap so the handler can size its own body reader from the same number the service validates against.

func (*DocumentService) Put added in v1.0.3

func (s *DocumentService) Put(
	ctx context.Context, clientID, subject, docKey string,
	raw []byte, visibility repository.ServiceDocumentVisibility,
) (*DocumentMeta, bool, error)

Put validates, canonicalises, encrypts and stores a document. It is a full replace: there is no merge, so a caller that wants to change one field reads, edits and writes the whole document.

func (*DocumentService) SharedEnabled added in v1.0.3

func (s *DocumentService) SharedEnabled() bool

SharedEnabled reports whether the shared visibility tier is available.

func (*DocumentService) SubjectPseudonym added in v1.0.3

func (s *DocumentService) SubjectPseudonym(userID string) string

SubjectPseudonym computes the deterministic pseudonym for a user ID. The erasure cascade derives the same value to find every document written about an erased account, so this derivation and the one in ErasureService must stay identical — including the canonicalisation.

type EmailOverrideStore added in v0.9.0

type EmailOverrideStore struct {
	// contains filtered or unexported fields
}

EmailOverrideStore adapts the branding + template repositories to the email.OverrideStore interface consumed by the Mailer on the send path. Lookups degrade to "no override" on error (the global template renders) — a branding query must never block an auth email.

func NewEmailOverrideStore added in v0.9.0

NewEmailOverrideStore wires the per-app branding + template repositories into an email.OverrideStore.

func (*EmailOverrideStore) Branding added in v0.9.0

func (s *EmailOverrideStore) Branding(ctx context.Context, app string) (vaultemail.Branding, bool)

Branding returns the per-app branding, or ok=false when there is none.

func (*EmailOverrideStore) Template added in v0.9.0

func (s *EmailOverrideStore) Template(ctx context.Context, app, name string) (*vaultemail.CompiledOverride, bool)

Template returns the per-app override for an email type, compiled and ready to execute, or ok=false when none exists, it is disabled, or it does not pass validation.

This is where a stored row becomes an executable template, so this is where the admin write path's validation runs a second time. The admin API is not the only way a row reaches email_templates — a restored backup, a direct write by the vault_app role, or a row written before the validation existed all land in the same table — and the send path used to compile whatever it found there (ASVS V1.3.7).

type ErasureService added in v0.8.0

type ErasureService struct {
	// contains filtered or unexported fields
}

ErasureService performs GDPR account erasure with key-recoverable escrow.

On deletion it (optionally) writes an encrypted recovery record, then scrubs and soft-deletes the user row, then cascade-deletes the user's PII and hard-deletes every refresh token. That order is the safety property and not an accident; DeleteAccount explains why, and the doc that used to be here had it backwards. When a recovery public key is configured the escrow write happens FIRST and must succeed: the service fails closed rather than erase a user without a recoverable record.

func NewErasureService added in v0.8.0

NewErasureService constructs an ErasureService. recoveryPub may be nil, in which case recovery escrow is disabled and erasure still proceeds.

func (*ErasureService) DeleteAccount added in v0.8.0

func (s *ErasureService) DeleteAccount(ctx context.Context, userID, deletedBy, reason string) error

DeleteAccount erases the user identified by userID. deletedBy records who initiated it ("self" or "admin:<id>") and reason is a short machine tag (e.g. "user_request"). It returns ErrUserNotFound if the user does not exist.

func (*ErasureService) SetLoginCountries added in v1.0.3

func (s *ErasureService) SetLoginCountries(repo repository.LoginCountryRepository)

SetLoginCountries attaches the login-country store to the erasure cascade.

The countries an account has signed in from are location-revealing personal data and must go with it. migrations/028 declared ON DELETE CASCADE and concluded erasure removed them "automatically with no bespoke cascade step", which is not true of a system that tombstones the user row instead of deleting it: the referential action never fires. Without this call the store retains that data across an erasure that reports success.

func (*ErasureService) SetServiceDocs added in v1.0.3

func (s *ErasureService) SetServiceDocs(repo repository.ServiceDocumentRepository)

SetServiceDocs attaches the service-scoped document store to the erasure cascade. Documents written about a user by other services are personal data under Art. 4(1) regardless of which service authored them, so erasure must reach them; without this the store would silently retain data across an erasure that reported success.

type HIBPClient

type HIBPClient struct {
	// contains filtered or unexported fields
}

HIBPClient checks passwords against the Have I Been Pwned database using k-anonymity (only the first 5 chars of the SHA-1 hash are sent).

func NewHIBPClient

func NewHIBPClient() *HIBPClient

NewHIBPClient creates a new HIBP client.

func (*HIBPClient) IsBreached

func (h *HIBPClient) IsBreached(password string) bool

IsBreached checks if a password appears in the HIBP database. Returns true if breached, false if clean or if HIBP is unreachable (fail open). SECURITY NOTE: Fail-open is a deliberate design choice — HIBP downtime should not block user registration. The risk (allowing a breached password during outage) is accepted as lower than the risk of blocking legitimate registrations.

func (*HIBPClient) ShedCount added in v1.0.3

func (h *HIBPClient) ShedCount() uint64

ShedCount reports checks that failed open because the concurrency cap was full.

type IdentityData

type IdentityData struct {
	GivenName       string                     `json:"given_name,omitempty"`
	FamilyName      string                     `json:"family_name,omitempty"`
	Username        string                     `json:"username,omitempty"`
	Country         string                     `json:"country,omitempty"`
	State           string                     `json:"state,omitempty"`
	DateOfBirth     string                     `json:"date_of_birth,omitempty"`
	Sex             string                     `json:"sex,omitempty"`
	MarketingEmails *bool                      `json:"marketing_emails,omitempty"`
	Billing         *BillingInfo               `json:"billing,omitempty"`
	Dynamic         map[string]json.RawMessage `json:"dynamic,omitempty"`

	// MarketingConsent is the provenance of MarketingEmails. The bool alone says
	// what the user's preference is; Art. 7(1) requires the controller to be able
	// to demonstrate *that* consent was given, which needs when and how.
	MarketingConsent *ConsentRecord `json:"marketing_consent,omitempty"`
}

IdentityData represents the decrypted identity profile fields.

PII lives here (AES-GCM encrypted at rest, keyed by HMAC pseudonym). New fields are omitempty so blobs written before they existed still decode. Dynamic holds opaque app-specific data (e.g. the legacy platform forum/garage state), namespaced by app key — vault42 treats it as encrypted, validated only for size and shape, never interpreting its contents.

func (*IdentityData) ReconcileMarketingConsent added in v0.9.0

func (d *IdentityData) ReconcileMarketingConsent(submitted *bool, prior *ConsentRecord) (changed bool)

ReconcileMarketingConsent decides what consent record an incoming profile update should carry, given what is already stored. It exists because PUT /user/identity is a full replace fed by a form the client round-trips: without it, two things go wrong.

  • Laundering. GET returns the bare bool with no provenance, so a client re-submits marketing_emails=true for an imported (pre-ticked, never affirmed) opt-in. Stamping that as source=profile would turn a value the user never chose into demonstrable Art. 7 consent. So a submitted value that is unchanged from the stored one is NOT a fresh act of consent: the existing record, and its provenance, is kept exactly as it was.
  • Erasure. A client that omits marketing_emails entirely (a partial-update client, or one whose form has no checkbox) would otherwise blank the stored record — destroying a recorded withdrawal along with it.

Only a value that actually differs from what is stored is an affirmative act, and only then is it stamped with source=profile. Returns true when the caller should emit a consent audit event.

func (*IdentityData) StampMarketingConsent added in v0.9.0

func (d *IdentityData) StampMarketingConsent(granted bool, source, origin string)

StampMarketingConsent sets the marketing preference together with its provenance. Always prefer this over assigning MarketingEmails directly: a bare bool records the preference but not the consent, which is what Art. 7(1) actually requires the controller to be able to produce.

func (*IdentityData) Validate added in v0.8.0

func (d *IdentityData) Validate() error

Validate checks the identity profile against vault42's field bounds. It is permissive about absent fields (all are optional) but rejects malformed or abusive values before they are encrypted and stored.

type IdentityService

type IdentityService struct {
	// contains filtered or unexported fields
}

IdentityService manages encrypted identity profiles.

func NewIdentityService

func NewIdentityService(repo repository.IdentityRepository, masterKey, hmacSecret []byte) *IdentityService

NewIdentityService creates a new identity service.

func (*IdentityService) Delete

func (s *IdentityService) Delete(ctx context.Context, userID string) error

Delete removes a user's identity profile.

func (*IdentityService) Get

func (s *IdentityService) Get(ctx context.Context, userID string) (*IdentityData, time.Time, error)

Get retrieves and decrypts a user's identity profile.

func (*IdentityService) MarketingAllowed added in v0.9.0

func (s *IdentityService) MarketingAllowed(ctx context.Context, userID string) (bool, error)

MarketingAllowed reports whether marketing email may be sent to a user, and is the only thing a campaign sender should consult. It fails closed: no profile, no consent record, or a non-affirmative (imported/legacy) record all mean no.

It has no caller, and that is the correct state rather than a gap. vault42 sends no marketing email: there is no campaign sender in this repository, and internal/service cannot be imported by one outside it. Every message this service sends is transactional -- verification, reset, lockout, new-country -- and none of them consults a marketing preference, because consent is not what authorizes them.

What it exists for is to be the one place the Art. 7 rule is written executably. The stored preference is a bool, and a bool invites the reading that true means consent; it does not, because an imported true is a value the user was never shown (Recital 32, and Planet49 C-673/17 on pre-ticked boxes). Any sender built later that reads MarketingEmails directly will get that wrong, and it will get it wrong silently. Keeping the rule stated once, tested by the compliance suite, is what makes "read this, not the field" an instruction with something behind it.

Deleting it would not remove a control, because nothing runs it. It would remove the definition, and leave the field.

func (*IdentityService) Pseudonym

func (s *IdentityService) Pseudonym(userID string) string

Pseudonym computes the deterministic pseudonym for a user ID.

func (*IdentityService) PutProfile added in v0.9.0

func (s *IdentityService) PutProfile(ctx context.Context, userID string, incoming *IdentityData, submitted *bool) (stored *ConsentRecord, changed bool, err error)

PutProfile is the full-replace profile write behind PUT /user/identity.

The consent reconciliation has to happen inside the same compare-and-set as the write, not before a blind one. Two things go wrong otherwise:

  • The prior record is read, then a concurrent unsubscribe commits, then this write lands carrying the pre-withdrawal consent — silently reverting a withdrawal the user was told had been honored. The CAS turns that into a retry, which re-reads the withdrawal and preserves it.
  • If the prior read fails, treating that as "no prior consent" would blank a stored withdrawal and re-stamp an imported flag as affirmative. The error is returned instead: a profile save is not worth guessing about consent.

Returns the consent record as persisted and whether it actually changed, so the caller can log a consent event only when one occurred, with the value that really landed — and only after the write has succeeded.

func (*IdentityService) UpdateMarketingConsent added in v0.9.0

func (s *IdentityService) UpdateMarketingConsent(ctx context.Context, userID string, granted bool, source, origin string) error

UpdateMarketingConsent changes only the marketing consent, leaving every other field of the profile as it was.

It is a compare-and-set loop rather than a plain Get/Upsert because the profile is one encrypted blob: a writer must decrypt the whole thing, change its field and re-encrypt, so two concurrent writers would each persist their own stale view and one change would vanish. Losing a withdrawal that way is not an acceptable outcome — the user would be told they had unsubscribed while the stored record still said otherwise.

func (*IdentityService) Upsert

func (s *IdentityService) Upsert(ctx context.Context, userID string, data *IdentityData) error

Upsert encrypts and stores a user's identity profile.

type LoginInput

type LoginInput struct {
	Email       string `json:"email"`
	Password    string `json:"password"` // #nosec G117 -- password field in request DTO, not stored
	RememberMe  bool   `json:"remember_me"`
	ClientID    string `json:"client_id"`
	Fingerprint vaultcrypto.FingerprintInput
	// DiscloseStatus permits the one refusal that is allowed to say why:
	// ErrPasswordResetRequired instead of ErrInvalidCredentials on an account
	// carrying must_reset_password. Nothing else about the login changes with
	// it, and no other outcome consults it.
	//
	// It is json:"-" and set by the transport after it has verified client
	// credentials against auth.clients and found the login:status scope on that
	// row. ClientID above is self-asserted body text and proves nothing, so it
	// must never be what decides this. The login handler rejects unknown JSON
	// fields, so a body naming this one is a 400 rather than a way in.
	DiscloseStatus bool `json:"-"`
}

LoginInput is the login request payload.

type LoginResult

type LoginResult struct {
	AccessToken      string   `json:"access_token"` // #nosec G117 -- OAuth2 response field name per RFC 6749
	TokenType        string   `json:"token_type"`
	ExpiresIn        int      `json:"expires_in"`
	RefreshToken     string   `json:"-"` // set via cookie, not in body
	CookieMaxAge     int      `json:"-"` // refresh cookie maxage in seconds
	Requires2FA      bool     `json:"requires_2fa,omitempty"`
	ChallengeToken   string   `json:"challenge_token,omitempty"`
	AvailableMethods []string `json:"available_methods,omitempty"`
	// ImportClaimRequired is never set. It signaled an unclaimed imported account
	// on its first login, which made that account distinguishable from every other
	// login failure to an unauthenticated caller; Login now answers
	// ErrInvalidCredentials there and mails the claim link out of band. The field
	// is retained so the transport layer keeps compiling until its 202 branch is
	// removed, and `omitempty` keeps it off the wire.
	ImportClaimRequired bool `json:"import_claim_required,omitempty"`
}

LoginResult is the login response.

func (LoginResult) MarshalJSON added in v1.0.3

func (r LoginResult) MarshalJSON() ([]byte, error)

MarshalJSON emits both spellings of the MFA challenge.

Both stay omitempty and therefore appear and disappear together. The ordinary single-step login is the overwhelming majority of responses, and adding mfa_required:false plus an empty mfa_methods to every one of them would change the shape of the common case for a client that tests for the key rather than its value.

RefreshToken and CookieMaxAge are absent by construction rather than by tag: the refresh credential travels in a Set-Cookie header, and a wire type that simply has no field for it cannot leak it into a response body.

type MFACompletion added in v1.0.3

type MFACompletion struct {
	// Method is the authenticator just verified.
	Method string
	// UserVerified is the WebAuthn user-verification flag from the assertion.
	// It is false for every other authenticator, and it is what separates AAL3
	// from AAL2 on the WebAuthn path.
	UserVerified bool
	// Prior lists the factors completed before the challenge was minted, read
	// off the challenge token. Empty derives AAL1 rather than assuming a
	// password: a federated login reaches this path too.
	Prior []string
}

MFACompletion describes the second factor that finished an MFA login and the factors that preceded it, so the issued token can state the combination rather than assume one.

type MFAService

type MFAService struct {
	// contains filtered or unexported fields
}

MFAService handles MFA policy decisions.

func NewMFAService

NewMFAService creates a new MFA service.

func (*MFAService) GetStatus

func (s *MFAService) GetStatus(ctx context.Context, userID string) (*MFAStatus, error)

GetStatus returns the MFA status for a user. Returns an error if the primary MFA methods (TOTP, WebAuthn) cannot be determined, to ensure callers fail closed rather than silently skipping MFA.

func (*MFAService) IsRequired

func (s *MFAService) IsRequired() bool

IsRequired returns whether MFA is required by server configuration.

func (*MFAService) RequiresMFA

func (s *MFAService) RequiresMFA(ctx context.Context, userID string, trustedDevice bool) (bool, error)

RequiresMFA determines if a user needs to complete 2FA.

type MFAStatus

type MFAStatus struct {
	TOTPEnabled     bool     `json:"totp_enabled"`
	WebAuthnEnabled bool     `json:"webauthn_enabled"`
	BackupCodes     int      `json:"backup_codes_remaining"`
	Methods         []string `json:"mfa_methods"`
	Required        bool     `json:"mfa_required"`
}

MFAStatus describes the MFA state for a user. Its wire shape is defined by mfaStatusWire below, which MarshalJSON produces; the tags here describe the same fields for readers.

func (MFAStatus) MarshalJSON added in v1.0.3

func (s MFAStatus) MarshalJSON() ([]byte, error)

MarshalJSON emits both method keys and guarantees the list is a JSON array. A user with no factor configured has a nil Methods slice, and encoding that directly yields null, which every strongly-typed client has to special-case. Doing this here rather than at the call site means the invariant holds for every MFAStatus, not only the ones GetStatus builds.

type MintConfig added in v1.0.3

type MintConfig struct {
	// Issuer is vault42's own issuer, so downstream verifiers can pin it.
	Issuer string
	// Audience is the resource audience minted tokens are addressed to. It must
	// differ from Issuer: a minted token that carried vault42's own audience
	// would pass vault42's own audience validation.
	Audience string
	// DefaultTTL is the lifetime used when a caller requests none.
	DefaultTTL time.Duration
	// MaxTTL is the operator's ceiling, itself capped by mintTTLCeiling.
	MaxTTL time.Duration
	// AllowedRoles is the exhaustive set of roles that may be minted. Empty
	// means no role may be minted.
	AllowedRoles []string
	// AllowedScopes is the exhaustive set of scopes that may be minted. Empty
	// means no scope may be minted.
	AllowedScopes []string
}

MintConfig holds the mint policy. Every field is a deny-by-default control; the zero value mints nothing.

type MintMetrics added in v1.0.3

type MintMetrics interface {
	RecordMintIssued()
	RecordMintRejected()
}

MintMetrics is the subset of the metrics collector this service records to.

type MintRequest added in v1.0.3

type MintRequest struct {
	// Subject is the caller-asserted subject. vault42 does not verify it and
	// cannot: it is the calling platform's own user id.
	Subject string
	// Roles and Scopes are optional and must be subsets of the configured
	// allow-lists.
	Roles  []string
	Scopes []string
	// TTL is optional; zero means MintConfig.DefaultTTL.
	TTL time.Duration
	// MintedBy is the authenticated client asking for the assertion. It reaches
	// the token as the minted_by claim so a relying party can attribute the
	// assertion without vault42's audit log, which it cannot read. The caller
	// supplies it from its own authenticated context, never from the request
	// body: an attribution a mint client could choose would name whichever
	// tenant it wanted to blame.
	MintedBy string
}

MintRequest is one validated mint call.

type MintResult added in v1.0.3

type MintResult struct {
	Token     string
	Subject   string
	Roles     []string
	Scopes    []string
	Audience  string
	Issuer    string
	JTI       string
	KID       string
	ExpiresAt time.Time
	ExpiresIn int
}

MintResult is a signed subject assertion.

type MintService added in v1.0.3

type MintService struct {
	// contains filtered or unexported fields
}

MintService issues subject-assertion tokens on behalf of a trusted service.

func NewMintService added in v1.0.3

func NewMintService(signer SigningKeyProvider, cfg MintConfig, metrics MintMetrics) (*MintService, error)

NewMintService validates the mint policy and returns a service, or an error that must abort startup.

Configuration is validated here rather than per request so a deployment that would mint dangerous tokens fails to start instead of failing safe once and unsafely later.

func (*MintService) Audience added in v1.0.3

func (s *MintService) Audience() string

Audience returns the configured resource audience.

func (*MintService) Mint added in v1.0.3

func (s *MintService) Mint(req MintRequest) (*MintResult, error)

Mint validates a request against the policy and signs the assertion.

type RecoveryRetention added in v0.9.9

type RecoveryRetention struct {
	// contains filtered or unexported fields
}

RecoveryRetention purges account-recovery escrow records past their retention horizon.

Every erasure with a recovery key configured appends a record holding the subject's real email, creation date, roles and display name, encrypted to the offline operator key. Like the audit log, the escrow is deliberately exempt from the erasure cascade — the whole point is that it outlives the account — and like the audit log it therefore has to be bounded by time instead, under Art. 5(1)(e). It shipped bounded by nothing: append-only triggers, no expiry column, UPDATE and DELETE revoked from both application roles, and no code path that removed a row.

The horizon is the Operator's call. It has to be long enough to cover the window in which an accidental or malicious deletion would still be noticed and reversed, and no longer.

func NewRecoveryRetention added in v0.9.9

func NewRecoveryRetention(pruner repository.AccountRecoveryPruner, period time.Duration) *RecoveryRetention

NewRecoveryRetention builds a sweeper. A period of zero disables it, which is the default: the escrow holds the only recoverable copy of an erased account, so destroying it must be an explicit operator choice, exactly as for the audit sweeper.

func (*RecoveryRetention) Done added in v0.9.9

func (r *RecoveryRetention) Done() <-chan struct{}

Done is closed once the sweep loop has exited, whether it ended via Stop or via its context being canceled. A caller that closes the database pool on its return can otherwise race a sweep that is mid-DELETE. The channel never closes if Start was not called.

func (*RecoveryRetention) Enabled added in v0.9.9

func (r *RecoveryRetention) Enabled() bool

Enabled reports whether a retention horizon is configured.

func (*RecoveryRetention) Start added in v0.9.9

func (r *RecoveryRetention) Start(ctx context.Context)

Start runs the sweeper until Stop is called. It sweeps once immediately: a process that restarts more often than the interval would otherwise never reach a tick and the purge would never happen.

Calling it more than once starts nothing further. Two loops would share one doneCh, and the second one to exit would close an already-closed channel: an unrecoverable panic raised from a deferred call in a background goroutine, which no handler can catch and which takes the process with it.

func (*RecoveryRetention) Stop added in v0.9.9

func (r *RecoveryRetention) Stop()

Stop terminates the sweep loop and blocks until it has actually exited, so the sweeper cannot outlive the database pool its caller closes on return. Safe to call more than once, and safe on a sweeper that was never started.

func (*RecoveryRetention) Sweep added in v0.9.9

func (r *RecoveryRetention) Sweep(ctx context.Context) (int64, error)

Sweep deletes every escrow record older than the retention horizon and returns how many rows went.

Serialized across replicas: the underlying prune takes an ACCESS EXCLUSIVE lock on the escrow table (it disables the append-only trigger to delete), so only one replica may sweep at a time. A replica that does not get the lock returns what it has and tries again next tick — the work is idempotent, so there is nothing to catch up on.

It loops, because one call deletes at most repository.RecoveryCleanupBatch rows. Holding that exclusive lock over an unbounded DELETE stalled every erasure for the length of the whole purge: an Art. 17 deletion with a recovery key configured appends its escrow record on the request path, and that append waits behind the ALTER TABLE the purge does to turn the append-only trigger off.

type RefreshResult

type RefreshResult struct {
	AccessToken  string `json:"access_token"` // #nosec G117 -- OAuth2 response field name per RFC 6749
	TokenType    string `json:"token_type"`
	ExpiresIn    int    `json:"expires_in"`
	RefreshToken string `json:"-"` // new refresh token (cookie)
	CookieMaxAge int    `json:"-"` // refresh cookie maxage in seconds
}

RefreshResult is the token refresh response.

type RefreshTokenRetention added in v1.0.3

type RefreshTokenRetention struct {
	// contains filtered or unexported fields
}

RefreshTokenRetention reaps dead refresh-token rows on a timer.

RefreshTokenRepository.DeleteExpired existed and worked, and nothing on the server path ever called it: the only production caller was `vault cleanup`, a CLI subcommand an operator has to remember to run. cmd/vault started the audit, recovery, keystore and admin-session sweepers and not this one, so auth.refresh_tokens was the one table with a reaper and no schedule — which is also why the unbatched DELETE it used to run mattered.

func NewRefreshTokenRetention added in v1.0.3

func NewRefreshTokenRetention(repo repository.RefreshTokenRepository) *RefreshTokenRetention

NewRefreshTokenRetention builds a sweeper over the refresh-token repository.

func (*RefreshTokenRetention) Done added in v1.0.3

func (r *RefreshTokenRetention) Done() <-chan struct{}

Done is closed once the sweep loop has exited, whether via Stop or a canceled context, so a caller that closes the database pool on return does not race a sweep mid-DELETE. It never closes if Start was not called.

func (*RefreshTokenRetention) Start added in v1.0.3

func (r *RefreshTokenRetention) Start(ctx context.Context)

Start runs the sweeper until Stop is called. It sweeps once immediately so a process that restarts more often than the interval still reaps. Calling it more than once starts nothing further: two loops would share one doneCh, and the second to exit would close an already-closed channel.

func (*RefreshTokenRetention) Stop added in v1.0.3

func (r *RefreshTokenRetention) Stop()

Stop terminates the sweep loop and blocks until it has exited, so the sweeper cannot outlive the pool its caller closes on return. Safe to call more than once and safe on a sweeper that was never started.

func (*RefreshTokenRetention) Sweep added in v1.0.3

func (r *RefreshTokenRetention) Sweep(ctx context.Context) (int64, error)

Sweep deletes spent and expired refresh tokens and returns how many rows went.

type RegisterInput

type RegisterInput struct {
	Email       string `json:"email"`
	Password    string `json:"password"` // #nosec G117 -- password field in request DTO, not stored
	DisplayName string `json:"display_name"`
	Locale      string `json:"locale"`
	RedirectTo  string `json:"redirect_to"` // relative path to redirect after email verification
}

RegisterInput is the registration request payload.

type RegisterResult

type RegisterResult struct {
	UserID string `json:"user_id"`
	Email  string `json:"email"`
}

RegisterResult is the registration response.

type RoleCatalog added in v0.8.0

type RoleCatalog struct {
	// contains filtered or unexported fields
}

RoleCatalog caches the set of valid user role names from the auth.app_roles catalog and filters user role lists down to catalog members. It refreshes lazily on a TTL and fails open: if the catalog has never loaded and a refresh errors, Filter returns its input unchanged (the admin-reserved filter applied upstream still prevents privilege escalation).

func NewRoleCatalog added in v0.8.0

func NewRoleCatalog(repo repository.AppRoleRepository, ttl time.Duration) *RoleCatalog

NewRoleCatalog creates a catalog cache backed by repo, refreshing every ttl (default 60s when ttl <= 0).

func (*RoleCatalog) Filter added in v0.8.0

func (c *RoleCatalog) Filter(ctx context.Context, roles []string) []string

Filter keeps only roles present in the catalog, preserving order. Fails open (returns roles unchanged) when the catalog is unavailable.

func (*RoleCatalog) Valid added in v0.8.0

func (c *RoleCatalog) Valid(ctx context.Context, name string) bool

Valid reports whether name is a catalog role (refreshing the cache if stale).

type SigningKeyProvider added in v1.0.3

type SigningKeyProvider func() (*rsa.PrivateKey, string)

SigningKeyProvider returns the currently active signing key and its kid. It matches keystore.ActiveKey so a rotating deployment picks up new keys without the mint service holding a stale one.

type SubjectWriteSerializer added in v1.0.3

type SubjectWriteSerializer interface {
	WithSubjectWriteLock(ctx context.Context, subjectHash string, fn func(context.Context) error) error
}

SubjectWriteSerializer is the capability a ServiceDocumentRepository advertises when it can serialize every writer for one subject across every process that talks to the same store.

It is an optional interface, discovered with a type assertion, rather than a method on ServiceDocumentRepository. The quota policy lives in this file and nowhere else: what the repository is asked for is mutual exclusion, not a second copy of the rules. Making it optional also means a store that cannot offer cross-process exclusion (an in-memory one, a future backend without advisory locks) stays usable and simply falls back to the in-process lock, instead of every implementation being forced to grow a method it would have to fake.

fn must run exactly once, synchronously, and must receive a context that carries whatever transaction the lock was taken in, so that the reads fn makes and the write it authorizes are the same unit of work as the lock. An implementation that ran fn outside the locked transaction would satisfy the signature and none of the point.

type TokenPair

type TokenPair struct {
	AccessToken  string // #nosec G117 -- internal DTO, never serialized to JSON
	RefreshToken string // #nosec G117 -- raw token (to be hashed before storage)
	ExpiresAt    time.Time
	RefreshExpAt time.Time
	FamilyID     string
}

TokenPair is an access+refresh token pair.

type TokenService

type TokenService struct {
	// contains filtered or unexported fields
}

TokenService handles JWT issuance and refresh token generation.

func NewTokenService

func NewTokenService(key *rsa.PrivateKey, kid, issuer, audience string, accessTTL, refreshTTL, rememberTTL time.Duration) *TokenService

NewTokenService creates a new token service.

func (*TokenService) AccessTokenTTL

func (s *TokenService) AccessTokenTTL() time.Duration

AccessTokenTTL returns the configured access token TTL.

func (*TokenService) InactivityTimeout added in v1.0.3

func (s *TokenService) InactivityTimeout() time.Duration

InactivityTimeout returns the configured inactivity bound, or zero when no bound is set.

func (*TokenService) IssueChallengeToken

func (s *TokenService) IssueChallengeToken(ctx context.Context, userID, fingerprint string, factors ...string) (string, error)

IssueChallengeToken creates a short-lived JWT for 2FA challenge.

It is bound to a presented DPoP key for the same reason the access token is: the challenge token is what POST /auth/2fa/verify authenticates, so leaving it bearer-equivalent would put an unconstrained credential in the middle of an otherwise sender-constrained login.

factors are the authenticator methods already completed when the challenge was minted; they travel on the challenge so the second-factor verify can state the full combination.

func (*TokenService) IssueRotatedPair added in v1.0.3

func (s *TokenService) IssueRotatedPair(ctx context.Context, userID string, roles, scopes []string, clientID, fingerprint, familyID string, familyOrigin time.Time) (*TokenPair, error)

IssueRotatedPair issues the next pair in an existing family and clamps its refresh expiry to the family's absolute deadline, and to the inactivity window if one is configured.

SECURITY INVARIANT: the returned refresh token can never outlive familyOrigin+maxSessionLifetime. The caller also rejects an already-expired family outright; this clamp is what makes the final rotation before the deadline honest, so the stored expires_at (checked on the next refresh) and the cookie max-age both end at the bound instead of a fresh full TTL past it.

A zero familyOrigin means the age is unknown and no clamp is applied — callers must reject rather than rotate in that case.

ctx sender-constrains the rotated access token exactly as it does a fresh one: a refresh presented with a DPoP proof yields a token bound to that same key.

func (*TokenService) IssueTokenPair

func (s *TokenService) IssueTokenPair(ctx context.Context, userID string, roles, scopes []string, clientID, fingerprint, familyID string, rememberMe bool) (*TokenPair, error)

IssueTokenPair creates a new access+refresh token pair.

An empty familyID starts a new family, so its origin is now and the refresh expiry is clamped to the absolute session lifetime here. A non-empty familyID is a rotation whose origin this function cannot know; rotations must go through IssueRotatedPair, which supplies it.

ctx is the REQUEST context, and it is read for exactly one thing: the thumbprint of a DPoP proof the middleware already validated. When one is present the access token carries cnf.jkt (RFC 9449 §6.1) and is sender-constrained; when it is absent the token is an ordinary bearer token, which every non-DPoP client depends on. This is the binding that did not exist: with cnf.jkt assigned nowhere, the middleware's thumbprint comparison never ran and a proof was checked against nothing.

func (*TokenService) IssueTokenPairWithAuth added in v1.0.3

func (s *TokenService) IssueTokenPairWithAuth(ctx context.Context, userID string, roles, scopes []string, clientID, fingerprint, familyID string, rememberMe bool, auth AuthContext) (*TokenPair, error)

IssueTokenPairWithAuth is IssueTokenPair with the OIDC authentication-event claims attached. Call it from a path that just authenticated a user; the bare IssueTokenPair is for issuance that observed no such event.

The two bindings are independent and both ride on this call: ctx sender- constrains the token to a DPoP key, auth describes the authentication event that produced it.

func (*TokenService) MaxSessionLifetime added in v1.0.3

func (s *TokenService) MaxSessionLifetime() time.Duration

MaxSessionLifetime returns the configured absolute session lifetime, or zero when no bound is set.

func (*TokenService) SetInactivityTimeout added in v1.0.3

func (s *TokenService) SetInactivityTimeout(d time.Duration)

SetInactivityTimeout sets the bound on how long a refresh-token family may go unused. Issuance clamps every refresh expiry to now+d, so a family that is not rotated inside the window expires on its own rather than waiting for the full refresh TTL (NIST SP 800-63B-4 §2.2.3). Zero disables the bound.

This is the clamp only. AuthService.enforceSessionInactivity is what TERMINATES the family, and it is the control: the clamp cannot revoke, cannot audit, and cannot act on a row whose expiry was written before the bound was configured. Setting this without the check would leave every pre-existing session unbounded.

Call once at wiring time, before the service starts issuing.

func (*TokenService) SetMaxSessionLifetime added in v1.0.3

func (s *TokenService) SetMaxSessionLifetime(d time.Duration)

SetMaxSessionLifetime sets the absolute bound on the age of a refresh-token family. Rotation refuses to extend a family past creation+d, and issuance clamps the refresh expiry to it, so the bound holds regardless of how often the client refreshes (NIST SP 800-63B-4 §2.2.3). Zero disables the bound.

Call once at wiring time, before the service starts issuing.

func (*TokenService) UpdateSigningKey

func (s *TokenService) UpdateSigningKey(key *rsa.PrivateKey, kid string)

UpdateSigningKey updates the signing key and kid for key rotation, and clears the exported private components of the key it replaces.

The wipe is far narrower than it looks; zeroPrivateKey documents why the retired key stays usable afterwards. It runs anyway, because clearing the fields that are reachable beats leaving them set, and because it becomes a real control again the day the standard library stops caching the key internally.

Signers hold the read lock for the whole of SignToken. That is deliberate ordering hygiene rather than a consequence of the wipe: it stops a rotation publishing a new kid while a signature is still being produced under the old one, which is what would otherwise let a token carry a kid its signature does not match. It is not load-bearing for memory safety, because signing does not read the words the wipe overwrites.

Jump to

Keyboard shortcuts

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