diff --git a/docs/spec/SPEC.md b/docs/spec/SPEC.md index 35fa56c..bd11d36 100644 --- a/docs/spec/SPEC.md +++ b/docs/spec/SPEC.md @@ -336,6 +336,9 @@ which this design exists to avoid. ### 5.2 The contract between them +The full contract is in `contract.md` (drafted 2026-09-18). What follows is +the summary it expands. + Versioned, and advertised in the JMAP session so either side can check it. - **Discovery.** The server's session names its webmail URL and the contract @@ -510,7 +513,7 @@ before anything that could prompt that notice happens. ## 8. Open decisions -- The INBUXA fork of ihasmail: its name, its repository, and how it tracks +- The INBUXA fork of ihasmail is **ihasmail-inbuxa** (named 2026-09-18). Open: its repository, and how it tracks public ihasmail (§5). - Product name: whether the shipped product is called inbuxa-server or something else inside the INBUXA brand. diff --git a/docs/spec/contract.md b/docs/spec/contract.md new file mode 100644 index 0000000..77dc1f2 --- /dev/null +++ b/docs/spec/contract.md @@ -0,0 +1,256 @@ +# Contract: inbuxa-server and its front ends + +Status: draft, 2026-09-18. Expands SPEC.md §5.2. + +## Parties + +| Party | What it is | How it reaches the server | +|---|---|---| +| **inbuxa-server** | The mail server | — | +| **ihasmail-inbuxa** | The INBUXA fork of ihasmail: a Node server and a web app. Public ihasmail stays Stalwart-facing and isn't party to this (SPEC.md §5) | Its **Node server** calls inbuxa-server, server to server. The browser only ever talks to ihasmail-inbuxa | +| **INBUXA Admin** (`inbuxa-admin`) | A static web app, a fork of Stalwart WebUI | The **browser** calls inbuxa-server directly, cross-origin | + +That split decides most of what follows. Cross-origin rules matter only for +INBUXA Admin. Token custody matters most for ihasmail-inbuxa, which holds +tokens on its server for people who aren't there. + +## What upstream does today + +Observed in the source at `v0.16.22` and against a running inbuxa-server on +2026-09-18. It's shared AGPL code, not Enterprise. + +- **OAuth endpoints:** discovery at `/.well-known/oauth-authorization-server` + and `/.well-known/openid-configuration`; the sign-in page at `/login`, which + posts to `/api/auth`; `/auth/token` (authorization code with PKCE, device + code, refresh token); `/auth/device`, `/auth/introspect`, `/auth/userinfo`; + dynamic client registration at `/auth/register`, which issues "stateless" + sealed client ids that carry their own redirect URIs; `/api/discover/{user}`, + which INBUXA Admin uses to find the endpoints. +- **There's no revocation endpoint.** Tokens are stateless and sealed, so a + token can only be invalidated by changing the account's password. +- **Clients aren't required to be registered, by default.** + `x:OidcProvider.requireClientRegistration` defaults to `false`. With it off, + any `client_id` with any redirect URI is accepted (HTTP ones only with PKCE). + With it on, a registered `x:OAuthClient` must use one of its `redirectUris`, + and an unregistered id is refused unless the person signing in holds + `oAuthClientOverride`. +- **Cross-origin:** `x:Http.usePermissiveCors` (all origins, `*`) defaults to + off, and is forced on in recovery mode, which includes first-boot bootstrap. + The OAuth metadata, token and OpenID endpoints answer `*` regardless. So on a + configured server running normally, INBUXA Admin's JMAP calls from another + origin are **blocked**, unless an operator allows every origin. +- **A server with no public URL** returns relative OAuth endpoints (`/login`) + and an empty issuer. INBUXA Admin now resolves them against the server's + address (fixed 2026-09-18). +- **Endpoint gating:** `x:Http.allowedEndpoints` is an expression that can + refuse endpoints by path and client IP. JMAP administration shares `/jmap` + with everything else, so it can't separate admin calls on its own. +- **ihasmail today** (public, and so the starting point for ihasmail-inbuxa) + signs in with HTTP Basic auth and keeps the password sealed in its session + store (`sealedCredentials: {username, password}`), sending it on every + upstream call. It registers JMAP push subscriptions to its own URL, and reads + permissions and edition from `/api/account`. + +## Requirements + +Each has an ID, and tests name the IDs they check. + +### Discovery and versions + +- **C-1.** The JMAP session carries a capability `urn:inbuxa:jmap` (the fork's + own namespace, SPEC.md §2.4) with: + - `version`: INBUXA's version (`2026.9.18`); + - `base`: the Stalwart release it's built on (`0.16.22`), absent once the + fork no longer tracks upstream; + - `contract`: this contract's version, an integer, starting at `1`; + - `webmailUrl` and `adminUrl`: where the front ends are, if configured. +- **C-2.** Each front end states the contract versions it supports and checks + `contract` after signing in. Outside its range it stops, with a message + naming both versions. For ihasmail-inbuxa this replaces public ihasmail's + "Stalwart 0.16 or later" check. +- **C-3.** A breaking change to anything in this document bumps `contract`. + Adding optional fields doesn't. + +### The front ends, configured once + +- **C-4.** A server-level singleton, `x:FrontEnds`, in the fork's namespace, + records `webmailUrl`, `adminUrl`, and `extraOrigins` (a list of further + allowed origins). It's the single source for discovery (C-1), the OAuth + client registrations (C-6), the cross-origin allowlist (C-14) and the admin + lane (C-18). The installer sets it, and so does INBUXA Admin's setup wizard. + +### Sign-in + +- **C-5.** INBUXA requires client registration: `requireClientRegistration` + defaults to **true**. This is a deliberate difference from upstream (see + "Security note"). +- **C-6.** Two first-party clients are registered as `x:OAuthClient` whenever + `x:FrontEnds` is set or changed: + - **`inbuxa-admin`**: a public client (no secret), authorization code with + PKCE S256, redirect URI `{adminUrl}/oauth/callback`. + - **`ihasmail-inbuxa`**: a confidential client with a secret held by the + ihasmail-inbuxa server, authorization code with PKCE S256, redirect URI + `{webmailUrl}/api/auth/callback`. + INBUXA Admin's `` is set to `inbuxa-admin`. + Until then it keeps upstream's `stalwart-webui`, which only works while + registration isn't required. +- **C-7.** Third-party mail clients (Thunderbird, mobile apps) keep working + through dynamic registration (`/auth/register`), whose stateless client ids + carry their own redirect URIs. That's upstream's mechanism and is left as it + is. +- **C-8.** People sign in on **the server's own sign-in page** (`/login`, + already INBUXA-branded), never on a front end's form. Two-factor happens + there, on the page's existing one-time-code step. Front ends never see a + password. ihasmail-inbuxa's own sign-in form is retired in favor of a + redirect. +- **C-9.** **Consent for anything that isn't first-party.** When a client other + than the two first-party ones asks to sign someone in, the sign-in page names + the client and the host its redirect URI goes to ("*Thunderbird* wants access + to your mail, and will return to *localhost*"). First-party clients skip it. + **Decision**: this is what makes an authorization-code phishing link visible + even where registration isn't required. + +### Tokens + +- **C-10.** ihasmail-inbuxa holds tokens, never passwords. It keeps the access + and refresh token for each session sealed in its session store, where it now + keeps sealed credentials, and refreshes the access token before it expires. + The browser still holds only ihasmail-inbuxa's own session cookie. Public + ihasmail's "the browser never holds a credential" property is kept. +- **C-11.** INBUXA Admin holds its tokens in the browser, as upstream WebUI + does, since it has no server of its own. So admin tokens are short-lived + (C-13). +- **C-12.** **Revocation**, which upstream can't do per token. Each refresh + token belongs to a *grant*: one sign-in by one client on one device, with its + own id. The server keeps a list of revoked grant ids until their tokens would + have expired anyway, and checks it on every token use and refresh. Revoking + is available: + - per grant, through `POST /auth/revoke` (RFC 7009, for a token the client + holds); + - for the signed-in account: every grant, or every grant except this one + ("sign out other sessions"); + - for an administrator: any account's grants; + - automatically, on password change (as upstream does today) and on account + deletion or suspension. + A revoked token stops working on its next use, and never later than one + access-token lifetime. +- **C-13.** Grants are listed per account (client, device description, created, + last used, IP), so ihasmail-inbuxa's "your sessions" screen shows server-side + truth. Lifetimes, all configurable: access tokens 1 hour and refresh 30 days + for ihasmail-inbuxa; access tokens 15 minutes and refresh 8 hours for + `inbuxa-admin`. + +### Cross-origin + +- **C-14.** Cross-origin requests are allowed only from the origins in + `x:FrontEnds`: `adminUrl`'s origin, `webmailUrl`'s origin, and + `extraOrigins`. The server echoes the matching origin in + `Access-Control-Allow-Origin` with `Vary: Origin`, never `*`, and sends + nothing for any other origin. +- **C-15.** OAuth discovery metadata may stay `*`, since it's public and + read-only. The token, revocation, introspection and userinfo endpoints follow + C-14, so a random web page can't exchange or probe tokens. +- **C-16.** Bootstrap and recovery mode keep upstream's permissive CORS, since + no front ends are configured yet and the admin needs to reach the server. + They're also when the recovery administrator applies (SPEC.md §6.2). +- **C-17.** `x:Http.usePermissiveCors` stays available for operators, but + turning it on logs a warning at startup saying which endpoints it opens. + +### The admin lane + +- **C-18.** Server-level administration (registry objects outside a tenant, + `x:Bootstrap`, `x:Action`, `x:Task`, the telemetry and troubleshooting + endpoints) requires a token issued to `inbuxa-admin`. That's the new OAuth + scope `inbuxa:admin`, which only that client is ever granted. An admin + account signing in through a mail client can't administer the server with + that token, even though the account could. +- **C-19.** ihasmail-inbuxa's own administration (accounts, domains, groups, + lists, roles, tenants, the dashboard) uses the scope `inbuxa:account-admin`, + granted only to `ihasmail-inbuxa`, and limited to those object types. +- **C-20.** Optionally, `x:FrontEnds.adminNetworks` (a list of CIDRs) limits + where `inbuxa:admin` requests may come from. Empty means anywhere. +- **C-21.** Basic auth never reaches the admin lane outside recovery mode. An + administrator who needs a script uses an API key or an app password with an + explicit admin scope, which is a separate, auditable credential. + +### Push + +- **C-22.** Unchanged from public ihasmail: ihasmail-inbuxa registers JMAP push + subscriptions to its own URL, with VAPID for browser notifications. The only + difference is that it authenticates with its token rather than the password. + +## First boot + +1. The installer, or INBUXA Admin's setup wizard, completes bootstrap + (SPEC.md §6.2). In bootstrap mode, CORS is permissive (C-16) and the + recovery administrator applies. +2. It sets `x:FrontEnds` (webmail and admin URLs, the public URL), which + registers both first-party clients (C-6). For ihasmail-inbuxa it returns the + client secret once, for the installer to write into ihasmail-inbuxa's + environment. +3. After the restart out of bootstrap, CORS follows C-14, registration is + required (C-5), and the recovery administrator is ignored (SPEC.md §6.2). + +## Security note: upstream accepts any client by default + +With `requireClientRegistration` off, which is upstream's default, the server +issues authorization codes to any `client_id` and any redirect URI, provided +PKCE is used for HTTP redirects. That allows authorization-code phishing: + +1. An attacker sends someone a link to the **genuine** sign-in page, with the + attacker's site as the redirect URI and a PKCE challenge the attacker made. +2. The person signs in on the real page, correctly, since nothing about it is + fake. +3. The code goes to the attacker's site, and the attacker, holding the PKCE + verifier, exchanges it for a working token to the person's mailbox. + +INBUXA closes this with C-5 (registration required), C-9 (consent naming the +redirect host), C-15 (token endpoint not callable from arbitrary pages) and +C-18 (no admin scope for anything but `inbuxa-admin`). + +INBUXA's production server runs upstream Stalwart Enterprise today. Whether +`requireClientRegistration` is on there hasn't been checked. It needs an +admin to read `x:OidcProvider`. Turning it on would break any client that uses +OAuth without registering, so check which clients INBUXA's users sign in with +before changing it. + +## Acceptance tests + +1. The session shows `urn:inbuxa:jmap` with `version`, `base`, `contract: 1` + and the front-end URLs (C-1). +2. A front end supporting only contract 2 refuses a contract-1 server with + both numbers in its message (C-2). +3. Setting `x:FrontEnds` registers both clients with the right redirect URIs. + Changing `adminUrl` updates `inbuxa-admin`'s (C-4, C-6). +4. An unregistered `client_id` is refused at sign-in. A registered one with a + foreign redirect URI is refused (C-5). +5. The phishing flow in the security note fails at step 1, and a registered + third-party client with a non-first-party redirect shows the consent page + (C-9). +6. ihasmail-inbuxa signs in without ever handling a password. Its session + store holds tokens only (C-8, C-10). +7. Revoking one grant stops that session within one access-token lifetime, + leaves others working, and "sign out other sessions" keeps the current one + (C-12). +8. A password change revokes every grant (C-12). +9. A cross-origin request from the admin origin gets that origin echoed. From + any other origin it gets no CORS headers, on `/jmap` and on `/auth/token` + alike (C-14, C-15). +10. In bootstrap mode, INBUXA Admin reaches the server from any origin (C-16). +11. An admin account's token from a third-party mail client can't read + `x:NetworkListener`. The same account through `inbuxa-admin` can (C-18). +12. ihasmail-inbuxa's token can manage accounts and tenants but not listeners + or certificates (C-19). +13. With `adminNetworks` set, an `inbuxa:admin` request from outside is refused + (C-20). +14. Basic auth to an admin method is refused on a configured server (C-21). + +## Open questions + +1. Whether `x:FrontEnds` is a new registry object (a schema addition in the + fork's namespace) or kept in the fork's own store. A registry object is + editable in INBUXA Admin for free, since the admin is schema-driven. +2. The consent page's wording and whether it remembers a decision per client. +3. API keys and app passwords with explicit scopes (C-21): what upstream's + `x:ApiKey` already supports, to observe before specifying. +4. Whether ihasmail-inbuxa's secret should rotate, and how.