// SPDX-License-Identifier: MPL-2.0 // Package authmail implements mailbox verification and password-reset protocols. // Applications own HTTP CSRF/origin checks, IP/concurrency limits and templates. // A confirmation must be a deliberate POST, never a mail-scanner-triggered GET. package authmail import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "errors" "fmt" "io" stdmail "net/mail" "net/url" "path" "strings" "time" "unicode" "gamertan.com/web/auth" "gamertan.com/web/mail" ) var ( ErrInvalid = errors.New("authmail: invalid request") ErrUnavailable = errors.New("authmail: link or account unavailable") ErrLimited = errors.New("authmail: please wait before requesting another message") ErrAddressUnavailable = errors.New("authmail: address cannot be used") ) type Purpose string const ( Verify Purpose = "verify" Change Purpose = "change" Reset Purpose = "reset" Lifetime = 15 * time.Minute ) // Subject is a repository/service boundary, not a public response or log value. // PasswordHash is needed for reauthentication and credential-race checks. type Subject struct { UserID, Email, PasswordHash string Revision int64 Verified bool } func (Subject) String() string { return "authmail.Subject{identity:redacted}" } func (subject Subject) GoString() string { return subject.String() } type Request struct { ID, UserID, Email, NewEmail string Purpose Purpose Revision int64 CredentialDigest, SessionDigest, NewDigest, OldDigest [32]byte CreatedAt, ExpiresAt time.Time } func (Request) String() string { return "authmail.Request{identity:redacted}" } func (request Request) GoString() string { return request.String() } type Pending struct { Request Request Subject Subject OldToken bool } func (Pending) String() string { return "authmail.Pending{identity:redacted}" } func (pending Pending) GoString() string { return pending.String() } // Repository must atomically recheck current account/session/credential identity, // throttling, token expiry and replay; mail intent and audit join each mutation. // Implementations must never grant authentication or remove MFA credentials. type Repository interface { OwnSubject(context.Context, [32]byte, time.Time) (Subject, error) ResetSubject(context.Context, string, time.Time) (Subject, error) Issue(context.Context, Request, []mail.Message, auth.AuditEvent) error Pending(context.Context, [32]byte, time.Time) (Pending, error) Complete(context.Context, [32]byte, string, string, []mail.Message, auth.AuditEvent) (bool, error) } // Config contains trusted deployment values, never a request Host header. The // two route paths must render deliberate confirmation forms. Plain-text copy is // centralized here; applications can provide a reviewed Composer to customize it. type Config struct { Origin, SiteName, ConfirmPath, ResetPath, SecurityPath string // TokenInFragment keeps link tokens out of HTTP/proxy request targets. The // application must transfer it locally into the deliberate confirmation POST. TokenInFragment bool Now func() time.Time Random io.Reader Compose Composer } type MessageContent struct{ Subject, Text string } func (MessageContent) String() string { return "authmail.MessageContent{content:redacted}" } func (content MessageContent) GoString() string { return content.String() } type MailIntent struct { Kind string SiteName, ActionURL, SecurityURL string ExpiresAt time.Time } func (MailIntent) String() string { return "authmail.MailIntent{links:redacted}" } func (intent MailIntent) GoString() string { return intent.String() } // Composer is trusted application code, not editable template code or arbitrary // HTML. It does not choose recipients, sender, identifiers or security state. type Composer func(MailIntent) (MessageContent, error) type Service struct { repository Repository origin *url.URL config Config } func New(repository Repository, config Config) (*Service, error) { if repository == nil || !plainHeader(config.SiteName, 100) { return nil, ErrInvalid } origin, err := url.Parse(config.Origin) if err != nil || origin.Scheme != "https" || origin.Host == "" || origin.User != nil || origin.RawQuery != "" || origin.Fragment != "" || (origin.Path != "" && origin.Path != "/") || origin.Opaque != "" { return nil, ErrInvalid } for _, route := range []string{config.ConfirmPath, config.ResetPath, config.SecurityPath} { if !strings.HasPrefix(route, "/") || strings.HasPrefix(route, "//") || strings.ContainsAny(route, "?#\\%") || !plainHeader(route, 256) || strings.TrimSuffix(route, "/") != path.Clean(route) { return nil, ErrInvalid } } if config.Now == nil { config.Now = time.Now } if config.Random == nil { config.Random = rand.Reader } if config.Compose == nil { config.Compose = DefaultComposer } return &Service{repository: repository, origin: origin, config: config}, nil } func plainHeader(value string, limit int) bool { if strings.TrimSpace(value) == "" || len(value) > limit { return false } for _, r := range value { if unicode.IsControl(r) { return false } } return true } // NormalizeEmail preserves this identity store's case-insensitive mailbox rule. // SMTPUTF8, display-name syntax and mailbox-provider alias rewriting are absent. func NormalizeEmail(value string) (string, error) { value = strings.ToLower(strings.TrimSpace(value)) if !plainHeader(value, 254) { return "", ErrInvalid } for _, r := range value { if r > 127 { return "", ErrInvalid } } address, err := stdmail.ParseAddress(value) if err != nil || address.Name != "" || address.Address != value || !strings.Contains(value, "@") { return "", ErrInvalid } return value, nil } func TokenDigest(token string) ([32]byte, error) { raw, err := base64.RawURLEncoding.DecodeString(token) if err != nil || len(raw) != 32 || base64.RawURLEncoding.EncodeToString(raw) != token { return [32]byte{}, ErrUnavailable } return sha256.Sum256([]byte("gwf.authmail.v1:" + token)), nil } func (service *Service) random(size int) (string, error) { raw := make([]byte, size) if _, err := io.ReadFull(service.config.Random, raw); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(raw), nil } func (service *Service) link(route, token string) string { link := *service.origin link.Path = route link.RawQuery = "" if token != "" { if service.config.TokenInFragment { link.Fragment = url.Values{"token": {token}}.Encode() } else { link.RawQuery = url.Values{"token": {token}}.Encode() } } return link.String() } func (service *Service) message(kind, recipient, token string, now time.Time) (mail.Message, error) { route := service.config.ConfirmPath if kind == "reset" { route = service.config.ResetPath } intent := MailIntent{Kind: kind, SiteName: service.config.SiteName, SecurityURL: service.link(service.config.SecurityPath, ""), ExpiresAt: now.Add(Lifetime)} if token != "" { intent.ActionURL = service.link(route, token) } content, err := service.config.Compose(intent) if err != nil { return mail.Message{}, ErrInvalid } id, err := service.random(18) if err != nil { return mail.Message{}, err } message := mail.Message{ID: "mail_" + id, To: recipient, Subject: content.Subject, Text: content.Text, CreatedAt: now} if message.Validate() != nil { return mail.Message{}, ErrInvalid } return message, nil } func (service *Service) audit(action, userID string, now time.Time) (auth.AuditEvent, error) { id, err := service.random(18) if err != nil { return auth.AuditEvent{}, err } actor := userID if strings.HasPrefix(action, "reset") { actor = "" } return auth.AuditEvent{ID: "mailaudit_" + id, ActorUserID: actor, Action: "auth.mail." + action, ResourceType: "user", ResourceID: userID, Summary: "Account mail operation", CreatedAt: now}, nil } // Status requires a current unrestricted session; it is not an address lookup. func (service *Service) Status(ctx context.Context, session [32]byte) (bool, error) { subject, err := service.repository.OwnSubject(ctx, session, service.config.Now().UTC()) return subject.Verified, err } func (service *Service) RequestVerification(ctx context.Context, session [32]byte) error { now := service.config.Now().UTC().Truncate(time.Second) subject, err := service.repository.OwnSubject(ctx, session, now) if err != nil { return err } if subject.Verified { return nil } return service.issue(ctx, subject, session, Verify, "", now) } // RequestChange requires the current password and confirmations at BOTH old and // new mailboxes. It does not change the canonical address immediately. Passkey- // only reauthentication would be a separate, operation-bound protocol, not a bool. func (service *Service) RequestChange(ctx context.Context, session [32]byte, currentPassword, newEmail string) error { if len(currentPassword) > 1024 { return ErrInvalid } now := service.config.Now().UTC().Truncate(time.Second) subject, err := service.repository.OwnSubject(ctx, session, now) if err != nil { return err } if !auth.VerifyPassword(subject.PasswordHash, currentPassword) { return auth.ErrInvalidCredentials } newEmail, err = NormalizeEmail(newEmail) if err != nil { return err } current, err := NormalizeEmail(subject.Email) if err != nil || newEmail == current { return ErrInvalid } return service.issue(ctx, subject, session, Change, newEmail, now) } // RequestReset has the same result for unknown, unverified, inactive, malformed // and account-rate-limited addresses. Applications must likewise keep responses // generic, rate-limit by IP and avoid response timing tied to actual SMTP work. func (service *Service) RequestReset(ctx context.Context, email string) error { email, err := NormalizeEmail(email) if err != nil { return nil } now := service.config.Now().UTC().Truncate(time.Second) subject, err := service.repository.ResetSubject(ctx, email, now) if errors.Is(err, ErrUnavailable) { return nil } if err != nil { return err } err = service.issue(ctx, subject, [32]byte{}, Reset, "", now) if errors.Is(err, ErrLimited) || errors.Is(err, ErrUnavailable) { return nil } return err } func (service *Service) issue(ctx context.Context, subject Subject, session [32]byte, purpose Purpose, newEmail string, now time.Time) error { email, err := NormalizeEmail(subject.Email) if err != nil { return ErrUnavailable } id, err := service.random(18) if err != nil { return err } token, err := service.random(32) if err != nil { return err } digest, _ := TokenDigest(token) request := Request{ID: "request_" + id, UserID: subject.UserID, Email: email, NewEmail: newEmail, Purpose: purpose, Revision: subject.Revision, CredentialDigest: sha256.Sum256([]byte(subject.PasswordHash)), SessionDigest: session, NewDigest: digest, CreatedAt: now, ExpiresAt: now.Add(Lifetime)} kind, target := string(purpose), email if purpose == Change { kind, target = "change-new", newEmail } message, err := service.message(kind, target, token, now) if err != nil { return err } messages := []mail.Message{message} if purpose == Change { oldToken, err := service.random(32) if err != nil { return err } request.OldDigest, _ = TokenDigest(oldToken) oldMessage, err := service.message("change-old", email, oldToken, now) if err != nil { return err } messages = append(messages, oldMessage) } audit, err := service.audit(string(purpose)+".request", subject.UserID, now) if err != nil { return err } return service.repository.Issue(ctx, request, messages, audit) } // Inspect is read-only and intentionally returns no account or address details. func (service *Service) Inspect(ctx context.Context, token string) (Purpose, error) { digest, err := TokenDigest(token) if err != nil { return "", err } pending, err := service.repository.Pending(ctx, digest, service.config.Now().UTC()) if err != nil { return "", err } return pending.Request.Purpose, nil } // Confirm consumes a verification/change token. false means the other mailbox // still needs confirmation. It never issues a login or changes a password. func (service *Service) Confirm(ctx context.Context, token string) (bool, error) { return service.complete(ctx, token, "", false) } // ResetPassword requires a reset token and retains passkeys/recovery codes. The // application must send the user through its normal sign-in and MFA afterwards. func (service *Service) ResetPassword(ctx context.Context, token, password string) error { if err := auth.ValidatePassword(password); err != nil { return err } _, err := service.complete(ctx, token, password, true) return err } func (service *Service) complete(ctx context.Context, token, password string, reset bool) (bool, error) { digest, err := TokenDigest(token) if err != nil { return false, err } now := service.config.Now().UTC().Truncate(time.Second) pending, err := service.repository.Pending(ctx, digest, now) if err != nil { return false, err } if (pending.Request.Purpose == Reset) != reset { return false, ErrUnavailable } var hash string var notices []mail.Message if reset { if auth.VerifyPassword(pending.Subject.PasswordHash, password) { return false, auth.ErrPasswordUnchanged } hash, err = auth.HashPasswordWithRandom(password, service.config.Random) if err != nil { return false, err } notice, err := service.message("password-changed", pending.Request.Email, "", now) if err != nil { return false, err } notices = append(notices, notice) } else if pending.Request.Purpose == Change { for _, recipient := range []string{pending.Request.Email, pending.Request.NewEmail} { notice, err := service.message("email-changed", recipient, "", now) if err != nil { return false, err } notices = append(notices, notice) } } audit, err := service.audit(string(pending.Request.Purpose)+".confirm", pending.Subject.UserID, now) if err != nil { return false, err } return service.repository.Complete(ctx, digest, pending.Request.ID, hash, notices, audit) } func DefaultComposer(intent MailIntent) (MessageContent, error) { var title, text string switch intent.Kind { case "verify": title, text = "Confirm your email", "Confirm that this is the email address you'd like to use for your account." case "reset": title, text = "Reset your password", "Someone requested a password reset for your account. If that was you, choose a new password using the link below." case "change-new": title, text = "Confirm your new email", "Confirm this address to continue your account email change. Your current mailbox also needs to approve the change." case "change-old": title, text = "Approve your email change", "Someone who confirmed your current password requested an account email change. Approve it only if you made this request; the new mailbox must confirm too." case "password-changed": title, text = "Your password was changed", "Your account password was reset. Existing sessions were signed out. Your passkeys and recovery codes were not removed." case "email-changed": title, text = "Your account email was changed", "Both mailboxes confirmed your account email change. Existing sessions were signed out. Your purchases and memberships still belong to the same account." default: return MessageContent{}, ErrInvalid } if intent.ActionURL != "" { text += fmt.Sprintf("\n\n%s\n\nThis link expires at %s. Opening it alone does not change your account; the page asks you to confirm.", intent.ActionURL, intent.ExpiresAt.UTC().Format(time.RFC1123)) } if intent.ActionURL != "" { text += "\n\nIf you did not request this, do not confirm it." } else { text += "\n\nIf this was not you, please contact the site's support team promptly." } text += "\nVisit your account security page or contact support:\n" + intent.SecurityURL + "\n\n" + intent.SiteName + "\n" return MessageContent{Subject: title + " — " + intent.SiteName, Text: text}, nil }