ADR-0129: VayuMail Device Approval — No Mail Sync Without Web Approval
Status: Accepted
Date: 2026-07-11
Author: @johalputt
Relates to: ADR-0126 (app
passwords, the credential rows device identities build on),
ADR-0128 (the private-key sync
endpoint this ADR hardens), the mail auth bridge (cmd/vayupress/vayuos.go),
the VayuPortal backend (cmd/vayupress/handlers_portal.go)
Context
IMAP, POP3 and SMTP submission authenticate with a single static credential.
They cannot prompt for a second factor, show a push notification, or run any
interactive challenge — the protocol handshake is LOGIN user pass and that
is the whole conversation. So even with TOTP 2FA enabled on a mailbox and on
the web console, a stolen mailbox password gave full mail access from any
device on earth: an attacker points any IMAP client at the server, types the
password, and the entire mailbox syncs.
App passwords (ADR-0126) reduced the blast radius of device compromise — a per-device credential is individually revocable — but minting one happens in the console, and the raw mailbox password itself still authenticated the mail protocols. The credential the attacker is most likely to hold (the password, via phishing, reuse or a leak) remained sufficient to sync everything.
The operator wants the bulletproof model: a new device must be explicitly approved from the 2FA-protected VayuPress web console before any mail syncs to it, and a password alone must never be enough.
Decision
Make the credential itself the enforcement point, since the mail protocols offer nothing else — the same insight as the 2FA-enforce policy, now applied per device with an explicit approval lifecycle.
Device identities on top of app passwords
vayumail_app_passwords gains four idempotently migrated columns:
device_id (random 32-hex identity, unique when set), platform, status
(pending | approved | blocked, default 'approved'), and last_used_at.
A device is an app-password row with a device identity. Existing rows keep
status='approved' by the migration default — they were minted from the
2FA-protected console, so grandfathering them is safe.
vayumail_accounts gains require_device_approval INTEGER NOT NULL DEFAULT 1
with a getter/setter on the AccountStore and a per-mailbox toggle in the
console. The secure default is ON; an identity without an account row (a
CMS-only user) is treated as ON too.
Registration and status — the member API
POST /api/v1/members/vayumail-device-register{email, password, device_name, platform}— authenticates with the raw mailbox credential (web-bootstrap scope, see below), then creates a device row: freshdevice_id, a freshly generated device password (same generator/format as app passwords, stored only as an Argon2id hash),status='pending'— or'approved'immediately when the mailbox has approval switched off. Responds{device_id, device_password, status}withCache-Control: no-store; the secret is shown exactly once. Audit:vayumail.device.register.POST /api/v1/members/vayumail-device-status{email, device_id, device_password}— the device proves possession of its OWN credential (verified against that one row's hash) and learns its state, so the app can tell the user to go approve it — and start syncing the moment it flips. Bumpslast_used_at.
Both reuse the shared mailAuthThrottle and return a uniform 401 on any
failure — byte-identical for an unknown mailbox, an unknown device and a wrong
secret — so neither endpoint can enumerate addresses or devices.
Enforcement — two credential scopes at the single chokepoint
verifyCredential (the one place every mail login already flowed through) is
split into two explicit scopes over a shared core:
- Mail-sync scope (
verifyCredential): IMAP/POP3/submission viaAuthUser, and the private-key sync endpoint (ADR-0128), because the private key decrypts the same mail those protocols serve. When the mailbox requires device approval, the raw CMS/mailbox password is rejected here; a credential matching a device row authenticates only if that row isapproved(bumpinglast_used_at).pendingandblockedrows fail uniformly. - Web-bootstrap scope (
verifyCredentialWeb): the member HTTP endpoints (vayumail-login,vayumail-device-register) keep accepting the raw password — a new device needs it exactly once, to register; a member needs it to sign into the portal (where TOTP is enforced separately). Throttling is identical in both scopes.
Console — the approval anchor
A Devices card on the admin Accounts page lists every registered device
(mailbox, label, platform, status chip, registered, last used) with
Approve / Block / Remove actions (POST /os/vayumail/devices/action,
CSRF-protected, admin-only, audited as vayumail.device.approve /
.block / .remove / .require), plus the per-mailbox
"Require device approval" toggle. Pending devices sort first and wear a
prominent badge--pending chip (styled for both themes). Approval is
deliberately admin-only: the session behind the console is protected by
interactive 2FA, which is precisely the factor IMAP cannot carry — a mailbox
holder must not be able to approve their own device with the same password
the attacker may hold.
Threat model
- Stolen mailbox password, attacker's device: the password no longer
authenticates IMAP/POP3/submission or the private-key endpoint. The
attacker can register a device — which sits
pending, syncs nothing, and announces itself on the Accounts page (an intrusion signal, audited with a register entry). Approval requires the 2FA-protected console session the attacker does not have. - Stolen device credential: revocable individually (Block/Remove) without touching the mailbox password; blocking takes effect on the next auth.
- Enumeration: register/status failures are byte-identical 401s; the
register response for a valid password necessarily differs (that is the
authenticated path), matching the
totp-requiredprecedent. - Lockout: the password always works for member login and device registration, and the operator can toggle enforcement off per mailbox, so no one is ever locked out of creating a path back in; only unattended sync is gated.
Consequences
- New devices (including any generic IMAP client using a freshly registered device credential) do not sync until approved — this is the point, and it is one console click.
- Mailboxes that relied on raw-password IMAP sign-in must either register +
approve a device (or mint an app password in the console) or have
require_device_approvalswitched off — the toggle exists exactly for operators who prefer the old model. - Existing app passwords keep working unchanged (
approvedby migration), so already-connected devices survive the upgrade without interruption. - The private-key endpoint (ADR-0128) is strictly hardened: with approval required, only an approved device credential unlocks the key.