Release v0.1.0-preview.28: transactional account email
verify / verify (push) Successful in 4m29s

Reviewed source export adds verified TLS mail, encrypted outbox, mailbox verification and password reset protocols. Preserve public ancestry; omit local development history and operational queue. Consumer deployment and inbox delivery proof remain separate.
This commit is contained in:
2026-09-11 04:12:17 -04:00
parent ebcbbf06f5
commit 494b72fa3b
18 changed files with 3175 additions and 47 deletions
+455
View File
@@ -0,0 +1,455 @@
// 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
}