Files
native/src/api/account-security.ts
T

518 lines
21 KiB
TypeScript

// Stalwart account-security management. Mirrors the webmail's
// stores/account-security-store.ts, talking to Stalwart's JMAP extension
// methods (x:AccountPassword, x:AppPassword, x:ApiKey, x:AccountSettings,
// x:Account). The webmail proxies these through a server-side passthrough so
// the basic-auth header stays in an httpOnly cookie; the mobile client already
// holds its own credentials, so it calls the JMAP endpoint directly with the
// `urn:stalwart:jmap` capability in `using`.
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import { generateAccountId } from '../lib/account-utils';
const STALWART_CAPABILITY = 'urn:stalwart:jmap';
const STALWART_USING = [CAPABILITIES.CORE, STALWART_CAPABILITY];
export type EncryptionType = 'Disabled' | 'Aes128' | 'Aes256';
export interface AppCredentialInfo {
id: string;
description: string;
createdAt: string | null;
expiresAt: string | null;
allowedIps: string[];
}
export type AppPasswordInfo = AppCredentialInfo;
export type ApiKeyInfo = AppCredentialInfo;
export interface AppCredentialInput {
description: string;
expiresAt?: string | null;
allowedIps?: string[];
}
export interface AuthInfo {
otpEnabled: boolean;
appPasswords: AppPasswordInfo[];
apiKeys: ApiKeyInfo[];
}
export interface CryptoInfo {
type: EncryptionType;
publicKeyId: string | null;
encryptOnAppend: boolean;
allowSpamTraining: boolean;
}
export interface PublicKeyInfo {
id: string;
description: string;
emailAddresses: string[];
createdAt: string | null;
expiresAt: string | null;
// Key material (PEM / armored). Only returned by some server versions.
key?: string;
}
export interface PublicKeyInput {
description: string;
key: string;
emailAddresses?: string[];
expiresAt?: string | null;
}
export interface PrincipalInfo {
displayName: string;
emails: string[];
quota: number;
roles: string[];
}
type MethodResponse = [string, Record<string, any>, string];
// True once a live session advertises Stalwart's JMAP extension. Without it
// none of the x:* methods exist, so the screen shows a "not available" notice.
// Stalwart lists `urn:stalwart:jmap` only under the account's
// `accountCapabilities` (verified on 0.16.19), never at session level, so the
// account-level map is what has to be checked (native #47).
export function isStalwartSupported(): boolean {
const session = jmapClient.currentSession;
if (!session || !jmapClient.isConnected) return false;
return jmapClient.hasAccountCapability(STALWART_CAPABILITY);
}
/**
* The server refused an x:* method to this sign-in. Stalwart 1.0 gives the
* account's own settings (password, two-factor, app passwords, API keys,
* encryption) only to first-party clients; a token from the client the app
* registers itself reaches mail, contacts, calendars and files, and every
* x:* call answers `forbidden`. Basic auth and 0.16 tokens are not limited.
*/
export class AccountSecurityForbiddenError extends Error {
constructor(message: string) {
super(message);
this.name = 'AccountSecurityForbiddenError';
}
}
/**
* Signed in to Stalwart 1.0 through the client the app registered itself,
* whose tokens reach no x:* object: the session then lists no Stalwart
* extension either (checked on 1.0.0), which is not the same as a server
* without it. Stalwart's registered client ids are `swc1.` sealed ids.
*/
export function isLimitedStalwartSignIn(): boolean {
const clientId = jmapClient.oauthClientId;
return !!clientId && clientId.startsWith('swc1.') && !isStalwartSupported() && !!jmapClient.currentSession;
}
/**
* Did the user sign in with the account's Stalwart password? Basic auth, or
* the password + TOTP sign-in, which ends in tokens (Stalwart takes no TOTP
* over Basic auth). Only then are a password change and turning TOTP off
* the user's to make here; a browser or webmail sign-in may have gone
* through another identity provider.
*/
export async function signedInWithPassword(): Promise<boolean> {
if (!jmapClient.usesBearerAuth) return true;
const username = jmapClient.username;
const serverUrl = jmapClient.serverUrl;
if (!username || !serverUrl) return false;
const tokens = await jmapClient.getStoredOAuthTokens(generateAccountId(username, serverUrl)).catch(() => null);
return tokens?.source === 'totp';
}
// Pull a single method response out by its call id and throw on a JMAP method
// error (the `['error', {type, description}, id]` envelope).
function resultFor<T = Record<string, any>>(responses: MethodResponse[], callId: string): T {
const match = responses.find((r) => r[2] === callId);
if (!match) throw new Error(`Missing JMAP response for call ${callId}`);
if (match[0] === 'error') {
const err = match[1] as { type?: string; description?: string };
const message = err.description || err.type || 'JMAP error';
if (err.type === 'forbidden') throw new AccountSecurityForbiddenError(message);
throw new Error(message);
}
return match[1] as T;
}
async function send(methodCalls: [string, Record<string, unknown>, string][]): Promise<MethodResponse[]> {
const res = await jmapClient.request(methodCalls, STALWART_USING);
return res.methodResponses as MethodResponse[];
}
function credentialFromResult(raw: Record<string, unknown>): AppCredentialInfo {
const allowedIps =
raw.allowedIps && typeof raw.allowedIps === 'object'
? Object.keys(raw.allowedIps as Record<string, unknown>)
: [];
return {
id: String(raw.id ?? ''),
description: typeof raw.description === 'string' ? raw.description : '',
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : null,
expiresAt: typeof raw.expiresAt === 'string' ? raw.expiresAt : null,
allowedIps,
};
}
function ipsToMap(ips?: string[]): Record<string, true> | undefined {
if (!ips || ips.length === 0) return undefined;
return Object.fromEntries(ips.map((ip) => [ip, true]));
}
function buildCreateBody(input: AppCredentialInput): Record<string, unknown> {
const body: Record<string, unknown> = { description: input.description };
if (input.expiresAt) body.expiresAt = input.expiresAt;
const allowed = ipsToMap(input.allowedIps);
if (allowed) body.allowedIps = allowed;
return body;
}
function extractEncryptionType(raw: unknown): EncryptionType {
if (!raw || typeof raw !== 'object') return 'Disabled';
const type = (raw as { ['@type']?: string })['@type'];
if (type === 'Aes128' || type === 'Aes256') return type;
return 'Disabled';
}
// ── Reads ─────────────────────────────────────────────────
export async function fetchAuthInfo(): Promise<AuthInfo> {
const accountId = jmapClient.accountId;
const responses = await send([
['x:AccountPassword/get', { accountId, ids: ['singleton'] }, 'p'],
['x:AppPassword/query', { accountId }, 'aq'],
['x:ApiKey/query', { accountId }, 'kq'],
]);
const passwordResult = resultFor<{
list?: Array<{ otpAuth?: { otpUrl?: string | null }; otpUrl?: string | null }>;
}>(responses, 'p');
const appPwQuery = resultFor<{ ids?: string[] }>(responses, 'aq');
const apiKeyQuery = resultFor<{ ids?: string[] }>(responses, 'kq');
// x:AccountPassword nests the TOTP URL in `otpAuth` on 0.16 and 1.0. 1.0's
// PasswordCredential (the admin's view of the same secret) has it as a
// top-level `otpUrl` instead, so take either.
const entry = passwordResult.list?.[0];
const otpAuth = entry?.otpAuth;
const otpEnabled = !!(otpAuth && typeof otpAuth === 'object' && otpAuth.otpUrl)
|| (typeof entry?.otpUrl === 'string' && !!entry.otpUrl);
const followUps: [string, Record<string, unknown>, string][] = [];
if (appPwQuery.ids?.length) {
followUps.push(['x:AppPassword/get', { accountId, ids: appPwQuery.ids }, 'app']);
}
if (apiKeyQuery.ids?.length) {
followUps.push(['x:ApiKey/get', { accountId, ids: apiKeyQuery.ids }, 'key']);
}
let appPasswords: AppPasswordInfo[] = [];
let apiKeys: ApiKeyInfo[] = [];
if (followUps.length) {
const followUpResponses = await send(followUps);
if (appPwQuery.ids?.length) {
const r = resultFor<{ list?: Array<Record<string, unknown>> }>(followUpResponses, 'app');
appPasswords = (r.list ?? []).map(credentialFromResult);
}
if (apiKeyQuery.ids?.length) {
const r = resultFor<{ list?: Array<Record<string, unknown>> }>(followUpResponses, 'key');
apiKeys = (r.list ?? []).map(credentialFromResult);
}
}
return { otpEnabled, appPasswords, apiKeys };
}
export async function fetchEncryptionType(): Promise<EncryptionType> {
return (await fetchCryptoInfo()).type;
}
export async function fetchCryptoInfo(): Promise<CryptoInfo> {
const accountId = jmapClient.accountId;
const responses = await send([['x:AccountSettings/get', { accountId, ids: ['singleton'] }, '0']]);
const result = resultFor<{ list?: Array<{ encryptionAtRest?: unknown }> }>(responses, '0');
const raw = (result.list?.[0]?.encryptionAtRest ?? {}) as {
publicKey?: string | null;
encryptOnAppend?: boolean;
allowSpamTraining?: boolean;
};
return {
type: extractEncryptionType(result.list?.[0]?.encryptionAtRest),
publicKeyId: typeof raw.publicKey === 'string' ? raw.publicKey : null,
encryptOnAppend: raw.encryptOnAppend === true,
allowSpamTraining: raw.allowSpamTraining === true,
};
}
/**
* Configure Stalwart's encryption at rest (`x:AccountSettings/set`
* `encryptionAtRest`). `Aes128`/`Aes256` need a public key id from
* {@link fetchPublicKeys}; `encryptOnAppend` also encrypts mail the user
* files (imported or appended over IMAP), `allowSpamTraining` lets the
* classifier read a message before it is encrypted. Since Stalwart 1.0.1
* JMAP drafts (`$draft`) and the Sent copy of a JMAP submission are stored
* unencrypted, so they stay editable and go out readable; 0.16 and 1.0.0
* encrypted those too.
*/
export async function updateEncryptionAtRest(input: {
type: EncryptionType;
publicKeyId?: string | null;
encryptOnAppend?: boolean;
allowSpamTraining?: boolean;
}): Promise<void> {
const accountId = jmapClient.accountId;
let payload: Record<string, unknown>;
if (input.type === 'Disabled') {
payload = { '@type': 'Disabled' };
} else {
if (!input.publicKeyId) throw new Error('A public key is required to enable encryption');
payload = {
'@type': input.type,
publicKey: input.publicKeyId,
encryptOnAppend: input.encryptOnAppend ?? false,
allowSpamTraining: input.allowSpamTraining ?? false,
};
}
const responses = await send([
['x:AccountSettings/set', { accountId, update: { singleton: { encryptionAtRest: payload } } }, '0'],
]);
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notUpdated?.singleton;
if (failure) throw new Error(failure.description || failure.type || 'Failed to update encryption settings');
}
function publicKeyFromResult(raw: Record<string, unknown>): PublicKeyInfo {
const emails =
raw.emailAddresses && typeof raw.emailAddresses === 'object'
? Object.keys(raw.emailAddresses as Record<string, unknown>)
: [];
return {
id: String(raw.id ?? ''),
description: typeof raw.description === 'string' ? raw.description : '',
emailAddresses: emails,
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : null,
expiresAt: typeof raw.expiresAt === 'string' ? raw.expiresAt : null,
key: typeof raw.key === 'string' ? raw.key : undefined,
};
}
/** S/MIME certificates and PGP public keys stored on the server (`x:PublicKey`). */
export async function fetchPublicKeys(): Promise<PublicKeyInfo[]> {
const accountId = jmapClient.accountId;
const queryResponses = await send([['x:PublicKey/query', { accountId }, '0']]);
const query = resultFor<{ ids?: string[] }>(queryResponses, '0');
if (!query.ids?.length) return [];
const getResponses = await send([['x:PublicKey/get', { accountId, ids: query.ids }, '0']]);
const got = resultFor<{ list?: Array<Record<string, unknown>> }>(getResponses, '0');
return (got.list ?? []).map(publicKeyFromResult);
}
export async function createPublicKey(input: PublicKeyInput): Promise<string> {
const accountId = jmapClient.accountId;
const tmpId = 'new';
const payload: Record<string, unknown> = {
description: input.description,
key: input.key,
emailAddresses: ipsToMap(input.emailAddresses) ?? {},
};
if (input.expiresAt) payload.expiresAt = input.expiresAt;
const responses = await send([['x:PublicKey/set', { accountId, create: { [tmpId]: payload } }, '0']]);
const result = resultFor<{
created?: Record<string, { id: string }>;
notCreated?: Record<string, { type?: string; description?: string }>;
}>(responses, '0');
const notCreated = result.notCreated?.[tmpId];
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to add public key');
const created = result.created?.[tmpId];
if (!created?.id) throw new Error('Server did not return the created public key');
return created.id;
}
export async function removePublicKey(id: string): Promise<void> {
const accountId = jmapClient.accountId;
const responses = await send([['x:PublicKey/set', { accountId, destroy: [id] }, '0']]);
const result = resultFor<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notDestroyed?.[id];
if (failure) throw new Error(failure.description || failure.type || 'Failed to remove public key');
}
/**
* The account's "Full name": the `description` of `x:AccountSettings`, which
* every user can read (webmail #900). Null when it is not set.
*/
export async function fetchAccountDisplayName(): Promise<string | null> {
const accountId = jmapClient.accountId;
const responses = await send([['x:AccountSettings/get', { accountId, ids: ['singleton'] }, '0']]);
const result = resultFor<{ list?: Array<{ description?: string | null }> }>(responses, '0');
const description = result.list?.[0]?.description;
return typeof description === 'string' && description.trim() ? description.trim() : null;
}
// Stalwart only lets admins read their own principal with x:Account/get;
// everyone else is refused, every time. A refusal is remembered per account
// for the session, so the alias lookups don't keep asking.
const PRINCIPAL_REFUSALS = new Set(['forbidden', 'unknownMethod', 'accountNotSupportedByMethod']);
const principalRefused = new Set<string>();
function principalKey(accountId: string): string {
return `${jmapClient.serverUrl ?? ''}|${accountId}`;
}
/** Test hook. */
export function resetPrincipalRefusals(): void {
principalRefused.clear();
}
export async function fetchPrincipal(): Promise<PrincipalInfo> {
const accountId = jmapClient.accountId;
const key = principalKey(accountId);
if (principalRefused.has(key)) throw new Error('forbidden');
const responses = await send([['x:Account/get', { accountId, ids: [accountId] }, '0']]);
const refusal = responses.find((r) => r[2] === '0' && r[0] === 'error')?.[1] as { type?: string } | undefined;
if (refusal?.type && PRINCIPAL_REFUSALS.has(refusal.type)) principalRefused.add(key);
const result = resultFor<{
list?: Array<{
description?: string | null;
aliases?: Record<string, { name?: string; enabled?: boolean }>;
quotas?: { maxDiskQuota?: number };
roles?: { ['@type']?: string };
name?: string;
}>;
}>(responses, '0');
const acc = result.list?.[0];
const aliasAddresses = acc?.aliases
? Object.values(acc.aliases).flatMap((a) => (a && a.enabled !== false && a.name ? [a.name] : []))
: [];
const primaryEmail = acc?.name ? [acc.name] : [];
return {
displayName: acc?.description ?? '',
emails: [...primaryEmail, ...aliasAddresses],
quota: acc?.quotas?.maxDiskQuota ?? 0,
roles: acc?.roles?.['@type'] ? [acc.roles['@type']] : [],
};
}
// ── Mutations ─────────────────────────────────────────────
/**
* With TOTP on, Stalwart wants the current code for any password or OTP
* change ("Current OTP code is required to change the password or OTP
* auth."; a wrong code reads "Current secret is incorrect."). Patch only
* `otpAuth/otpCode`: a whole `otpAuth` object would reset `otpUrl` and turn
* TOTP off. The property is `otpAuth` on 0.16 and 1.0 alike (checked on
* 0.16.27 and 1.0.0).
*/
function otpCodePatch(otpCode: string | undefined): Record<string, string> {
const code = otpCode?.trim();
return code ? { 'otpAuth/otpCode': code } : {};
}
/** `otpCode` is required once TOTP is on. */
export async function changePassword(currentPassword: string, newPassword: string, otpCode?: string): Promise<void> {
const accountId = jmapClient.accountId;
const responses = await send([
[
'x:AccountPassword/set',
{
accountId,
update: { singleton: { currentSecret: currentPassword, secret: newPassword, ...otpCodePatch(otpCode) } },
},
'0',
],
]);
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notUpdated?.singleton;
if (failure) throw new Error(failure.description || failure.type || 'Failed to change password');
// The stored credential must follow, otherwise the next request 401s and
// the next launch evicts the account.
await jmapClient.updatePassword(newPassword);
}
export async function updateDisplayName(displayName: string): Promise<void> {
const accountId = jmapClient.accountId;
const responses = await send([
['x:AccountSettings/set', { accountId, update: { singleton: { description: displayName } } }, '0'],
]);
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notUpdated?.singleton;
if (failure) throw new Error(failure.description || failure.type || 'Failed to update display name');
}
export async function enableTotp(currentPassword: string, otpUrl: string, otpCode: string): Promise<void> {
const accountId = jmapClient.accountId;
const responses = await send([
[
'x:AccountPassword/set',
{ accountId, update: { singleton: { currentSecret: currentPassword, otpAuth: { otpUrl, otpCode } } } },
'0',
],
]);
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notUpdated?.singleton;
if (failure) throw new Error(failure.description || failure.type || 'Failed to enable two-factor authentication');
}
/** Stalwart asks for a current code to turn TOTP off, like any OTP change. */
export async function disableTotp(currentPassword: string, otpCode?: string): Promise<void> {
const accountId = jmapClient.accountId;
const code = otpCode?.trim();
const responses = await send([
[
'x:AccountPassword/set',
{
accountId,
update: { singleton: { currentSecret: currentPassword, otpAuth: { otpUrl: null, ...(code ? { otpCode: code } : {}) } } },
},
'0',
],
]);
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notUpdated?.singleton;
if (failure) throw new Error(failure.description || failure.type || 'Failed to disable two-factor authentication');
}
type SetMethod = 'x:AppPassword/set' | 'x:ApiKey/set';
async function createCredential(method: SetMethod, input: AppCredentialInput): Promise<{ id: string; secret: string }> {
const accountId = jmapClient.accountId;
const tmpId = 'new';
const responses = await send([[method, { accountId, create: { [tmpId]: buildCreateBody(input) } }, '0']]);
const result = resultFor<{
created?: Record<string, { id: string; secret: string }>;
notCreated?: Record<string, { type?: string; description?: string }>;
}>(responses, '0');
const notCreated = result.notCreated?.[tmpId];
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to create credential');
const created = result.created?.[tmpId];
if (!created?.id || !created.secret) throw new Error('Server did not return the created credential');
return { id: created.id, secret: created.secret };
}
async function removeCredential(method: SetMethod, id: string): Promise<void> {
const accountId = jmapClient.accountId;
const responses = await send([[method, { accountId, destroy: [id] }, '0']]);
const result = resultFor<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notDestroyed?.[id];
if (failure) throw new Error(failure.description || failure.type || 'Failed to remove credential');
}
export function createAppPassword(input: AppCredentialInput): Promise<{ id: string; secret: string }> {
return createCredential('x:AppPassword/set', input);
}
export function removeAppPassword(id: string): Promise<void> {
return removeCredential('x:AppPassword/set', id);
}
export function createApiKey(input: AppCredentialInput): Promise<{ id: string; secret: string }> {
return createCredential('x:ApiKey/set', input);
}
export function removeApiKey(id: string): Promise<void> {
return removeCredential('x:ApiKey/set', id);
}