package db import ( "crypto/rand" "database/sql" "math/big" "time" ) const appPasswordChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789" // GenerateAppPassword returns a random secret for IMAP/SMTP client login — the only // credential those protocols ever see, since AUTH has no interactive MFA step (see // esrv_mailbox_app_passwords in schema.go). Floors at 25 chars regardless of minLen. func GenerateAppPassword(minLen int) string { if minLen < 25 { minLen = 25 } b := make([]byte, minLen) max := big.NewInt(int64(len(appPasswordChars))) for i := range b { n, _ := rand.Int(rand.Reader, max) b[i] = appPasswordChars[n.Int64()] } return string(b) } func (d *DB) ListAppPasswordsForMailbox(mailboxID int64) ([]MailboxAppPassword, error) { rows, err := d.Query(`SELECT id, mailbox_id, label, password_hash, is_active, created_at, last_used_at, expires_at FROM esrv_mailbox_app_passwords WHERE mailbox_id = ? ORDER BY created_at`, mailboxID) if err != nil { return nil, err } defer rows.Close() var out []MailboxAppPassword for rows.Next() { var p MailboxAppPassword var createdAt string var lastUsedAt, expiresAt sql.NullString if err := rows.Scan(&p.ID, &p.MailboxID, &p.Label, &p.PasswordHash, &p.IsActive, &createdAt, &lastUsedAt, &expiresAt); err != nil { return nil, err } p.CreatedAt, _ = parseTime(createdAt) if lastUsedAt.Valid { t, _ := parseTime(lastUsedAt.String) p.LastUsedAt = &t } if expiresAt.Valid { t, _ := parseTime(expiresAt.String) p.ExpiresAt = &t } out = append(out, p) } return out, rows.Err() } // CreateAppPassword inserts a new app password. expiresAt is nil for one that never // expires (the default). func (d *DB) CreateAppPassword(mailboxID int64, label, passwordHash string, expiresAt *time.Time) (int64, error) { res, err := d.Exec(`INSERT INTO esrv_mailbox_app_passwords (mailbox_id, label, password_hash, expires_at) VALUES (?, ?, ?, ?)`, mailboxID, label, passwordHash, expiresAt) if err != nil { return 0, err } return res.LastInsertId() } // VerifyMailboxAppPassword resolves the mailbox by its primary email (never an alias) // and bcrypt-checks it against every active app password. A mailbox's app-password // list is small, so a linear scan needs no index. Returns (nil, nil) on no match. func (d *DB) VerifyMailboxAppPassword(email, password string) (*Mailbox, error) { mbox, err := d.GetMailboxByEmail(email) if err != nil || mbox == nil { return nil, err } rows, err := d.Query(`SELECT id, password_hash FROM esrv_mailbox_app_passwords WHERE mailbox_id = ? AND is_active = 1 AND (expires_at IS NULL OR expires_at > ?)`, mbox.ID, time.Now()) if err != nil { return nil, err } var matchedID int64 found := false for rows.Next() { var id int64 var hash string if err := rows.Scan(&id, &hash); err != nil { rows.Close() return nil, err } if CheckPassword(password, hash) { matchedID = id found = true break } } rowsErr := rows.Err() // Must close before the UPDATE below: the connection pool is capped to one // connection (see schema.go's Open), so an Exec while these rows are still open // would deadlock waiting for a connection that rows itself is holding. rows.Close() if rowsErr != nil { return nil, rowsErr } if !found { return nil, nil } if _, err := d.Exec(`UPDATE esrv_mailbox_app_passwords SET last_used_at = ? WHERE id = ?`, time.Now(), matchedID); err != nil { return nil, err } return mbox, nil } func (d *DB) SetAppPasswordActive(id int64, active bool) error { _, err := d.Exec(`UPDATE esrv_mailbox_app_passwords SET is_active = ? WHERE id = ?`, active, id) return err } // RemoveAppPassword deletes an app password, scoped to mailboxID so a caller can't // remove one belonging to a different mailbox by guessing/manipulating its id — // mirrors DeleteWebAuthnCredential's (id, ownerID) pattern. func (d *DB) RemoveAppPassword(id, mailboxID int64) error { _, err := d.Exec(`DELETE FROM esrv_mailbox_app_passwords WHERE id = ? AND mailbox_id = ?`, id, mailboxID) return err }