diff --git a/server/config.go b/server/config.go index b7dadddf..de94b4ca 100644 --- a/server/config.go +++ b/server/config.go @@ -71,7 +71,7 @@ const ( DefaultVisitorEmailLimitReplenish = time.Hour DefaultVisitorTopicCreationLimitBurst = 100 DefaultVisitorTopicCreationLimitReplenish = time.Minute - DefaultVisitorAccountCreationLimitBurst = 3 + DefaultVisitorAccountCreationLimitBurst = 6 // Shared by signup and password-reset requests (same per-visitor bucket) DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour DefaultVisitorAuthFailureLimitBurst = 30 DefaultVisitorAuthFailureLimitReplenish = time.Minute diff --git a/user/magic_link_test.go b/user/magic_link_test.go new file mode 100644 index 00000000..4be1fe55 --- /dev/null +++ b/user/magic_link_test.go @@ -0,0 +1,269 @@ +package user + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// addVerifyLink generates a raw token, stores an email-verification magic link for it, and +// returns the raw token so the test can "click" it via VerifyEmail. +func addVerifyLink(t *testing.T, a *Manager, userID, email string, expires int64) string { + raw := generateLinkToken() + require.Nil(t, a.AddMagicLink(&MagicLink{ + TokenHash: hashToken(raw), + Kind: MagicLinkKindEmailVerify, + UserID: userID, + Email: email, + Expires: expires, + Created: time.Now().Unix(), + })) + return raw +} + +func TestUser_MagicLink_VerifyEmail_SetsPrimary(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Now().Add(24*time.Hour).Unix()) + + // Before verifying: pending, not yet verified, no primary + pending, err := a.PendingEmails(phil.ID) + require.Nil(t, err) + require.Equal(t, []string{"phil@example.com"}, pending) + emails, err := a.Emails(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(emails)) + primary, err := a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "", primary) + + // Verify: the first verified email auto-becomes primary + m, err := a.VerifyEmail(hashToken(raw)) + require.Nil(t, err) + require.Equal(t, "phil@example.com", m.Email) + + emails, err = a.Emails(phil.ID) + require.Nil(t, err) + require.Equal(t, []string{"phil@example.com"}, emails) + primary, err = a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "phil@example.com", primary) + pending, err = a.PendingEmails(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(pending)) + + // Reset-by-email lookup resolves to the account + userID, err := a.UserIDByPrimaryEmail("phil@example.com") + require.Nil(t, err) + require.Equal(t, phil.ID, userID) + }) +} + +func TestUser_MagicLink_VerifyEmail_SecondStaysSecondary(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw1 := addVerifyLink(t, a, phil.ID, "first@example.com", time.Now().Add(24*time.Hour).Unix()) + _, err = a.VerifyEmail(hashToken(raw1)) + require.Nil(t, err) + + raw2 := addVerifyLink(t, a, phil.ID, "second@example.com", time.Now().Add(24*time.Hour).Unix()) + _, err = a.VerifyEmail(hashToken(raw2)) + require.Nil(t, err) + + // Both verified, but primary is still the first + emails, err := a.Emails(phil.ID) + require.Nil(t, err) + require.Equal(t, []string{"first@example.com", "second@example.com"}, emails) + primary, err := a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "first@example.com", primary) + }) +} + +func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + ben, err := a.User("ben") + require.Nil(t, err) + + // phil verifies shared@ first -> becomes his primary + rawPhil := addVerifyLink(t, a, phil.ID, "shared@example.com", time.Now().Add(24*time.Hour).Unix()) + _, err = a.VerifyEmail(hashToken(rawPhil)) + require.Nil(t, err) + primary, err := a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "shared@example.com", primary) + + // ben verifies the same address -> allowed as secondary, but NOT his primary + rawBen := addVerifyLink(t, a, ben.ID, "shared@example.com", time.Now().Add(24*time.Hour).Unix()) + _, err = a.VerifyEmail(hashToken(rawBen)) + require.Nil(t, err) + emails, err := a.Emails(ben.ID) + require.Nil(t, err) + require.Equal(t, []string{"shared@example.com"}, emails) + primary, err = a.PrimaryEmail(ben.ID) + require.Nil(t, err) + require.Equal(t, "", primary) + + // Explicitly promoting ben's copy to primary collides with phil's + require.ErrorIs(t, a.SetPrimaryEmail(ben.ID, "shared@example.com"), ErrEmailPrimaryElsewhere) + // ...and phil keeps his primary (the failed promotion rolled back ben's clear, which was a no-op anyway) + primary, err = a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "shared@example.com", primary) + }) +} + +func TestUser_MagicLink_SetPrimary_NotVerified(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + require.ErrorIs(t, a.SetPrimaryEmail(phil.ID, "nope@example.com"), ErrEmailNotFound) + }) +} + +func TestUser_MagicLink_Expired(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Now().Add(-time.Minute).Unix()) + _, err = a.VerifyEmail(hashToken(raw)) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + + // Nothing got verified + emails, err := a.Emails(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(emails)) + }) +} + +func TestUser_MagicLink_SingleUse(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Now().Add(24*time.Hour).Unix()) + _, err = a.VerifyEmail(hashToken(raw)) + require.Nil(t, err) + // Second click: token already consumed + _, err = a.VerifyEmail(hashToken(raw)) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + }) +} + +func TestUser_MagicLink_ReplaceOnReRequest(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw1 := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Now().Add(24*time.Hour).Unix()) + raw2 := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Now().Add(24*time.Hour).Unix()) + + // Only one pending row remains; the old token no longer works + pending, err := a.PendingEmails(phil.ID) + require.Nil(t, err) + require.Equal(t, []string{"phil@example.com"}, pending) + _, err = a.MagicLinkByHash(hashToken(raw1)) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + + m, err := a.MagicLinkByHash(hashToken(raw2)) + require.Nil(t, err) + require.Equal(t, "phil@example.com", m.Email) + }) +} + +func TestUser_MagicLink_PasswordReset_RoundTrip(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw := generateLinkToken() + require.Nil(t, a.AddMagicLink(&MagicLink{ + TokenHash: hashToken(raw), + Kind: MagicLinkKindPasswordReset, + UserID: phil.ID, + Expires: time.Now().Add(time.Hour).Unix(), + Created: time.Now().Unix(), + })) + + m, err := a.MagicLinkByHash(hashToken(raw)) + require.Nil(t, err) + require.Equal(t, MagicLinkKindPasswordReset, m.Kind) + require.Equal(t, phil.ID, m.UserID) + require.Equal(t, "", m.Email) // reset rows carry no email + + // Reset rows do not appear as pending emails + pending, err := a.PendingEmails(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(pending)) + + // New request replaces the old token + raw2 := generateLinkToken() + require.Nil(t, a.AddMagicLink(&MagicLink{ + TokenHash: hashToken(raw2), + Kind: MagicLinkKindPasswordReset, + UserID: phil.ID, + Expires: time.Now().Add(time.Hour).Unix(), + Created: time.Now().Unix(), + })) + _, err = a.MagicLinkByHash(hashToken(raw)) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + + // Single use: deleting consumes it + require.Nil(t, a.DeleteMagicLink(hashToken(raw2))) + _, err = a.MagicLinkByHash(hashToken(raw2)) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + }) +} + +func TestUser_MagicLink_Reaper(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + expired := addVerifyLink(t, a, phil.ID, "expired@example.com", time.Now().Add(-time.Hour).Unix()) + valid := addVerifyLink(t, a, phil.ID, "valid@example.com", time.Now().Add(time.Hour).Unix()) + + require.Nil(t, a.deleteExpiredMagicLinks()) + + _, err = a.MagicLinkByHash(hashToken(expired)) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + m, err := a.MagicLinkByHash(hashToken(valid)) + require.Nil(t, err) + require.Equal(t, "valid@example.com", m.Email) + }) +} + +func TestUser_MagicLink_UserIDByPrimaryEmail_NotFound(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + _, err := a.UserIDByPrimaryEmail("ghost@example.com") + require.ErrorIs(t, err, ErrUserNotFound) + }) +} diff --git a/user/manager.go b/user/manager.go index 34d96403..e6df6910 100644 --- a/user/manager.go +++ b/user/manager.go @@ -40,6 +40,7 @@ const ( DefaultUserPasswordBcryptCost = 10 DefaultAccessCacheEnabled = false DefaultAccessCacheReloadInterval = 87 * time.Second + DefaultExpiredMagicLinkReapInterval = time.Hour // How often expired email-verify/password-reset links are swept ) var ( @@ -91,6 +92,7 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) { go manager.asyncAccessCacheReloadLoop(manager.config.AccessCacheReloadInterval) } go manager.asyncQueueWriteLoop(manager.config.QueueWriterInterval) + go manager.asyncExpiredMagicLinkReapLoop(DefaultExpiredMagicLinkReapInterval) return manager, nil } @@ -128,6 +130,25 @@ func (a *Manager) asyncAccessCacheReloadLoop(interval time.Duration) { } } +// asyncExpiredMagicLinkReapLoop periodically deletes expired email-verification and +// password-reset links so the user_magic_link table does not accumulate dead rows. Expiry is +// already enforced on read, so this is housekeeping only; it replaces the old in-memory +// expireLoop that lived in mail.Sender. +func (a *Manager) asyncExpiredMagicLinkReapLoop(interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-a.quit: + return + case <-ticker.C: + if err := a.deleteExpiredMagicLinks(); err != nil { + log.Tag(tag).Err(err).Warn("Reaping expired magic links failed") + } + } + } +} + // Authenticate checks username and password and returns a User if correct, and the user has not been // marked as deleted. The method returns in constant-ish time, regardless of whether the user exists or // the password is correct or incorrect. @@ -1451,12 +1472,197 @@ func (a *Manager) AddEmail(userID, email string) error { return nil } -// RemoveEmail deletes a verified email address from the user with the given user ID +// RemoveEmail deletes a verified email address from the user with the given user ID. +// Removing the primary email leaves the account with no primary -- there is deliberately +// no auto-promotion of another verified address; the user is nudged to pick a new one. func (a *Manager) RemoveEmail(userID, email string) error { _, err := a.db.Exec(a.queries.deleteEmail, userID, email) return err } +// PrimaryEmail returns the user's primary (recovery) email address, or an empty string if +// the user has not designated one. +func (a *Manager) PrimaryEmail(userID string) (string, error) { + var email sql.NullString + err := a.db.ReadOnly().QueryRow(a.queries.selectPrimaryEmail, userID).Scan(&email) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } else if err != nil { + return "", err + } + return email.String, nil +} + +// UserIDByPrimaryEmail returns the ID of the (at most one) account for which the given address +// is the primary email. Returns ErrUserNotFound if no account claims it as primary. Used by the +// password-reset request flow to resolve an email identifier to a single account. +func (a *Manager) UserIDByPrimaryEmail(email string) (string, error) { + var userID string + err := a.db.ReadOnly().QueryRow(a.queries.selectUserIDByPrimary, email).Scan(&userID) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrUserNotFound + } else if err != nil { + return "", err + } + return userID, nil +} + +// PendingEmails returns the user's unverified (pending) email addresses, i.e. addresses with +// an outstanding email-verification magic link. +func (a *Manager) PendingEmails(userID string) ([]string, error) { + rows, err := a.db.ReadOnly().Query(a.queries.selectPendingEmails, userID) + if err != nil { + return nil, err + } + defer rows.Close() + emails := make([]string, 0) + for rows.Next() { + var email string + if err := rows.Scan(&email); err != nil { + return nil, err + } + emails = append(emails, email) + } + if err := rows.Err(); err != nil { + return nil, err + } + return emails, nil +} + +// SetPrimaryEmail marks a verified email address as the user's primary (recovery) email, +// clearing any previous primary in the same transaction. Returns ErrEmailNotFound if the +// address is not verified on the account, or ErrEmailPrimaryElsewhere if it is already the +// primary email on another account (enforced by the global partial unique index). +func (a *Manager) SetPrimaryEmail(userID, email string) error { + return db.ExecTx(a.db, func(tx *sql.Tx) error { + if _, err := tx.Exec(a.queries.updateEmailClearPrimary, userID); err != nil { + return err + } + res, err := tx.Exec(a.queries.updateEmailSetPrimary, userID, email) + if err != nil { + if isUniqueConstraintError(err) { + return ErrEmailPrimaryElsewhere + } + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrEmailNotFound // Address not verified on this account + } + return nil + }) +} + +// AddMagicLink stores a pending magic link, replacing any existing link in the same scope: +// for email_verify that is the (user_id, email) pair (one pending verification per address); +// for password_reset that is the user_id (one active reset per account). The replace-delete and +// the insert run in one transaction so a re-request atomically supersedes the old token. +func (a *Manager) AddMagicLink(m *MagicLink) error { + return db.ExecTx(a.db, func(tx *sql.Tx) error { + switch m.Kind { + case MagicLinkKindEmailVerify: + if _, err := tx.Exec(a.queries.deleteVerifyScope, m.UserID, m.Email); err != nil { + return err + } + case MagicLinkKindPasswordReset: + if _, err := tx.Exec(a.queries.deleteResetScope, m.UserID); err != nil { + return err + } + default: + return ErrInvalidArgument + } + if _, err := tx.Exec(a.queries.insertMagicLink, m.TokenHash, string(m.Kind), m.UserID, nullString(m.Email), m.Expires, m.Created); err != nil { + return err + } + return nil + }) +} + +// MagicLinkByHash looks up a magic link by the hex SHA-256 of its raw token, returning +// ErrMagicLinkNotFound if none exists. Callers must assert the returned Kind matches the flow +// they serve and check Expires themselves. +func (a *Manager) MagicLinkByHash(tokenHash string) (*MagicLink, error) { + var m MagicLink + var kind string + var email sql.NullString + err := a.db.ReadOnly().QueryRow(a.queries.selectMagicLinkByHash, tokenHash).Scan(&m.TokenHash, &kind, &m.UserID, &email, &m.Expires, &m.Created) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrMagicLinkNotFound + } else if err != nil { + return nil, err + } + m.Kind = MagicLinkKind(kind) + m.Email = email.String + return &m, nil +} + +// DeleteMagicLink deletes a magic link by its token hash. Used to enforce single use after a +// reset is performed (email verification deletes the row inside VerifyEmail's transaction). +func (a *Manager) DeleteMagicLink(tokenHash string) error { + _, err := a.db.Exec(a.queries.deleteMagicLinkByHash, tokenHash) + return err +} + +// VerifyEmail consumes an email-verification magic link: after validating the token (kind + +// expiry), it deletes the link, adds the address to the user's verified emails, and -- if the +// user has no primary email yet and the address is not already primary on another account -- +// promotes the new address to primary. All mutations run in one transaction. A primary +// collision simply leaves the address verified but non-primary. Returns the consumed link. +func (a *Manager) VerifyEmail(tokenHash string) (*MagicLink, error) { + m, err := a.MagicLinkByHash(tokenHash) + if err != nil { + return nil, err + } + if m.Kind != MagicLinkKindEmailVerify || time.Now().Unix() > m.Expires { + return nil, ErrMagicLinkNotFound + } + err = db.ExecTx(a.db, func(tx *sql.Tx) error { + // Single use: delete the link, then add the (idempotent) verified address + if _, err := tx.Exec(a.queries.deleteMagicLinkByHash, tokenHash); err != nil { + return err + } + if _, err := tx.Exec(a.queries.insertEmailIgnore, m.UserID, m.Email); err != nil { + return err + } + // Promote to primary only if the user has none yet and the address is globally free. + // We check with SELECTs rather than catching a unique violation, because Postgres aborts + // the whole transaction on any constraint error (which would undo the verified-email add). + var primary sql.NullString + err := tx.QueryRow(a.queries.selectPrimaryEmail, m.UserID).Scan(&primary) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if primary.String != "" { + return nil // User already has a primary -- leave it + } + var owner string + err = tx.QueryRow(a.queries.selectUserIDByPrimary, m.Email).Scan(&owner) + if errors.Is(err, sql.ErrNoRows) { + if _, err := tx.Exec(a.queries.updateEmailSetPrimary, m.UserID, m.Email); err != nil { + return err + } + } else if err != nil { + return err + } + // owner found -> address is primary elsewhere -> stays a verified secondary + return nil + }) + if err != nil { + return nil, err + } + return m, nil +} + +// deleteExpiredMagicLinks removes magic links whose expiry has passed. Expiry is also enforced +// on read, so this is purely housekeeping to bound table growth; it runs from the reaper loop. +func (a *Manager) deleteExpiredMagicLinks() error { + _, err := a.db.Exec(a.queries.deleteExpiredMagicLinks, time.Now().Unix()) + return err +} + func (a *Manager) readEmail(rows *sql.Rows) (string, error) { var email string if !rows.Next() { diff --git a/user/manager_postgres.go b/user/manager_postgres.go index 0395baae..9cedf5fd 100644 --- a/user/manager_postgres.go +++ b/user/manager_postgres.go @@ -217,9 +217,23 @@ const ( postgresDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2` // Email queries - postgresSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = $1 ORDER BY email` - postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)` - postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $2` + postgresSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = $1 ORDER BY email` + postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)` + postgresInsertEmailIgnoreQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2) ON CONFLICT (user_id, email) DO NOTHING` + postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $2` + postgresSelectPrimaryEmailQuery = `SELECT email FROM user_email WHERE user_id = $1 AND is_primary` + postgresSelectUserIDByPrimaryQuery = `SELECT user_id FROM user_email WHERE email = $1 AND is_primary` + postgresUpdateEmailSetPrimaryQuery = `UPDATE user_email SET is_primary = TRUE WHERE user_id = $1 AND email = $2` + postgresUpdateEmailClearPrimaryQuery = `UPDATE user_email SET is_primary = FALSE WHERE user_id = $1 AND is_primary` + + // Magic link queries (email verification + password reset) + postgresInsertMagicLinkQuery = `INSERT INTO user_magic_link (token_hash, kind, user_id, email, expires, created) VALUES ($1, $2, $3, $4, $5, $6)` + postgresSelectMagicLinkByHashQuery = `SELECT token_hash, kind, user_id, email, expires, created FROM user_magic_link WHERE token_hash = $1` + postgresDeleteMagicLinkByHashQuery = `DELETE FROM user_magic_link WHERE token_hash = $1` + postgresDeleteVerifyScopeQuery = `DELETE FROM user_magic_link WHERE kind = 'email_verify' AND user_id = $1 AND email = $2` + postgresDeleteResetScopeQuery = `DELETE FROM user_magic_link WHERE kind = 'password_reset' AND user_id = $1` + postgresSelectPendingEmailsQuery = `SELECT email FROM user_magic_link WHERE kind = 'email_verify' AND user_id = $1 ORDER BY email` + postgresDeleteExpiredMagicLinksQuery = `DELETE FROM user_magic_link WHERE expires < $1` // Billing queries postgresUpdateBillingQuery = ` @@ -306,7 +320,19 @@ var postgresQueries = queries{ deletePhoneNumber: postgresDeletePhoneNumberQuery, selectEmails: postgresSelectEmailsQuery, insertEmail: postgresInsertEmailQuery, + insertEmailIgnore: postgresInsertEmailIgnoreQuery, deleteEmail: postgresDeleteEmailQuery, + selectPrimaryEmail: postgresSelectPrimaryEmailQuery, + selectUserIDByPrimary: postgresSelectUserIDByPrimaryQuery, + updateEmailSetPrimary: postgresUpdateEmailSetPrimaryQuery, + updateEmailClearPrimary: postgresUpdateEmailClearPrimaryQuery, + insertMagicLink: postgresInsertMagicLinkQuery, + selectMagicLinkByHash: postgresSelectMagicLinkByHashQuery, + deleteMagicLinkByHash: postgresDeleteMagicLinkByHashQuery, + deleteVerifyScope: postgresDeleteVerifyScopeQuery, + deleteResetScope: postgresDeleteResetScopeQuery, + selectPendingEmails: postgresSelectPendingEmailsQuery, + deleteExpiredMagicLinks: postgresDeleteExpiredMagicLinksQuery, updateBilling: postgresUpdateBillingQuery, } diff --git a/user/manager_postgres_schema.go b/user/manager_postgres_schema.go index ba8502f2..a855dc66 100644 --- a/user/manager_postgres_schema.go +++ b/user/manager_postgres_schema.go @@ -75,8 +75,21 @@ const ( CREATE TABLE IF NOT EXISTS user_email ( user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, email TEXT NOT NULL, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, PRIMARY KEY (user_id, email) ); + CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary; + CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary; + CREATE TABLE IF NOT EXISTS user_magic_link ( + token_hash TEXT NOT NULL, + kind TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + email TEXT, + expires BIGINT NOT NULL, + created BIGINT NOT NULL, + PRIMARY KEY (token_hash) + ); + CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind); CREATE TABLE IF NOT EXISTS schema_version ( store TEXT PRIMARY KEY, version INT NOT NULL @@ -89,7 +102,7 @@ const ( // Schema table management queries for Postgres const ( - postgresCurrentSchemaVersion = 7 + postgresCurrentSchemaVersion = 8 postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'` postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)` ) @@ -102,11 +115,30 @@ const ( PRIMARY KEY (user_id, email) ); ` + + // 7 -> 8: primary (recovery) email + magic-link table for verification/reset. + // No backfill -- existing verified emails stay non-primary. + postgresMigrate7To8UpdateQueries = ` + ALTER TABLE user_email ADD COLUMN is_primary BOOLEAN NOT NULL DEFAULT FALSE; + CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary; + CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary; + CREATE TABLE IF NOT EXISTS user_magic_link ( + token_hash TEXT NOT NULL, + kind TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + email TEXT, + expires BIGINT NOT NULL, + created BIGINT NOT NULL, + PRIMARY KEY (token_hash) + ); + CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind); + ` postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'user'` ) var postgresMigrations = map[int]func(db *sql.DB) error{ 6: postgresMigrateFrom6, + 7: postgresMigrateFrom7, } func setupPostgres(db *sql.DB) error { @@ -141,6 +173,16 @@ func postgresMigrateFrom6(db *sql.DB) error { return nil } +func postgresMigrateFrom7(db *sql.DB) error { + if _, err := db.Exec(postgresMigrate7To8UpdateQueries); err != nil { + return err + } + if _, err := db.Exec(postgresUpdateSchemaVersionQuery, 8); err != nil { + return err + } + return nil +} + func setupNewPostgres(db *sql.DB) error { if _, err := db.Exec(postgresCreateTablesQueries); err != nil { return err diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go index 62652ce3..61bafc94 100644 --- a/user/manager_sqlite.go +++ b/user/manager_sqlite.go @@ -214,9 +214,23 @@ const ( sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?` // Email queries - sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ? ORDER BY email` - sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)` - sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?` + sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ? ORDER BY email` + sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)` + sqliteInsertEmailIgnoreQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?) ON CONFLICT (user_id, email) DO NOTHING` + sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?` + sqliteSelectPrimaryEmailQuery = `SELECT email FROM user_email WHERE user_id = ? AND is_primary = 1` + sqliteSelectUserIDByPrimaryQuery = `SELECT user_id FROM user_email WHERE email = ? AND is_primary = 1` + sqliteUpdateEmailSetPrimaryQuery = `UPDATE user_email SET is_primary = 1 WHERE user_id = ? AND email = ?` + sqliteUpdateEmailClearPrimaryQuery = `UPDATE user_email SET is_primary = 0 WHERE user_id = ? AND is_primary = 1` + + // Magic link queries (email verification + password reset) + sqliteInsertMagicLinkQuery = `INSERT INTO user_magic_link (token_hash, kind, user_id, email, expires, created) VALUES (?, ?, ?, ?, ?, ?)` + sqliteSelectMagicLinkByHashQuery = `SELECT token_hash, kind, user_id, email, expires, created FROM user_magic_link WHERE token_hash = ?` + sqliteDeleteMagicLinkByHashQuery = `DELETE FROM user_magic_link WHERE token_hash = ?` + sqliteDeleteVerifyScopeQuery = `DELETE FROM user_magic_link WHERE kind = 'email_verify' AND user_id = ? AND email = ?` + sqliteDeleteResetScopeQuery = `DELETE FROM user_magic_link WHERE kind = 'password_reset' AND user_id = ?` + sqliteSelectPendingEmailsQuery = `SELECT email FROM user_magic_link WHERE kind = 'email_verify' AND user_id = ? ORDER BY email` + sqliteDeleteExpiredMagicLinksQuery = `DELETE FROM user_magic_link WHERE expires < ?` // Billing queries sqliteUpdateBillingQuery = ` @@ -302,7 +316,19 @@ var sqliteQueries = queries{ deletePhoneNumber: sqliteDeletePhoneNumberQuery, selectEmails: sqliteSelectEmailsQuery, insertEmail: sqliteInsertEmailQuery, + insertEmailIgnore: sqliteInsertEmailIgnoreQuery, deleteEmail: sqliteDeleteEmailQuery, + selectPrimaryEmail: sqliteSelectPrimaryEmailQuery, + selectUserIDByPrimary: sqliteSelectUserIDByPrimaryQuery, + updateEmailSetPrimary: sqliteUpdateEmailSetPrimaryQuery, + updateEmailClearPrimary: sqliteUpdateEmailClearPrimaryQuery, + insertMagicLink: sqliteInsertMagicLinkQuery, + selectMagicLinkByHash: sqliteSelectMagicLinkByHashQuery, + deleteMagicLinkByHash: sqliteDeleteMagicLinkByHashQuery, + deleteVerifyScope: sqliteDeleteVerifyScopeQuery, + deleteResetScope: sqliteDeleteResetScopeQuery, + selectPendingEmails: sqliteSelectPendingEmailsQuery, + deleteExpiredMagicLinks: sqliteDeleteExpiredMagicLinksQuery, updateBilling: sqliteUpdateBillingQuery, } diff --git a/user/manager_sqlite_schema.go b/user/manager_sqlite_schema.go index 6ee24f8c..5b389627 100644 --- a/user/manager_sqlite_schema.go +++ b/user/manager_sqlite_schema.go @@ -88,9 +88,23 @@ const ( CREATE TABLE IF NOT EXISTS user_email ( user_id TEXT NOT NULL, email TEXT NOT NULL, + is_primary INT NOT NULL DEFAULT (0), PRIMARY KEY (user_id, email), FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE ); + CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary = 1; + CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary = 1; + CREATE TABLE IF NOT EXISTS user_magic_link ( + token_hash TEXT NOT NULL, + kind TEXT NOT NULL, + user_id TEXT NOT NULL, + email TEXT, + expires INT NOT NULL, + created INT NOT NULL, + PRIMARY KEY (token_hash), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind); CREATE TABLE IF NOT EXISTS schemaVersion ( id INT PRIMARY KEY, version INT NOT NULL @@ -107,7 +121,7 @@ const ( // Schema version table management for SQLite const ( - sqliteCurrentSchemaVersion = 7 + sqliteCurrentSchemaVersion = 8 sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)` sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1` sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` @@ -236,6 +250,26 @@ const ( ); ` + // 7 -> 8: primary (recovery) email + magic-link table for verification/reset. + // No backfill -- existing verified emails stay non-primary, so the ALTER cannot + // conflict and no old notification address becomes a recovery channel. + sqliteMigrate7To8UpdateQueries = ` + ALTER TABLE user_email ADD COLUMN is_primary INT NOT NULL DEFAULT (0); + CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary = 1; + CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary = 1; + CREATE TABLE IF NOT EXISTS user_magic_link ( + token_hash TEXT NOT NULL, + kind TEXT NOT NULL, + user_id TEXT NOT NULL, + email TEXT, + expires INT NOT NULL, + created INT NOT NULL, + PRIMARY KEY (token_hash), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind); + ` + // 5 -> 6 sqliteMigrate5To6UpdateQueries = ` PRAGMA foreign_keys=off; @@ -339,6 +373,7 @@ var ( 4: sqliteMigrateFrom4, 5: sqliteMigrateFrom5, 6: sqliteMigrateFrom6, + 7: sqliteMigrateFrom7, } ) @@ -493,3 +528,16 @@ func sqliteMigrateFrom6(sqlDB *sql.DB) error { return nil }) } + +func sqliteMigrateFrom7(sqlDB *sql.DB) error { + log.Tag(tag).Info("Migrating user database schema: from 7 to 8") + return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + if _, err := tx.Exec(sqliteMigrate7To8UpdateQueries); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 8); err != nil { + return err + } + return nil + }) +} diff --git a/user/manager_test.go b/user/manager_test.go index 6d13929e..3845752e 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -1789,6 +1789,83 @@ func TestMigrationFrom4(t *testing.T) { require.Nil(t, a.Authorize(nil, "up", PermissionRead)) // % matches 0 or more characters } +func TestMigrationFrom7(t *testing.T) { + filename := filepath.Join(t.TempDir(), "user.db") + rawDB, err := sql.Open("sqlite3", filename) + require.Nil(t, err) + + // Create a "version 7" schema: user_email exists but has no is_primary column, and there + // is no user_magic_link table yet. (Mirrors the production schema right before v8.) + _, err = rawDB.Exec(` + BEGIN; + CREATE TABLE IF NOT EXISTS tier ( + id TEXT PRIMARY KEY, code TEXT NOT NULL, name TEXT NOT NULL, + messages_limit INT NOT NULL, messages_expiry_duration INT NOT NULL, emails_limit INT NOT NULL, + calls_limit INT NOT NULL, reservations_limit INT NOT NULL, attachment_file_size_limit INT NOT NULL, + attachment_total_size_limit INT NOT NULL, attachment_expiry_duration INT NOT NULL, + attachment_bandwidth_limit INT NOT NULL, stripe_monthly_price_id TEXT, stripe_yearly_price_id TEXT + ); + CREATE TABLE IF NOT EXISTS user ( + id TEXT PRIMARY KEY, tier_id TEXT, user TEXT NOT NULL, pass TEXT NOT NULL, + role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL, + prefs JSON NOT NULL DEFAULT '{}', sync_topic TEXT NOT NULL, provisioned INT NOT NULL, + stats_messages INT NOT NULL DEFAULT (0), stats_emails INT NOT NULL DEFAULT (0), + stats_calls INT NOT NULL DEFAULT (0), stripe_customer_id TEXT, stripe_subscription_id TEXT, + stripe_subscription_status TEXT, stripe_subscription_interval TEXT, + stripe_subscription_paid_until INT, stripe_subscription_cancel_at INT, created INT NOT NULL, deleted INT, + FOREIGN KEY (tier_id) REFERENCES tier (id) + ); + CREATE UNIQUE INDEX idx_user ON user (user); + CREATE TABLE IF NOT EXISTS user_email ( + user_id TEXT NOT NULL, + email TEXT NOT NULL, + PRIMARY KEY (user_id, email), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS schemaVersion (id INT PRIMARY KEY, version INT NOT NULL); + INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created) + VALUES ('u_everyone', '*', '', 'anonymous', '', 0, UNIXEPOCH()); + INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created) + VALUES ('u_phil', 'phil', '', 'user', 'st_phil', 0, UNIXEPOCH()); + INSERT INTO user_email (user_id, email) VALUES ('u_phil', 'old@example.com'); + INSERT INTO schemaVersion (id, version) VALUES (1, 7); + COMMIT; + `) + require.Nil(t, err) + require.Nil(t, rawDB.Close()) + + // Opening the manager triggers the 7 -> 8 migration + a := newTestManagerFromFile(t, filename, "", PermissionDenyAll, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval) + checkSchemaVersion(t, testDB(a)) + + // The pre-existing verified email survives and stays NON-primary (no backfill) + emails, err := a.Emails("u_phil") + require.Nil(t, err) + require.Equal(t, []string{"old@example.com"}, emails) + primary, err := a.PrimaryEmail("u_phil") + require.Nil(t, err) + require.Equal(t, "", primary) + + // The new magic-link machinery works post-migration + raw := generateLinkToken() + require.Nil(t, a.AddMagicLink(&MagicLink{ + TokenHash: hashToken(raw), + Kind: MagicLinkKindEmailVerify, + UserID: "u_phil", + Email: "new@example.com", + Expires: time.Now().Add(24 * time.Hour).Unix(), + Created: time.Now().Unix(), + })) + m, err := a.VerifyEmail(hashToken(raw)) + require.Nil(t, err) + require.Equal(t, "new@example.com", m.Email) + + // new@ becomes primary because the user had none (old@ was a pre-existing non-primary) + primary, err = a.PrimaryEmail("u_phil") + require.Nil(t, err) + require.Equal(t, "new@example.com", primary) +} + func checkSchemaVersion(t *testing.T, d *db.DB) { rows, err := d.Query(`SELECT version FROM schemaVersion`) require.Nil(t, err) diff --git a/user/types.go b/user/types.go index c400e48f..44aecd5d 100644 --- a/user/types.go +++ b/user/types.go @@ -73,6 +73,27 @@ type TokenUpdate struct { LastOrigin netip.Addr } +// MagicLinkKind discriminates the two link-token flows stored in the user_magic_link table. +type MagicLinkKind string + +// Magic link kinds +const ( + MagicLinkKindEmailVerify MagicLinkKind = "email_verify" + MagicLinkKindPasswordReset MagicLinkKind = "password_reset" +) + +// MagicLink is a pending, single-use link token -- either an email verification or a +// password reset, distinguished by Kind. The raw token travels in the emailed link; +// only its TokenHash (hex SHA-256) is persisted. +type MagicLink struct { + TokenHash string + Kind MagicLinkKind + UserID string + Email string // Address being verified for email_verify; empty (NULL) for password_reset + Expires int64 + Created int64 +} + // Prefs represents a user's configuration settings type Prefs struct { Language *string `json:"language,omitempty"` @@ -275,6 +296,8 @@ var ( ErrPhoneNumberExists = errors.New("phone number already exists") ErrEmailNotFound = errors.New("email not found") ErrEmailExists = errors.New("email already exists") + ErrEmailPrimaryElsewhere = errors.New("email is the recovery email on another account") + ErrMagicLinkNotFound = errors.New("magic link not found") ErrProvisionedUserChange = errors.New("cannot change or delete provisioned user") ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token") ) @@ -350,9 +373,23 @@ type queries struct { deletePhoneNumber string // Email queries - selectEmails string - insertEmail string - deleteEmail string + selectEmails string + insertEmail string + insertEmailIgnore string // Idempotent insert (ON CONFLICT DO NOTHING) used inside VerifyEmail + deleteEmail string + selectPrimaryEmail string + selectUserIDByPrimary string + updateEmailSetPrimary string + updateEmailClearPrimary string + + // Magic link queries (email verification + password reset) + insertMagicLink string + selectMagicLinkByHash string + deleteMagicLinkByHash string + deleteVerifyScope string // Delete pending email_verify rows for (user_id, email) + deleteResetScope string // Delete the active password_reset row for user_id + selectPendingEmails string // Pending (unverified) email addresses for a user + deleteExpiredMagicLinks string // Billing queries updateBilling string diff --git a/user/util.go b/user/util.go index 16f6cc09..5157b95f 100644 --- a/user/util.go +++ b/user/util.go @@ -1,7 +1,9 @@ package user import ( + "crypto/sha256" "database/sql" + "encoding/hex" "regexp" "strings" @@ -9,6 +11,11 @@ import ( "heckel.io/ntfy/v2/util" ) +// linkTokenLength is the length of a raw magic-link token. At 48 base62 characters +// it carries ~285 bits of entropy, well above the ~256-bit target, so the tokens +// need no brute-force cap -- just expiry and single-use. +const linkTokenLength = 48 + var ( allowedUsernameRegex = regexp.MustCompile(`^[-_.+@a-zA-Z0-9]+$`) // Does not include Everyone (*) allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*' @@ -67,6 +74,21 @@ func GenerateToken() string { return util.RandomLowerStringPrefix(tokenPrefix, tokenLength) } +// generateLinkToken returns a fresh high-entropy raw token for a magic link +// (email verification or password reset). The raw token is carried in the emailed +// link; only its hashToken digest is persisted. +func generateLinkToken() string { + return util.RandomString(linkTokenLength) +} + +// hashToken returns the hex-encoded SHA-256 digest of a raw magic-link token. +// Tokens are stored hashed so a database read cannot yield working links; a high-entropy +// token makes a fast (unsalted) hash sufficient, unlike a password. +func hashToken(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + // HashPassword hashes the given password using bcrypt with the configured cost func HashPassword(password string) (string, error) { return hashPassword(password, DefaultUserPasswordBcryptCost) diff --git a/util/util.go b/util/util.go index be349691..1c924e2b 100644 --- a/util/util.go +++ b/util/util.go @@ -2,20 +2,19 @@ package util import ( "bytes" + crand "crypto/rand" "encoding/base64" "encoding/json" "errors" "fmt" "io" "math" - "math/rand" "net/netip" "os" "regexp" "slices" "strconv" "strings" - "sync" "time" "unicode/utf8" @@ -30,8 +29,6 @@ const ( ) var ( - random = rand.New(rand.NewSource(time.Now().UnixNano())) - randomMutex = sync.Mutex{} sizeStrRegex = regexp.MustCompile(`(?i)^(\d+)([gmkb])?$`) errInvalidPriority = errors.New("invalid priority") noQuotesRegex = regexp.MustCompile(`^[-_./:@a-zA-Z0-9]+$`) @@ -144,14 +141,33 @@ func RandomLowerStringPrefix(prefix string, length int) string { return randomStringPrefixWithCharset(prefix, length, randomStringLowerCaseCharset) } +// randomStringPrefixWithCharset builds a random string from charset using crypto/rand. +// We use rejection sampling (dropping the few highest byte values that would skew the +// distribution) so every character is uniformly distributed -- important because these +// strings back security tokens (access tokens, magic-link tokens, IDs), not just labels. func randomStringPrefixWithCharset(prefix string, length int, charset string) string { - randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?! - defer randomMutex.Unlock() - b := make([]byte, length-len(prefix)) - for i := range b { - b[i] = charset[random.Intn(len(charset))] + n := length - len(prefix) + if n <= 0 { + return prefix[:length] } - return prefix + string(b) + result := make([]byte, n) + limit := 256 - (256 % len(charset)) // reject byte values >= limit to avoid modulo bias + buf := make([]byte, n) + for i := 0; i < n; { + if _, err := crand.Read(buf); err != nil { + panic("crypto/rand failed: " + err.Error()) // Should never happen on a sane system + } + for _, c := range buf { + if i >= n { + break + } + if int(c) < limit { + result[i] = charset[int(c)%len(charset)] + i++ + } + } + } + return prefix + string(result) } // ValidRandomString returns true if the given string matches the format created by RandomString diff --git a/util/util_test.go b/util/util_test.go index ae855147..8de18b66 100644 --- a/util/util_test.go +++ b/util/util_test.go @@ -25,6 +25,30 @@ func TestRandomString(t *testing.T) { require.NotEqual(t, s1, s2) } +// TestRandomString_CSPRNG guards the crypto/rand-backed generator: every character must come +// from the expected charset (rejection sampling correctness) and a large batch must be unique +// (no clock-seeded PRNG collapsing to a predictable stream). +func TestRandomString_CSPRNG(t *testing.T) { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + seen := make(map[string]bool) + charCounts := make(map[rune]int) + for i := 0; i < 5000; i++ { + s := RandomString(48) + require.Equal(t, 48, len(s)) + require.False(t, seen[s], "duplicate random string generated") + seen[s] = true + for _, c := range s { + require.Contains(t, charset, string(c)) + charCounts[c]++ + } + } + // Every charset character should appear at least once across 5000*48 draws; a heavily + // biased or broken generator would leave gaps. + for _, c := range charset { + require.Greater(t, charCounts[c], 0, "character %q never appeared", string(c)) + } +} + func TestFileExists(t *testing.T) { filename := filepath.Join(t.TempDir(), "somefile.txt") require.Nil(t, os.WriteFile(filename, []byte{0x25, 0x86}, 0600))