From 69bedf0b26aa3f8567c02fc95f63753d9999ee82 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:03:23 +0200 Subject: [PATCH] feat: manage public keys and encryption at rest through the Stalwart API --- src/api/account-security.ts | 129 +++++++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 1 deletion(-) diff --git a/src/api/account-security.ts b/src/api/account-security.ts index 52a4db71..01e3bb01 100644 --- a/src/api/account-security.ts +++ b/src/api/account-security.ts @@ -37,6 +37,30 @@ export interface AuthInfo { 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[]; @@ -151,10 +175,113 @@ export async function fetchAuthInfo(): Promise { } export async function fetchEncryptionType(): Promise { + return (await fetchCryptoInfo()).type; +} + +export async function fetchCryptoInfo(): Promise { const accountId = jmapClient.accountId; const responses = await send([['x:AccountSettings/get', { accountId, ids: ['singleton'] }, '0']]); const result = resultFor<{ list?: Array<{ encryptionAtRest?: unknown }> }>(responses, '0'); - return extractEncryptionType(result.list?.[0]?.encryptionAtRest); + 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 (sent/imported), `allowSpamTraining` lets the classifier read a + * message before it is encrypted. + */ +export async function updateEncryptionAtRest(input: { + type: EncryptionType; + publicKeyId?: string | null; + encryptOnAppend?: boolean; + allowSpamTraining?: boolean; +}): Promise { + const accountId = jmapClient.accountId; + let payload: Record; + 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 }>(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): PublicKeyInfo { + const emails = + raw.emailAddresses && typeof raw.emailAddresses === 'object' + ? Object.keys(raw.emailAddresses as Record) + : []; + 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 { + 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> }>(getResponses, '0'); + return (got.list ?? []).map(publicKeyFromResult); +} + +export async function createPublicKey(input: PublicKeyInput): Promise { + const accountId = jmapClient.accountId; + const tmpId = 'new'; + const payload: Record = { + 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; + notCreated?: Record; + }>(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 { + const accountId = jmapClient.accountId; + const responses = await send([['x:PublicKey/set', { accountId, destroy: [id] }, '0']]); + const result = resultFor<{ notDestroyed?: Record }>(responses, '0'); + const failure = result.notDestroyed?.[id]; + if (failure) throw new Error(failure.description || failure.type || 'Failed to remove public key'); } export async function fetchPrincipal(): Promise {