@rapidmx/web-client 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +381 -381
  2. package/apps/admin/branding/index.tsx +39 -39
  3. package/apps/admin/data-requests/index.tsx +482 -482
  4. package/apps/admin/domains/[uid].tsx +166 -166
  5. package/apps/admin/escrow-scopes/[uid].tsx +350 -350
  6. package/apps/admin/index.tsx +129 -129
  7. package/apps/admin/mailboxes/[uid].tsx +271 -271
  8. package/apps/admin/mailboxes/new/index.tsx +30 -30
  9. package/apps/admin/plugins/index.tsx +15 -15
  10. package/apps/admin/retention-policy/index.tsx +39 -39
  11. package/apps/admin/signing-certificates/index.tsx +343 -343
  12. package/apps/escrow/audit-log/index.tsx +196 -196
  13. package/apps/escrow/matters/[uid].tsx +621 -621
  14. package/apps/shared/auth/adminAccess.ts +99 -99
  15. package/apps/shared/components/admin/layout/AdminShell.tsx +384 -384
  16. package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -238
  17. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
  18. package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -194
  19. package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
  20. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
  21. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
  22. package/apps/shared/components/admin/settings/PluginsManager.tsx +2093 -2093
  23. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
  24. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
  25. package/apps/shared/components/admin/setup/SetupWizard.tsx +446 -446
  26. package/apps/shared/components/admin/usePagedList.tsx +129 -129
  27. package/apps/shared/components/calendar/EventModal.tsx +206 -206
  28. package/apps/shared/components/calendar/MonthView.tsx +185 -185
  29. package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -227
  30. package/apps/shared/components/calendar/SplitDayView.tsx +144 -144
  31. package/apps/shared/components/calendar/TimeGridView.tsx +246 -246
  32. package/apps/shared/components/calendar/allDay.ts +124 -124
  33. package/apps/shared/components/contacts/ContactForm.tsx +383 -383
  34. package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -103
  35. package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
  36. package/apps/shared/components/layout/AppShell.tsx +463 -461
  37. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
  38. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
  39. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
  40. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
  41. package/apps/shared/components/layout/UserMenu.tsx +439 -439
  42. package/apps/shared/components/mail/ConversationList.tsx +332 -332
  43. package/apps/shared/components/mail/ConversationThreadPane.tsx +613 -613
  44. package/apps/shared/components/mail/MailSelectionBar.tsx +240 -240
  45. package/apps/shared/components/mail/MenuButton.tsx +404 -404
  46. package/apps/shared/components/mail/MessageDetailPane.tsx +1757 -1757
  47. package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
  48. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
  49. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1705 -1681
  50. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
  51. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
  52. package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
  53. package/apps/shared/components/mail/reading/MessageMoreMenu.tsx +258 -258
  54. package/apps/shared/components/mail/reading/MessageSourceDialog.tsx +79 -79
  55. package/apps/shared/components/mail/reading/messageExport.ts +59 -59
  56. package/apps/shared/components/mail/reading/printMessage.ts +99 -99
  57. package/apps/shared/components/mail/reading/useMessageActions.ts +443 -443
  58. package/apps/shared/components/mail/verificationSeals.ts +125 -125
  59. package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
  60. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
  61. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
  62. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
  63. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
  64. package/apps/shared/keyboard/dispatch.ts +124 -124
  65. package/apps/shared/keyboard/format.ts +89 -89
  66. package/apps/shared/keyboard/keymap.ts +114 -114
  67. package/apps/shared/keyboard/registry.ts +65 -65
  68. package/apps/shared/keyboard/targets.ts +79 -79
  69. package/apps/shared/mail/folderOfType.ts +49 -49
  70. package/apps/shared/mail/folderTree.ts +143 -143
  71. package/apps/shared/mail/listAllPages.ts +39 -39
  72. package/apps/shared/mail/newMailNotifications.ts +171 -171
  73. package/apps/shared/mail/outbox/sendJob.ts +445 -445
  74. package/apps/shared/mail/outbox/sendOutcomes.ts +155 -155
  75. package/apps/shared/mail/reportNotices.ts +66 -66
  76. package/apps/shared/mail/senderBlocking.ts +141 -141
  77. package/apps/shared/mail/useMailConnection.ts +205 -205
  78. package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
  79. package/apps/shared/mail/useMailboxUpdateAccess.ts +60 -60
  80. package/apps/shared/mail/useMarkMessageRead.ts +47 -47
  81. package/apps/shared/mail/useNewMailNotifications.ts +178 -178
  82. package/apps/shared/notifications/store.ts +560 -560
  83. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
  84. package/apps/shared/search/localIndexBuilder.ts +481 -481
  85. package/apps/shared/signing/enrollmentStorage.ts +33 -33
  86. package/apps/shared/signing/enrollmentTracker.ts +385 -385
  87. package/apps/shared/signing/enrollmentView.ts +251 -251
  88. package/apps/shared/signing/useNow.ts +19 -19
  89. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
  90. package/apps/shared/styles/app.css +396 -396
  91. package/apps/www/calendar/index.tsx +581 -581
  92. package/apps/www/contacts/[uid].tsx +112 -112
  93. package/apps/www/index.tsx +3012 -3012
  94. package/apps/www/messages/[uid].tsx +139 -139
  95. package/apps/www/settings/auto-reply/index.tsx +136 -136
  96. package/apps/www/settings/blocked-senders/index.tsx +303 -303
  97. package/apps/www/settings/encryption/index.tsx +1290 -1290
  98. package/apps/www/settings/filters/[uid].tsx +179 -179
  99. package/apps/www/settings/filters/index.tsx +105 -105
  100. package/apps/www/settings/filters/new/index.tsx +165 -165
  101. package/apps/www/settings/labels/index.tsx +207 -207
  102. package/apps/www/settings/privacy/index.tsx +495 -495
  103. package/apps/www/settings/profile/index.tsx +251 -251
  104. package/apps/www/settings/read-receipts/index.tsx +150 -150
  105. package/apps/www/settings/sharing/index.tsx +259 -259
  106. package/apps/www/settings/signatures/[uid].tsx +175 -175
  107. package/apps/www/settings/signatures/index.tsx +91 -91
  108. package/apps/www/settings/signatures/new/index.tsx +138 -138
  109. package/apps/www/tasks/index.tsx +654 -654
  110. package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -2
  111. package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
  112. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +2 -2
  113. package/dist/apps/shared/components/layout/AppShell.js +4 -2
  114. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +7 -1
  115. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +20 -1
  116. package/dist/apps/shared/components/mail/reading/printMessage.js +11 -11
  117. package/dist/apps/shared/styles/app.css +396 -396
  118. package/package.json +2 -2
@@ -1,1290 +1,1290 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import { pageTitle } from "../../../shared/navigation/pageTitle.js";
6
- import React, { FormEvent, useEffect, useState } from "react";
7
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import {
9
- KeyVault,
10
- MasterKeyWrap,
11
- PublicKey,
12
- WrappedPrivateKey,
13
- addMasterKeyWrap,
14
- cancelSignEnrollment,
15
- findActivePublicKey,
16
- getCurrentSignEnrollment,
17
- getEscrowInfo,
18
- getKeyVault,
19
- rekey,
20
- removeMasterKeyWrap,
21
- startSignEnrollment,
22
- } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
23
- import {
24
- ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE,
25
- SIGNING_PRIVATE_KEY_AAD_PURPOSE,
26
- UnlockedKeys,
27
- destroyUnlockedKeys,
28
- getUnlockedKeys,
29
- unlockWithPassword,
30
- } from "@rapidmx/react-shared/crypto/keySession.js";
31
- import { IDLE_TIMEOUT_OPTIONS_MINUTES, getIdleTimeoutMinutes, setIdleTimeoutMinutes } from "@rapidmx/react-shared/crypto/idleTimeout.js";
32
- import {
33
- LOCAL_INDEX_SIZE_OPTIONS,
34
- getDefaultLocalIndexByteBudget,
35
- getLocalIndexByteBudget,
36
- setLocalIndexByteBudget,
37
- } from "../../../shared/search/localIndexSizePreference.js";
38
- import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
39
- import { KeysLockedError, buildAad, generateMasterKey, openWithKey, sealWithKey } from "@rapidmx/react-shared/crypto/masterKey.js";
40
- import { buildEscrowWrap, buildPasswordWrap, buildRecoveryWraps } from "@rapidmx/react-shared/crypto/masterKeyWraps.js";
41
- import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "@rapidmx/react-shared/crypto/keys.js";
42
- import { getMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
43
- import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
44
- import KeyEnrollmentGate from "../../../shared/components/layout/KeyEnrollmentGate.js";
45
- import { useUnlockPrompt } from "../../../shared/components/layout/UnlockPromptProvider.js";
46
- import { destroyLocalIndex } from "../../../shared/search/localIndexRpcClient.js";
47
- import { notifyApiError } from "../../../shared/notifications/apiErrors.js";
48
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
49
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
50
- import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
51
- import SigningCertificateCard, { SigningCertificateMode } from "../../../shared/components/settings/SigningCertificateCard.js";
52
- import { readStoredSignEnrollment, storeSignEnrollment } from "../../../shared/signing/enrollmentStorage.js";
53
- import {
54
- checkEnrollmentNow,
55
- forgetEnrollment,
56
- recordEnrollmentResult,
57
- useEnrollmentSnapshot,
58
- watchCurrentEnrollment,
59
- watchEnrollment,
60
- } from "../../../shared/signing/enrollmentTracker.js";
61
- import { STALE_REQUEST_TEXT, beforeRequestText, healthWarning, isInstalling, timeOf } from "../../../shared/signing/enrollmentView.js";
62
- import { useSigningEnrollmentInfo } from "../../../shared/signing/signingInfo.js";
63
-
64
- const MIN_PASSWORD_LENGTH = 8;
65
-
66
- /** Mirrors restapi's `BaseKeyVaultRoute` `MAX_MASTER_KEY_WRAPS` - `addMasterKeyWrap()` refuses a vault
67
- * already holding this many wraps (escrow included). */
68
- export const MAX_MASTER_KEY_WRAPS = 20;
69
-
70
- /** How often, and how many times, the page asks for the mailbox's keys while a certificate is issued but not installed yet. */
71
- const INSTALL_WATCH_INTERVAL_MS = 15_000;
72
- const INSTALL_WATCH_ATTEMPTS = 40;
73
-
74
- const KEYS_LOCKED_MESSAGE = "Your encryption keys were locked before this could finish. Unlock them and try again.";
75
-
76
- /** Thrown inside `handleRotateKeys()` when the escrow wrap of the new master key can't be built - the rotation is
77
- * then abandoned before `rekey()`. */
78
- class EscrowWrapUnavailableError extends Error {
79
- constructor(readonly cause: unknown) {
80
- super("Could not prepare escrow protection for the new master key.");
81
- }
82
- }
83
-
84
- /** Thrown by `rewrapVaultPrivateKeys()` when a vault entry can't be opened with the current master key. */
85
- class UncoveredVaultKeysError extends Error {
86
- constructor(readonly fingerprints: string[]) {
87
- super(`Could not open ${fingerprints.length} wrapped private key(s).`);
88
- }
89
- }
90
-
91
- /** Thrown when this session's master key no longer opens the vault - another device rotated the keys since this
92
- * session unlocked. Anything wrapped or sealed under it would be unusable (and a signing key sealed under it would
93
- * be installed at the vault's current generation, blocking rotation), so nothing is written. */
94
- class StaleSessionKeysError extends Error {
95
- constructor() {
96
- super("This session's master key no longer opens the key vault.");
97
- }
98
- }
99
-
100
- const STALE_KEYS_MESSAGE =
101
- "Your encryption keys were changed on another device (for example, rotated), so the copy unlocked in this session no longer works. Nothing was changed. Unlock again with your current password, then try again.";
102
-
103
- function wrappedKeyAad(mailboxUid: string, entry: WrappedPrivateKey): Uint8Array {
104
- return buildAad(mailboxUid, entry.useType === "sign" ? SIGNING_PRIVATE_KEY_AAD_PURPOSE : ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE);
105
- }
106
-
107
- /**
108
- * Whether `masterKey` opens at least one of `vault`'s wrapped private keys - i.e. it is still the vault's current
109
- * master key. A vault with no wrapped keys has nothing to check against and passes. The opened key bytes are zeroed
110
- * straight away. `KeysLockedError` (a destroyed master key) propagates.
111
- */
112
- async function masterKeyOpensVault(mailboxUid: string, masterKey: Uint8Array, vault: KeyVault): Promise<boolean> {
113
- if (vault.wrappedKeys.length === 0) {
114
- return true;
115
- }
116
- for (const entry of vault.wrappedKeys) {
117
- try {
118
- const raw = await openWithKey(masterKey, entry, wrappedKeyAad(mailboxUid, entry));
119
- raw.fill(0);
120
- return true;
121
- } catch (err) {
122
- if (err instanceof KeysLockedError) {
123
- throw err;
124
- }
125
- }
126
- }
127
- return false;
128
- }
129
-
130
- /**
131
- * Re-seals EVERY private key in the vault (not just the ones this session imported - an inactive or
132
- * newly issued key would otherwise be silently dropped by `rekey()`, which replaces `wrappedKeys`
133
- * wholesale) under a brand new master key. Opens each entry with `currentMasterKey` first and refuses
134
- * (`UncoveredVaultKeysError`) if any can't be opened, before generating anything. Plaintext key bytes
135
- * are zeroed as soon as they've been re-sealed. `KeysLockedError` (a destroyed master key) propagates.
136
- */
137
- async function rewrapVaultPrivateKeys(
138
- mailboxUid: string,
139
- currentMasterKey: Uint8Array,
140
- wrappedKeys: WrappedPrivateKey[],
141
- ): Promise<{ mk: Uint8Array; wrappedKeys: WrappedPrivateKey[] }> {
142
- const aadFor = (entry: WrappedPrivateKey) => wrappedKeyAad(mailboxUid, entry);
143
- const opened: { entry: WrappedPrivateKey; raw: Uint8Array }[] = [];
144
- try {
145
- const failed: string[] = [];
146
- for (const entry of wrappedKeys) {
147
- try {
148
- opened.push({ entry, raw: await openWithKey(currentMasterKey, entry, aadFor(entry)) });
149
- } catch (err) {
150
- if (err instanceof KeysLockedError) {
151
- throw err;
152
- }
153
- failed.push(entry.fingerprint);
154
- }
155
- }
156
- if (failed.length > 0) {
157
- throw new UncoveredVaultKeysError(failed);
158
- }
159
- const mk = generateMasterKey();
160
- const rewrapped: WrappedPrivateKey[] = [];
161
- for (const { entry, raw } of opened) {
162
- const sealed = await sealWithKey(mk, raw, aadFor(entry));
163
- rewrapped.push({
164
- ciphertext: sealed.ciphertext,
165
- nonce: sealed.nonce,
166
- algorithm: "AES-256-GCM",
167
- fingerprint: entry.fingerprint,
168
- useType: entry.useType,
169
- });
170
- }
171
- return { mk, wrappedKeys: rewrapped };
172
- } finally {
173
- for (const { raw } of opened) {
174
- raw.fill(0);
175
- }
176
- }
177
- }
178
-
179
- const ROTATION_CONFLICT_MESSAGE =
180
- "Your keys were not rotated because the server reported a conflicting change. If a signing certificate enrollment is still in progress for this mailbox (it may have been started on another device), wait for it to finish, then try again. If this mailbox is under escrow, its escrow protection may have changed meanwhile - reload this page and try again. Nothing was changed.";
181
-
182
- const METHOD_LABELS: Record<string, string> = {
183
- password: "Password",
184
- passkey: "Passkey",
185
- recovery: "Recovery code",
186
- escrow: "Escrow (managed by your organization)",
187
- };
188
-
189
- function idleTimeoutLabel(minutes: number): string {
190
- if (minutes === 0) {
191
- return "Never";
192
- }
193
- if (minutes === 60) {
194
- return "1 hour";
195
- }
196
- return `${minutes} minutes`;
197
- }
198
-
199
- export type SettingsEncryptionPageProps = Omit<SettingsShellProps, "active">;
200
-
201
- function SettingsEncryptionPage(props: SettingsEncryptionPageProps) {
202
- return (
203
- <SettingsShell {...props} active="encryption">
204
- <EncryptionGate userUid={props.userUid} impersonating={props.impersonating} />
205
- </SettingsShell>
206
- );
207
- }
208
-
209
- /**
210
- * Unlike `MailShell`, `SettingsShell` doesn't already gate its children behind `KeyEnrollmentGate` (a
211
- * user can reach Settings without ever having opened Mail this session) - wrapped here instead, so
212
- * `EncryptionContent` below can always assume `getUnlockedKeys()` has something for this mailbox before
213
- * it renders. See `KeyEnrollmentGate`'s own doc comment for why it's safe to mount more than once (it
214
- * short-circuits to `children` immediately for a mailbox already unlocked elsewhere this session).
215
- */
216
- function EncryptionGate({ userUid, impersonating }: { userUid?: string; impersonating?: boolean }) {
217
- const { mailboxUid, mailboxes } = useSettingsShell();
218
- const mailbox = mailboxes.find((mb) => mb.uid === mailboxUid)!;
219
- // restapi only lets the mailbox's actual owner write its key vault (`requireMailboxOwner()`), so a
220
- // delegate (or an admin impersonating the owner) never provisions keys here, and sees no write actions.
221
- const isOwner = mailbox.ownerUserUid !== undefined && mailbox.ownerUserUid === userUid && !impersonating;
222
- return (
223
- <KeyEnrollmentGate
224
- mailboxUid={mailboxUid}
225
- mailboxAddress={mailbox.primarySmtpAddress}
226
- mailboxKeys={mailbox.keys}
227
- canProvision={isOwner}
228
- >
229
- <EncryptionContent canManageKeys={isOwner} />
230
- </KeyEnrollmentGate>
231
- );
232
- }
233
-
234
- function EncryptionContent({ canManageKeys }: { canManageKeys: boolean }) {
235
- const { mailboxUid, mailboxes } = useSettingsShell();
236
- const mailbox = mailboxes.find((mb) => mb.uid === mailboxUid)!;
237
- const { requestUnlock } = useUnlockPrompt();
238
-
239
- const [vault, setVault] = useState<KeyVault | null>(null);
240
- const [loadError, setLoadError] = useState<string | null>(null);
241
- const [removingMethod, setRemovingMethod] = useState<string | null>(null);
242
- const [pendingRemoval, setPendingRemoval] = useState<MasterKeyWrap | null>(null);
243
- const [actionError, setActionError] = useState<string | null>(null);
244
-
245
- const [newPassword, setNewPassword] = useState("");
246
- const [confirmNewPassword, setConfirmNewPassword] = useState("");
247
- const [addingPassword, setAddingPassword] = useState(false);
248
-
249
- const [regenerating, setRegenerating] = useState(false);
250
- const [newRecoveryCodes, setNewRecoveryCodes] = useState<string[] | null>(null);
251
- // Distinguishes the "Save your new recovery codes" screen's copy for the two different actions that
252
- // land on it - a plain regeneration only invalidates old recovery codes, while a rotation also
253
- // invalidates every other unlock method, which the copy needs to say plainly.
254
- const [recoveryCodesReason, setRecoveryCodesReason] = useState<"regenerate" | "rotate">("regenerate");
255
- const [codesSaved, setCodesSaved] = useState(false);
256
- const [codesCopied, setCodesCopied] = useState(false);
257
- // Shown on the "Save your new recovery codes" screen itself - a regeneration or rotation that only
258
- // partly finished (e.g. some new codes saved but not all, or old codes that couldn't be removed) still
259
- // lands there, since whatever codes did save must be shown now or never.
260
- const [codesWarning, setCodesWarning] = useState<string | null>(null);
261
- // Set when a rotation succeeded but this session couldn't re-unlock under the new master key - the
262
- // stale keys are destroyed, and the "keys removed" screen explains why.
263
- const [rotationRelockReason, setRotationRelockReason] = useState<string | null>(null);
264
-
265
- const [rotationPassword, setRotationPassword] = useState("");
266
- const [rotationConfirmPassword, setRotationConfirmPassword] = useState("");
267
- const [rotating, setRotating] = useState(false);
268
-
269
- const [destroyed, setDestroyed] = useState(false);
270
- const [idleTimeoutMinutes, setIdleTimeoutMinutesState] = useState(() => getIdleTimeoutMinutes());
271
- const [localIndexByteBudget, setLocalIndexByteBudgetState] = useState(() => getLocalIndexByteBudget());
272
- // Both possible defaults (web and Electron) are themselves entries in `LOCAL_INDEX_SIZE_OPTIONS`.
273
- const localIndexDefaultByteBudgetLabel = LOCAL_INDEX_SIZE_OPTIONS.find((option) => option.bytes === getDefaultLocalIndexByteBudget())!.label;
274
-
275
- // `mailbox.keys` comes from `SettingsShell`'s one-time `listMailboxes()` fetch - once an ACME
276
- // enrollment issues, the server has installed a new signing key that fetch never saw. Only this
277
- // page refetches (via `getMailbox()`) to notice; `null` means "no fresher data yet, use mailbox.keys".
278
- const [refreshedKeys, setRefreshedKeys] = useState<PublicKey[] | null>(null);
279
- const displayedKeys = refreshedKeys ?? mailbox.keys ?? [];
280
- const activeSigningKey = findActivePublicKey(displayedKeys, "sign");
281
-
282
- // "checking" = a stored enrollment id is being confirmed with the server. Rotation stays disabled unless
283
- // this is "idle".
284
- const [signingStatus, setSigningStatus] = useState<"checking" | "idle" | "enrolling" | "pending">(() =>
285
- readStoredSignEnrollment(mailboxUid!) ? "checking" : "idle",
286
- );
287
- const [signingEnrollmentId, setSigningEnrollmentId] = useState<string | null>(null);
288
- const [signingError, setSigningError] = useState<string | null>(null);
289
- const rotationBlockedBySigning = signingStatus !== "idle";
290
- // What is known of the mailbox's signing-certificate enrollment: kept current by the shared tracker (`signing/enrollmentTracker.ts`), which reads
291
- // the server with backoff while this page is visible, stops when the enrollment ends, and goes on in the app frame when this page is left.
292
- const enrollment = useEnrollmentSnapshot(mailboxUid);
293
- const enrollmentResult = enrollment?.result ?? null;
294
- // How this deployment issues certificates (a public CA by itself, or an administrator by hand): what the card and the request say follows it. Only the owner asks.
295
- const signingInfo = useSigningEnrollmentInfo(canManageKeys);
296
-
297
- const [cancelingEnrollment, setCancelingEnrollment] = useState(false);
298
- const [cancelEnrollmentError, setCancelEnrollmentError] = useState<string | null>(null);
299
-
300
- // restapi's rekey() drops every old escrow wrap and requires a fresh one for the new master key (see
301
- // handleRotateKeys), so an escrow wrap in the vault always covers the current master key.
302
- const hasEscrowWrap = vault?.masterKeyWraps.some((w) => w.method === "escrow") ?? false;
303
- const [wrappingEscrow, setWrappingEscrow] = useState(false);
304
- const [escrowError, setEscrowError] = useState<string | null>(null);
305
-
306
- /**
307
- * This mailbox's unlocked keys *right now* - never a copy read at render time, which a lock (idle
308
- * timeout, another tab's sign-out) may since have destroyed. Prompts for the password when locked;
309
- * rejects if the prompt is dismissed.
310
- */
311
- async function currentUnlockedKeys(): Promise<UnlockedKeys> {
312
- const current = getUnlockedKeys(mailboxUid!);
313
- if (current && !current.destroyed) {
314
- return current;
315
- }
316
- return requestUnlock(mailboxUid!, displayedKeys);
317
- }
318
-
319
- /**
320
- * `currentUnlockedKeys()`, checked against a freshly fetched vault before anything is wrapped or sealed under its
321
- * master key: after a rotation on another device the session's master key is dead, and every write built from
322
- * it would be too. Rejects with `StaleSessionKeysError` then. Resolves to the keys and that fresh vault.
323
- */
324
- async function verifiedUnlockedKeys(): Promise<{ current: UnlockedKeys; freshVault: KeyVault }> {
325
- const current = await currentUnlockedKeys();
326
- const freshVault = await getKeyVault(mailboxUid!);
327
- if (!(await masterKeyOpensVault(mailboxUid!, current.masterKey, freshVault))) {
328
- throw new StaleSessionKeysError();
329
- }
330
- return { current, freshVault };
331
- }
332
-
333
- /** Drops this session's out-of-date keys (and, as with every key destruction, the local index) and asks for the
334
- * current password, so the next attempt works with the vault's real master key. */
335
- function relockStaleKeys() {
336
- destroyUnlockedKeys(mailboxUid);
337
- void destroyLocalIndex(mailboxUid!);
338
- requestUnlock(mailboxUid!, displayedKeys).catch(() => undefined);
339
- }
340
-
341
- function errorMessage(err: unknown, fallback: string): string {
342
- if (err instanceof KeysLockedError) {
343
- return KEYS_LOCKED_MESSAGE;
344
- }
345
- if (err instanceof StaleSessionKeysError) {
346
- relockStaleKeys();
347
- return STALE_KEYS_MESSAGE;
348
- }
349
- return err instanceof ApiRequestError ? err.message : fallback;
350
- }
351
-
352
- async function handleWrapEscrow() {
353
- setEscrowError(null);
354
- setWrappingEscrow(true);
355
- try {
356
- // Only reachable when mailbox.escrowScopeId is set (see the render guard below).
357
- const { current } = await verifiedUnlockedKeys();
358
- const escrowInfo = await getEscrowInfo(mailboxUid!);
359
- const wrap = await buildEscrowWrap(current.masterKey, escrowInfo.escrowScopeId, fromBase64(escrowInfo.publicKey.publicKey));
360
- await addMasterKeyWrap(mailboxUid!, wrap);
361
- await loadVault();
362
- } catch (err) {
363
- setEscrowError(errorMessage(err, "Could not add escrow protection for this mailbox."));
364
- } finally {
365
- setWrappingEscrow(false);
366
- }
367
- }
368
-
369
- // Follows the enrollment this browser started (its stored id) - or, for the owner, the one the server names as the mailbox's current one (started
370
- // on another device, or long ago), so a pending one blocks rotation and shows its progress here too.
371
- useEffect(() => {
372
- const storedId = readStoredSignEnrollment(mailboxUid!);
373
- let cancelled = false;
374
- let release: (() => void) | undefined;
375
- if (storedId) {
376
- setSigningStatus("checking");
377
- release = watchEnrollment(mailboxUid!, storedId);
378
- } else if (canManageKeys) {
379
- getCurrentSignEnrollment(mailboxUid!)
380
- .then((current) => {
381
- if (!cancelled && current) {
382
- if (current.status === "pending") {
383
- storeSignEnrollment(mailboxUid!, current.enrollmentId);
384
- }
385
- release = watchCurrentEnrollment(mailboxUid!, current, current.status === "pending");
386
- }
387
- })
388
- .catch(() => undefined);
389
- }
390
- return () => {
391
- cancelled = true;
392
- release?.();
393
- };
394
- }, [mailboxUid, canManageKeys]);
395
-
396
- // What the tracker knows becomes this page's state: pending (or not heard from yet - assume it is still going, and let the polling keep asking) blocks
397
- // rotation; an enrollment that ended, or that the server no longer knows, does not.
398
- useEffect(() => {
399
- if (!enrollment) {
400
- return;
401
- }
402
- if (enrollmentResult?.status === "pending" || (!enrollmentResult && enrollment.offline)) {
403
- setSigningEnrollmentId(enrollment.enrollmentId);
404
- setSigningStatus("pending");
405
- } else if (enrollmentResult || enrollment.gone) {
406
- setSigningEnrollmentId(null);
407
- setSigningStatus("idle");
408
- }
409
- }, [enrollment?.enrollmentId, enrollmentResult?.status, enrollment?.gone, enrollment?.offline]);
410
-
411
- // Once a certificate is issued the server has installed a new signing key that `mailbox.keys` (fetched once by the shell) never saw. The server installs it
412
- // with a job a few minutes after the CA issues it (`installedAt`): until then the key is not there, so ask again every 15 s (for ten minutes at most).
413
- const issuedUntil = timeOf(enrollmentResult?.notAfter);
414
- const keyIsCurrent = !!activeSigningKey && (issuedUntil === undefined || issuedUntil <= activeSigningKey.notAfter + 60_000);
415
- useEffect(() => {
416
- if (enrollmentResult?.status !== "issued" || keyIsCurrent) {
417
- return;
418
- }
419
- let cancelled = false;
420
- let timer: ReturnType<typeof setTimeout> | undefined;
421
- let attempts = 0;
422
- const refresh = () => {
423
- getMailbox(mailboxUid!)
424
- .then((refreshed) => {
425
- if (cancelled) {
426
- return;
427
- }
428
- const keys = refreshed.keys ?? [];
429
- setRefreshedKeys(keys);
430
- // Not installed yet, and the server said it is on its way: look again.
431
- if (isInstalling(enrollmentResult) && ++attempts < INSTALL_WATCH_ATTEMPTS && !findActivePublicKey(keys, "sign")) {
432
- timer = setTimeout(refresh, INSTALL_WATCH_INTERVAL_MS);
433
- }
434
- })
435
- .catch(() => undefined);
436
- };
437
- refresh();
438
- return () => {
439
- cancelled = true;
440
- clearTimeout(timer);
441
- };
442
- }, [enrollmentResult?.status, enrollmentResult?.installedAt, enrollment?.enrollmentId, keyIsCurrent, mailboxUid]);
443
-
444
- /** Cancels the pending enrollment (restapi's owner-only DELETE), which is what lets rotation run again. */
445
- async function handleCancelEnrollment(enrollmentId: string) {
446
- setCancelEnrollmentError(null);
447
- setCancelingEnrollment(true);
448
- try {
449
- const result = await cancelSignEnrollment(mailboxUid!, enrollmentId);
450
- if (result.status === "pending") {
451
- setCancelEnrollmentError("The enrollment couldn't be cancelled yet. Try again.");
452
- } else if (result.status === "failed") {
453
- // Cancelled - the "failure" is the cancellation itself, not something to report.
454
- forgetEnrollment(mailboxUid!);
455
- storeSignEnrollment(mailboxUid!, null);
456
- setSigningStatus("idle");
457
- setSigningEnrollmentId(null);
458
- } else {
459
- // Issued before the cancel landed: it finishes like any other issued enrollment.
460
- recordEnrollmentResult(mailboxUid!, enrollmentId, result);
461
- }
462
- } catch (err) {
463
- if (err instanceof ApiRequestError && err.status === 404) {
464
- // The server no longer knows this enrollment, so nothing is pending.
465
- forgetEnrollment(mailboxUid!);
466
- storeSignEnrollment(mailboxUid!, null);
467
- setSigningStatus("idle");
468
- setSigningEnrollmentId(null);
469
- } else {
470
- setCancelEnrollmentError(errorMessage(err, "Could not cancel the signing certificate enrollment."));
471
- }
472
- } finally {
473
- setCancelingEnrollment(false);
474
- }
475
- }
476
-
477
- async function handleEnrollSigning() {
478
- setSigningError(null);
479
- setSigningStatus("enrolling");
480
- try {
481
- const { current } = await verifiedUnlockedKeys();
482
- const { keyPair, csrPem } = await generateKeyPairWithCsr(mailbox.primarySmtpAddress, "sign");
483
- const privateKeyRaw = await exportPrivateKeyPkcs8(keyPair.privateKey);
484
- const wrappedKeySealed = await sealWithKey(
485
- current.masterKey,
486
- privateKeyRaw,
487
- buildAad(mailboxUid!, SIGNING_PRIVATE_KEY_AAD_PURPOSE),
488
- );
489
- const { enrollmentId } = await startSignEnrollment(mailboxUid!, {
490
- csr: csrPem,
491
- wrappedKey: { ciphertext: wrappedKeySealed.ciphertext, nonce: wrappedKeySealed.nonce, algorithm: "AES-256-GCM" },
492
- });
493
- storeSignEnrollment(mailboxUid!, enrollmentId);
494
- // Followed from now on (and after this page is left): the server has just accepted it, so it is pending - no need to ask first.
495
- watchEnrollment(mailboxUid!, enrollmentId, { initial: { status: "pending" } });
496
- setSigningEnrollmentId(enrollmentId);
497
- setSigningStatus("pending");
498
- } catch (err) {
499
- setSigningError(errorMessage(err, "Could not start signing certificate enrollment."));
500
- setSigningStatus("idle");
501
- }
502
- }
503
-
504
- function handleIdleTimeoutChange(e: React.ChangeEvent<HTMLSelectElement>) {
505
- const minutes = Number(e.target.value);
506
- setIdleTimeoutMinutes(minutes);
507
- setIdleTimeoutMinutesState(minutes);
508
- }
509
-
510
- function handleLocalIndexByteBudgetChange(e: React.ChangeEvent<HTMLSelectElement>) {
511
- const bytes = Number(e.target.value);
512
- setLocalIndexByteBudget(bytes);
513
- setLocalIndexByteBudgetState(bytes);
514
- }
515
-
516
- function loadVault() {
517
- return getKeyVault(mailboxUid!)
518
- .then(setVault)
519
- .catch((err) => setLoadError(err instanceof ApiRequestError ? err.message : "Could not load your key vault."));
520
- }
521
-
522
- useEffect(() => {
523
- void loadVault();
524
- }, [mailboxUid]);
525
-
526
- const passwordWraps = vault?.masterKeyWraps.filter((w) => w.method === "password") ?? [];
527
- const recoveryWraps = vault?.masterKeyWraps.filter((w) => w.method === "recovery") ?? [];
528
- const otherWraps = vault?.masterKeyWraps.filter((w) => w.method !== "password" && w.method !== "recovery") ?? [];
529
-
530
- // The owner's own unlock methods are every non-escrow wrap - restapi refuses (409) to remove the last
531
- // one. The app unlocks with a password or a single-use recovery code (passkeys have no unlock path yet),
532
- // so the last password wrap is never offered for removal either: without it only a dwindling set of
533
- // recovery codes would be left. A forgotten password is replaced after a recovery-code unlock instead
534
- // (`RecoveryCodeUnlock.tsx`).
535
- const ownUnlockWrapCount = vault?.masterKeyWraps.filter((w) => w.method !== "escrow").length ?? 0;
536
- function canRemoveWrap(wrap: MasterKeyWrap): boolean {
537
- return wrap.method === "password" ? passwordWraps.length > 1 : ownUnlockWrapCount > 1;
538
- }
539
-
540
- async function handleRemove(wrap: MasterKeyWrap) {
541
- setPendingRemoval(null);
542
- const key = `${wrap.method}:${wrap.methodId ?? ""}`;
543
- setRemovingMethod(key);
544
- setActionError(null);
545
- try {
546
- await removeMasterKeyWrap(mailboxUid!, wrap.method, wrap.methodId);
547
- await loadVault();
548
- } catch (err) {
549
- // A pop-up (the confirmation is already closed); `actionError` keeps the guidance tied to the forms below.
550
- notifyApiError(err, "Couldn't remove this unlock method");
551
- } finally {
552
- setRemovingMethod(null);
553
- }
554
- }
555
-
556
- /** Only offered while the vault has no password wrap at all - `unlockWithPassword()` only ever tries
557
- * the first one, so a second password would be accepted here but never unlock anything. */
558
- async function handleAddPassword(e: FormEvent) {
559
- e.preventDefault();
560
- if (newPassword.length < MIN_PASSWORD_LENGTH) {
561
- setActionError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
562
- return;
563
- }
564
- if (newPassword !== confirmNewPassword) {
565
- setActionError("Passwords do not match.");
566
- return;
567
- }
568
- setActionError(null);
569
- setAddingPassword(true);
570
- try {
571
- const { current } = await verifiedUnlockedKeys();
572
- const wrap = await buildPasswordWrap(mailboxUid!, current.masterKey, newPassword);
573
- await addMasterKeyWrap(mailboxUid!, wrap);
574
- setNewPassword("");
575
- setConfirmNewPassword("");
576
- await loadVault();
577
- } catch (err) {
578
- setActionError(errorMessage(err, "Could not add this password."));
579
- } finally {
580
- setAddingPassword(false);
581
- }
582
- }
583
-
584
- /**
585
- * Adds the new recovery wraps *before* removing any old one, so a failure part-way through can never
586
- * leave the mailbox with fewer working recovery codes than it started with. `buildRecoveryWraps()`
587
- * labels its wraps `recovery-1..N` - the same labels the old set most likely has, and restapi's
588
- * `removeMasterKeyWrap()` removes every wrap matching a `methodId` - so each new wrap gets a
589
- * batch-unique label first, letting the old ones be removed by their own `methodId` without touching
590
- * the new ones.
591
- *
592
- * The vault holds at most `MAX_MASTER_KEY_WRAPS` wraps. Checked against a fresh copy of the vault before
593
- * anything is written: when the new set only fits once old recovery codes are gone, an old code is
594
- * removed just before each new one that has no room yet (the working-code count never drops below the
595
- * original set's), and when it can't fit even then, nothing is written and the user is told how many
596
- * other methods to remove.
597
- */
598
- async function handleRegenerateRecoveryCodes() {
599
- // Only reachable once the vault has loaded (the button is disabled until then).
600
- setActionError(null);
601
- setRegenerating(true);
602
- let built: { wraps: MasterKeyWrap[]; codes: string[] };
603
- let oldRecoveryWraps: MasterKeyWrap[];
604
- let free: number;
605
- try {
606
- const { current, freshVault } = await verifiedUnlockedKeys();
607
- oldRecoveryWraps = freshVault.masterKeyWraps.filter((w) => w.method === "recovery");
608
- free = MAX_MASTER_KEY_WRAPS - freshVault.masterKeyWraps.length;
609
- built = await buildRecoveryWraps(mailboxUid!, current.masterKey);
610
- const shortfall = built.wraps.length - (free + oldRecoveryWraps.length);
611
- if (shortfall > 0) {
612
- setVault(freshVault);
613
- setActionError(
614
- `Your key vault can hold at most ${MAX_MASTER_KEY_WRAPS} unlock methods and already has ${freshVault.masterKeyWraps.length}. ` +
615
- `Regenerating needs room for ${built.wraps.length} new recovery codes, so remove ${shortfall} other unlock ` +
616
- `method${shortfall === 1 ? "" : "s"} (such as an extra password or passkey) below first.`,
617
- );
618
- setRegenerating(false);
619
- return;
620
- }
621
- } catch (err) {
622
- setActionError(errorMessage(err, "Could not regenerate recovery codes."));
623
- setRegenerating(false);
624
- return;
625
- }
626
- const batch = Date.now().toString(36);
627
- const pendingOld = [...oldRecoveryWraps];
628
- let removedEarly = 0;
629
- const savedCodes: string[] = [];
630
- let addError: unknown = null;
631
- for (let i = 0; i < built.wraps.length; i++) {
632
- // The old wrap removed to make room for this new one, if any - put back if the add then fails, so a
633
- // failed add never costs a working code.
634
- let removedForThis: MasterKeyWrap | null = null;
635
- try {
636
- if (free <= 0) {
637
- // Guaranteed non-empty by the shortfall check above.
638
- const old = pendingOld.shift()!;
639
- await removeMasterKeyWrap(mailboxUid!, "recovery", old.methodId);
640
- removedForThis = old;
641
- removedEarly++;
642
- free++;
643
- }
644
- await addMasterKeyWrap(mailboxUid!, { ...built.wraps[i], methodId: `recovery-${batch}-${i + 1}` });
645
- free--;
646
- savedCodes.push(built.codes[i]);
647
- } catch (err) {
648
- addError = err;
649
- if (removedForThis) {
650
- try {
651
- await addMasterKeyWrap(mailboxUid!, removedForThis);
652
- removedEarly--;
653
- } catch {
654
- // Still counted in removedEarly, which the messages below report.
655
- }
656
- }
657
- break;
658
- }
659
- }
660
-
661
- const removedEarlyNote =
662
- removedEarly > 0
663
- ? `${removedEarly} of your old recovery codes had to be removed to make room; the rest were kept and still work`
664
- : "Your old recovery codes were kept and still work";
665
-
666
- if (savedCodes.length === 0) {
667
- const message = addError instanceof ApiRequestError ? addError.message : "Could not regenerate recovery codes.";
668
- setActionError(removedEarly > 0 ? `${message} ${removedEarlyNote}.` : message);
669
- setRegenerating(false);
670
- await loadVault();
671
- return;
672
- }
673
-
674
- let warning: string | null = null;
675
- if (savedCodes.length < built.wraps.length) {
676
- // Remaining old codes are deliberately left in place - the new set is incomplete, so the old one
677
- // is still part of the user's recovery safety net.
678
- warning = `Only ${savedCodes.length} of ${built.wraps.length} new recovery codes could be saved${
679
- addError instanceof ApiRequestError ? ` (${addError.message})` : ""
680
- }. ${removedEarlyNote} - save the codes below, then try regenerating again.`;
681
- } else {
682
- let notRemoved = 0;
683
- for (const wrap of pendingOld) {
684
- try {
685
- await removeMasterKeyWrap(mailboxUid!, "recovery", wrap.methodId);
686
- } catch {
687
- notRemoved++;
688
- }
689
- }
690
- if (notRemoved > 0) {
691
- warning = `${notRemoved} of your old recovery codes could not be removed and still work. Remove them from the unlock methods list, or regenerate again.`;
692
- }
693
- }
694
-
695
- setRecoveryCodesReason("regenerate");
696
- setCodesWarning(warning);
697
- setNewRecoveryCodes(savedCodes);
698
- setCodesSaved(false);
699
- setRegenerating(false);
700
- await loadVault();
701
- }
702
-
703
- async function handleCopyCodes() {
704
- try {
705
- // Only reachable via the button below, which never renders while newRecoveryCodes is null.
706
- await navigator.clipboard.writeText(newRecoveryCodes!.join("\n"));
707
- setCodesCopied(true);
708
- setTimeout(() => setCodesCopied(false), 2000);
709
- } catch {
710
- // Clipboard access can be denied by the browser - the codes are still selectable/copyable by
711
- // hand from the list below.
712
- }
713
- }
714
-
715
- /**
716
- * Real revocation for a captured wrap (`keyvaultApi.ts`'s `rekey()` - see that function's own doc
717
- * comment): re-wraps every private key in a freshly fetched vault under a brand new master key
718
- * (`rewrapVaultPrivateKeys()` - all of them, active or not, since `rekey()` replaces `wrappedKeys`
719
- * wholesale), wraps that new MK under a freshly entered password and a fresh set of recovery codes,
720
- * and atomically replaces the vault - the enrolled keypair/certificate itself is unchanged (restapi's
721
- * own `rekey()` rejects anything else, so `keys` is a fresh copy of the mailbox's, not this page's
722
- * possibly stale list), only how it's protected. Every *other* unlock method this mailbox had stops
723
- * working the instant this succeeds, since `rekey()` replaces `masterKeyWraps` wholesale - the whole
724
- * point, for a captured-wrap scenario where it's unclear which method was compromised.
725
- */
726
- async function handleRotateKeys(e: FormEvent) {
727
- e.preventDefault();
728
- if (rotationBlockedBySigning) {
729
- // The form's controls are disabled in this state too; this also covers an implicit submit.
730
- return;
731
- }
732
- if (rotationPassword.length < MIN_PASSWORD_LENGTH) {
733
- setActionError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
734
- return;
735
- }
736
- if (rotationPassword !== rotationConfirmPassword) {
737
- setActionError("Passwords do not match.");
738
- return;
739
- }
740
- setActionError(null);
741
- setEscrowError(null);
742
- setRotating(true);
743
- let keys: PublicKey[];
744
- let codes: string[];
745
- try {
746
- const current = await currentUnlockedKeys();
747
- const [freshVault, freshMailbox] = await Promise.all([getKeyVault(mailboxUid!), getMailbox(mailboxUid!)]);
748
- keys = freshMailbox.keys ?? [];
749
- // A session key that opens nothing is stale (rotated elsewhere) rather than missing some keys.
750
- if (!(await masterKeyOpensVault(mailboxUid!, current.masterKey, freshVault))) {
751
- throw new StaleSessionKeysError();
752
- }
753
- const rewrapped = await rewrapVaultPrivateKeys(mailboxUid!, current.masterKey, freshVault.wrappedKeys);
754
- const mk = rewrapped.mk;
755
- const passwordWrap = await buildPasswordWrap(mailboxUid!, mk, rotationPassword);
756
- const recovery = await buildRecoveryWraps(mailboxUid!, mk);
757
- codes = recovery.codes;
758
- const masterKeyWraps = [passwordWrap, ...recovery.wraps];
759
- // restapi's rekey() drops the old escrow wraps, and requires a replacement escrow wrap only when the vault
760
- // already holds one and the mailbox's escrow scope still exists - so one is carried over exactly then, in
761
- // the same request. A mailbox assigned a scope but never escrowed isn't silently escrowed by a rotation,
762
- // and a deleted scope (escrow-info 404s) needs nothing. Any other failure to build it aborts the rotation -
763
- // rotating without it would end escrow coverage. restapi's own 409 stays the final word.
764
- if (freshVault.masterKeyWraps.some((w) => w.method === "escrow")) {
765
- try {
766
- const escrowInfo = await getEscrowInfo(mailboxUid!);
767
- masterKeyWraps.push(await buildEscrowWrap(mk, escrowInfo.escrowScopeId, fromBase64(escrowInfo.publicKey.publicKey)));
768
- } catch (err) {
769
- if (!(err instanceof ApiRequestError && err.status === 404)) {
770
- throw new EscrowWrapUnavailableError(err);
771
- }
772
- }
773
- }
774
- await rekey(mailboxUid!, { wrappedKeys: rewrapped.wrappedKeys, masterKeyWraps, keys });
775
- } catch (err) {
776
- if (err instanceof UncoveredVaultKeysError) {
777
- setActionError(
778
- `Your key vault holds ${err.fingerprints.length === 1 ? "a private key" : `${err.fingerprints.length} private keys`} this session can't open (${err.fingerprints.join(", ")}), so rotating now would lose ${err.fingerprints.length === 1 ? "it" : "them"}. Nothing was changed.`,
779
- );
780
- } else if (err instanceof EscrowWrapUnavailableError) {
781
- const reason = err.cause instanceof ApiRequestError ? ` (${err.cause.message})` : "";
782
- setActionError(
783
- `Your keys were not rotated: this mailbox is under escrow, and escrow protection for the new keys couldn't be prepared${reason}. Nothing was changed.`,
784
- );
785
- } else if (err instanceof ApiRequestError && err.status === 409) {
786
- setActionError(ROTATION_CONFLICT_MESSAGE);
787
- } else {
788
- setActionError(errorMessage(err, "Could not rotate your encryption keys."));
789
- }
790
- setRotating(false);
791
- return;
792
- }
793
-
794
- // The rotation is committed - every old unlock method is already dead, so these codes must be on
795
- // screen right now, before anything below that could fail or hang.
796
- const newPassword = rotationPassword;
797
- setRotationPassword("");
798
- setRotationConfirmPassword("");
799
- setRecoveryCodesReason("rotate");
800
- setCodesWarning(null);
801
- setNewRecoveryCodes(codes);
802
- setCodesSaved(false);
803
- setRefreshedKeys(keys);
804
- try {
805
- // Refreshes this session's own cached keys against the new MK, via the password we just set -
806
- // the underlying private key material didn't change, but the stale MK in memory would silently
807
- // build wrong future wraps if left as-is. If that fails, the stale keys are destroyed instead.
808
- try {
809
- await unlockWithPassword(mailboxUid!, keys, newPassword);
810
- } catch {
811
- destroyUnlockedKeys(mailboxUid);
812
- void destroyLocalIndex(mailboxUid!);
813
- setRotationRelockReason(
814
- "Your keys were rotated, but this session couldn't unlock them again with your new password. Reload the page and unlock with your new password.",
815
- );
816
- setDestroyed(true);
817
- }
818
- await loadVault();
819
- } finally {
820
- setRotating(false);
821
- }
822
- }
823
-
824
- function handleDestroyKeysNow() {
825
- destroyUnlockedKeys(mailboxUid);
826
- // Spec §11: the Tier 2 local index MUST be destroyed on the same events that destroy unlocked
827
- // keys. Not awaited - this page's own "keys removed" confirmation shouldn't wait on it, and
828
- // destroyLocalIndex() never throws either way. Reached directly here (rather than relying on
829
- // LocalIndexLifecycle.tsx's polling, which only runs while Mail's own MailShell is mounted) since
830
- // this button lives on the Settings page, which never mounts that component.
831
- void destroyLocalIndex(mailboxUid!);
832
- setDestroyed(true);
833
- }
834
-
835
- // Checked before `destroyed` - a rotation whose re-unlock failed destroys this session's keys, but its
836
- // new recovery codes still have to be seen (and acknowledged) first.
837
- if (newRecoveryCodes) {
838
- return (
839
- <div className="flex-1 min-w-0 overflow-y-auto p-6">
840
- <div className="max-w-xl">
841
- <h1 className="text-lg font-bold tracking-tight mb-1">Save your new recovery codes</h1>
842
- {codesWarning && <Alert>{codesWarning}</Alert>}
843
- <p className="text-sm text-text-muted mb-4">
844
- {recoveryCodesReason === "rotate"
845
- ? "Your keys have been rotated - every previous unlock method (password, recovery codes, or anything else on file) has stopped working. "
846
- : codesWarning
847
- ? ""
848
- : "Your old recovery codes no longer work. "}
849
- If you lose your password, these new codes are the only way to recover your encrypted mail.
850
- Each code can be used once. Store them somewhere safe — they will not be shown again.
851
- </p>
852
- <ul className="grid grid-cols-2 gap-2 mb-3 font-mono text-sm">
853
- {newRecoveryCodes.map((code) => (
854
- <li key={code} className="bg-surface-alt rounded-sm py-1.5 px-2 text-center">
855
- {code}
856
- </li>
857
- ))}
858
- </ul>
859
- <Button type="button" variant="secondary" className="!w-auto mb-5" onClick={handleCopyCodes}>
860
- {codesCopied ? "Copied" : "Copy codes to clipboard"}
861
- </Button>
862
- <label className="flex items-center gap-2 text-sm mb-4">
863
- <input type="checkbox" checked={codesSaved} onChange={(e) => setCodesSaved(e.target.checked)} />
864
- I have saved these recovery codes in a safe place.
865
- </label>
866
- <Button type="button" disabled={!codesSaved} onClick={() => setNewRecoveryCodes(null)} className="!w-auto">
867
- Done
868
- </Button>
869
- </div>
870
- </div>
871
- );
872
- }
873
-
874
- if (destroyed) {
875
- return (
876
- <div className="flex-1 min-w-0 overflow-y-auto p-6">
877
- <div className="max-w-xl">
878
- <Alert>
879
- {rotationRelockReason ??
880
- "Your encryption keys have been removed from this session. Reload the page (or open Mail again) to unlock them when you need to read or send encrypted mail."}
881
- </Alert>
882
- </div>
883
- </div>
884
- );
885
- }
886
-
887
- // The "Digital signatures" section: a pending enrollment first (even while an older certificate is still active - it is a renewal); else the
888
- // active certificate; else how the last enrollment ended (a failure, or a certificate that has since expired); else nothing has been requested.
889
- const expiredSigningKey = displayedKeys
890
- .filter((key) => key.useType === "sign" && !key.revokedAt && key.notAfter <= Date.now())
891
- .sort((a, b) => b.notAfter - a.notAfter)[0];
892
- // A certificate the CA has issued but the server has not installed yet counts as issued too (there is no key to show yet: the card says it is installing).
893
- const installing = canManageKeys && isInstalling(enrollmentResult);
894
- const certificateMode: SigningCertificateMode | null =
895
- signingStatus === "pending"
896
- ? "pending"
897
- : activeSigningKey || installing
898
- ? canManageKeys
899
- ? "issued"
900
- : null
901
- : canManageKeys && enrollmentResult?.status === "failed"
902
- ? "failed"
903
- : canManageKeys && expiredSigningKey
904
- ? "expired"
905
- : null;
906
- const certificateEnrollment = certificateMode === "issued" && enrollmentResult?.status !== "issued" ? null : certificateMode === "expired" ? null : enrollmentResult;
907
-
908
- return (
909
- <div className="flex-1 min-w-0 overflow-y-auto p-6">
910
- <div className="max-w-xl flex flex-col gap-6">
911
- <div>
912
- <h1 className="text-lg font-bold tracking-tight mb-1">Encryption</h1>
913
- <p className="text-sm text-text-muted">
914
- Manage how {mailbox.displayName} unlocks its encryption keys on this and other devices.
915
- </p>
916
- </div>
917
-
918
- {loadError && <Alert>{loadError}</Alert>}
919
- {actionError && <Alert>{actionError}</Alert>}
920
- {!canManageKeys && (
921
- <p className="text-sm text-text-muted">
922
- Only this mailbox&rsquo;s owner, signed in as themselves, can change its encryption keys and
923
- unlock methods.
924
- </p>
925
- )}
926
-
927
- <div>
928
- <h2 className="text-sm font-semibold mb-2">Encryption keys</h2>
929
- {displayedKeys.length > 0 ? (
930
- <ul className="flex flex-col gap-1 text-sm">
931
- {displayedKeys.map((key) => (
932
- <li key={key.fingerprint} className="font-mono text-xs">
933
- {key.useType === "sign" ? "Signing" : "Encryption"} key: {key.fingerprint}
934
- {key.revokedAt &&
935
- (key.revocationReason === "superseded" ? (
936
- <span className="text-text-muted"> (superseded)</span>
937
- ) : (
938
- <span className="text-danger"> (revoked)</span>
939
- ))}
940
- </li>
941
- ))}
942
- </ul>
943
- ) : (
944
- <p className="text-sm text-text-muted">No keys enrolled yet.</p>
945
- )}
946
- </div>
947
-
948
- <div>
949
- <h2 className="text-sm font-semibold mb-2">Digital signatures</h2>
950
- {activeSigningKey && signingStatus !== "pending" && (
951
- <p className={["text-sm text-text-muted", certificateMode ? "mb-3" : ""].join(" ")}>
952
- Enabled — outgoing mail from this mailbox is signed with a publicly-trusted
953
- certificate.
954
- </p>
955
- )}
956
- {certificateMode && (
957
- <div className="flex flex-col gap-2">
958
- {certificateMode === "failed" && signingError && <Alert>{signingError}</Alert>}
959
- <SigningCertificateCard
960
- mode={certificateMode}
961
- enrollment={certificateEnrollment}
962
- snapshot={enrollment}
963
- info={signingInfo}
964
- address={mailbox.primarySmtpAddress}
965
- keyNotAfter={certificateMode === "expired" ? expiredSigningKey?.notAfter : activeSigningKey?.notAfter}
966
- canRequest={canManageKeys}
967
- requesting={signingStatus === "enrolling"}
968
- onCheck={canManageKeys ? () => void checkEnrollmentNow(mailboxUid!) : undefined}
969
- onRequest={handleEnrollSigning}
970
- />
971
- </div>
972
- )}
973
- {!activeSigningKey && !certificateMode && !canManageKeys ? (
974
- <p className="text-sm text-text-muted">Not enabled.</p>
975
- ) : activeSigningKey || certificateMode ? null : (
976
- <div className="flex flex-col gap-2">
977
- <p className="text-xs text-text-muted">
978
- Lets recipients verify that mail from this mailbox is genuinely from you.
979
- Optional — encryption already works without it.
980
- </p>
981
- {beforeRequestText(signingInfo, mailbox.primarySmtpAddress) && (
982
- <p className="text-xs text-text-muted">{beforeRequestText(signingInfo, mailbox.primarySmtpAddress)}</p>
983
- )}
984
- {healthWarning(signingInfo) && <Alert>{healthWarning(signingInfo)}</Alert>}
985
- {enrollment?.gone && <Alert>{STALE_REQUEST_TEXT}</Alert>}
986
- {signingError && <Alert>{signingError}</Alert>}
987
- {signingInfo?.backend !== "none" && (
988
- <Button
989
- type="button"
990
- variant="secondary"
991
- className="!w-auto"
992
- loading={signingStatus === "enrolling"}
993
- disabled={signingStatus !== "idle"}
994
- onClick={handleEnrollSigning}
995
- >
996
- Enable digital signatures
997
- </Button>
998
- )}
999
- </div>
1000
- )}
1001
- </div>
1002
-
1003
- {mailbox.escrowScopeId && (
1004
- <div>
1005
- <h2 className="text-sm font-semibold mb-2">Escrow</h2>
1006
- {escrowError && <Alert>{escrowError}</Alert>}
1007
- {hasEscrowWrap ? (
1008
- <p className="text-sm text-text-muted">
1009
- This mailbox is under legal/compliance escrow — an authorized holder in your
1010
- organization can recover its encrypted mail if needed. This does not weaken
1011
- protection against anyone else.
1012
- </p>
1013
- ) : (
1014
- <div className="flex flex-col gap-2">
1015
- <p className="text-xs text-text-muted">
1016
- Your organization has assigned this mailbox to an escrow scope, but nothing has been
1017
- protected yet — an authorized holder cannot recover this mailbox&rsquo;s encrypted mail
1018
- until you complete this step.{" "}
1019
- This does not weaken protection against anyone else.
1020
- </p>
1021
- {canManageKeys && (
1022
- <Button
1023
- type="button"
1024
- variant="secondary"
1025
- className="!w-auto"
1026
- loading={wrappingEscrow}
1027
- disabled={wrappingEscrow}
1028
- onClick={handleWrapEscrow}
1029
- >
1030
- Add escrow protection
1031
- </Button>
1032
- )}
1033
- </div>
1034
- )}
1035
- </div>
1036
- )}
1037
-
1038
- <div>
1039
- <h2 className="text-sm font-semibold mb-2">Unlock methods</h2>
1040
- <p className="text-xs text-text-muted mb-3">
1041
- Removing a method here stops it from being usable to unlock this mailbox going forward,
1042
- but it is <strong>not</strong> full revocation — anyone who already captured a wrapped
1043
- copy and knows its secret could still use it. For real revocation (e.g. after a lost
1044
- device), rotate your keys entirely below instead.
1045
- </p>
1046
- {vault && vault.masterKeyWraps.length > 0 ? (
1047
- <ul className="flex flex-col gap-2">
1048
- {[...passwordWraps, ...recoveryWraps, ...otherWraps].map((wrap) => {
1049
- const key = `${wrap.method}:${wrap.methodId ?? ""}`;
1050
- return (
1051
- <li key={key} className="flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm">
1052
- <span>{METHOD_LABELS[wrap.method] ?? wrap.method}</span>
1053
- {wrap.method !== "escrow" &&
1054
- canManageKeys &&
1055
- (canRemoveWrap(wrap) ? (
1056
- <Button
1057
- type="button"
1058
- variant="text"
1059
- className="!w-auto text-danger"
1060
- loading={removingMethod === key}
1061
- disabled={removingMethod !== null}
1062
- onClick={() => setPendingRemoval(wrap)}
1063
- >
1064
- Remove
1065
- </Button>
1066
- ) : (
1067
- <span className="text-xs text-text-muted">
1068
- {ownUnlockWrapCount > 1 ? "Needed to unlock" : "Your only unlock method"}
1069
- </span>
1070
- ))}
1071
- </li>
1072
- );
1073
- })}
1074
- </ul>
1075
- ) : (
1076
- <p className="text-sm text-text-muted">No unlock methods on file.</p>
1077
- )}
1078
- <Modal open={pendingRemoval !== null} onClose={() => setPendingRemoval(null)} title="Remove unlock method">
1079
- <p className="text-sm mb-5">
1080
- Remove this unlock method? It will no longer unlock this mailbox&rsquo;s encrypted mail on any device.
1081
- </p>
1082
- <div className="flex gap-3 justify-end">
1083
- <Button type="button" variant="secondary" className="!w-auto" onClick={() => setPendingRemoval(null)}>
1084
- Cancel
1085
- </Button>
1086
- <Button
1087
- type="button"
1088
- className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger"
1089
- onClick={() => handleRemove(pendingRemoval!)}
1090
- >
1091
- Remove method
1092
- </Button>
1093
- </div>
1094
- </Modal>
1095
- </div>
1096
-
1097
- {canManageKeys && (
1098
- <>
1099
- {passwordWraps.length === 0 ? (
1100
- vault && (
1101
- <form onSubmit={handleAddPassword} className="flex flex-col gap-2">
1102
- <h2 className="text-sm font-semibold">Add a password</h2>
1103
- <input
1104
- type="password"
1105
- aria-label="New password"
1106
- placeholder="New password"
1107
- className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
1108
- value={newPassword}
1109
- onChange={(e) => setNewPassword(e.target.value)}
1110
- disabled={addingPassword}
1111
- autoComplete="new-password"
1112
- />
1113
- <input
1114
- type="password"
1115
- aria-label="Confirm new password"
1116
- placeholder="Confirm new password"
1117
- className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
1118
- value={confirmNewPassword}
1119
- onChange={(e) => setConfirmNewPassword(e.target.value)}
1120
- disabled={addingPassword}
1121
- autoComplete="new-password"
1122
- />
1123
- <div>
1124
- <Button type="submit" loading={addingPassword} disabled={addingPassword} className="!w-auto">
1125
- Add password
1126
- </Button>
1127
- </div>
1128
- </form>
1129
- )
1130
- ) : (
1131
- <div>
1132
- <h2 className="text-sm font-semibold mb-2">Changing your password</h2>
1133
- <p className="text-xs text-text-muted">
1134
- Only one password can unlock this mailbox. To change it, rotate your keys below
1135
- - that sets a new password and replaces every other unlock method.
1136
- </p>
1137
- </div>
1138
- )}
1139
- <div>
1140
- <h2 className="text-sm font-semibold mb-2">Recovery codes</h2>
1141
- <p className="text-xs text-text-muted mb-3">
1142
- Regenerating replaces all of your existing recovery codes — old ones stop working
1143
- immediately.
1144
- </p>
1145
- <Button
1146
- type="button"
1147
- variant="secondary"
1148
- loading={regenerating}
1149
- disabled={regenerating || !vault}
1150
- onClick={handleRegenerateRecoveryCodes}
1151
- className="!w-auto"
1152
- >
1153
- Regenerate recovery codes
1154
- </Button>
1155
- </div>
1156
-
1157
- <form onSubmit={handleRotateKeys} className="flex flex-col gap-2 border-t border-border pt-6">
1158
- <h2 className="text-sm font-semibold">Rotate keys</h2>
1159
- <p className="text-xs text-text-muted mb-1">
1160
- Real revocation, for when a device or an unlock method may have been compromised.
1161
- Re-protects your existing encryption key under a brand new master key - your signing/
1162
- encryption keypair itself doesn&rsquo;t change, so mail you&rsquo;ve already sent or
1163
- received still decrypts normally. Every current unlock method (password, recovery codes,
1164
- and anything else on file) stops working immediately; you&rsquo;ll set a new password and
1165
- get new recovery codes below.
1166
- </p>
1167
- {rotationBlockedBySigning && (
1168
- <p className="text-xs text-text-muted">
1169
- {signingStatus === "checking"
1170
- ? "Checking whether a signing certificate enrollment is still in progress..."
1171
- : "Rotation is unavailable while a signing certificate enrollment is in progress - rotating now would lose the key being enrolled. Try again once it finishes, or cancel the enrollment."}
1172
- </p>
1173
- )}
1174
- {signingStatus === "pending" && signingEnrollmentId && (
1175
- <div className="flex flex-col gap-2">
1176
- {cancelEnrollmentError && <Alert>{cancelEnrollmentError}</Alert>}
1177
- <div>
1178
- <Button
1179
- type="button"
1180
- variant="secondary"
1181
- className="!w-auto"
1182
- loading={cancelingEnrollment}
1183
- disabled={cancelingEnrollment}
1184
- onClick={() => handleCancelEnrollment(signingEnrollmentId)}
1185
- >
1186
- Cancel enrollment
1187
- </Button>
1188
- </div>
1189
- </div>
1190
- )}
1191
- <input
1192
- type="password"
1193
- aria-label="New password for rotated keys"
1194
- placeholder="New password"
1195
- className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
1196
- value={rotationPassword}
1197
- onChange={(e) => setRotationPassword(e.target.value)}
1198
- disabled={rotating || rotationBlockedBySigning}
1199
- autoComplete="new-password"
1200
- />
1201
- <input
1202
- type="password"
1203
- aria-label="Confirm new password for rotated keys"
1204
- placeholder="Confirm new password"
1205
- className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
1206
- value={rotationConfirmPassword}
1207
- onChange={(e) => setRotationConfirmPassword(e.target.value)}
1208
- disabled={rotating || rotationBlockedBySigning}
1209
- autoComplete="new-password"
1210
- />
1211
- <div>
1212
- <Button
1213
- type="submit"
1214
- variant="secondary"
1215
- className="!w-auto text-danger"
1216
- loading={rotating}
1217
- disabled={rotating || rotationBlockedBySigning}
1218
- >
1219
- Rotate keys now
1220
- </Button>
1221
- </div>
1222
- </form>
1223
- </>
1224
- )}
1225
-
1226
- <div>
1227
- <h2 className="text-sm font-semibold mb-2">Session timeout</h2>
1228
- <p className="text-xs text-text-muted mb-3">
1229
- Automatically destroys your unlocked keys on this device after this much time with no
1230
- activity anywhere in the app - not just Mail or Settings. Applies the next time you open
1231
- or reload the app.
1232
- </p>
1233
- <select
1234
- aria-label="Session timeout"
1235
- className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
1236
- value={idleTimeoutMinutes}
1237
- onChange={handleIdleTimeoutChange}
1238
- >
1239
- {IDLE_TIMEOUT_OPTIONS_MINUTES.map((minutes) => (
1240
- <option key={minutes} value={minutes}>
1241
- {idleTimeoutLabel(minutes)}
1242
- </option>
1243
- ))}
1244
- </select>
1245
- </div>
1246
-
1247
- <div>
1248
- <h2 className="text-sm font-semibold mb-2">Local search index size</h2>
1249
- <p className="text-xs text-text-muted mb-3">
1250
- How much decrypted mail this device keeps in a local encrypted search index, so recent
1251
- search results and inbox previews work instantly without contacting the server every
1252
- time. Defaults to {localIndexDefaultByteBudgetLabel} on this device. Applies the next
1253
- time this mailbox&rsquo;s index rebuilds (e.g. the next time you unlock it) — lowering
1254
- it doesn&rsquo;t delete anything already indexed elsewhere, it just narrows what this
1255
- device keeps a local copy of.
1256
- </p>
1257
- <select
1258
- aria-label="Local search index size"
1259
- className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
1260
- value={localIndexByteBudget}
1261
- onChange={handleLocalIndexByteBudgetChange}
1262
- >
1263
- {LOCAL_INDEX_SIZE_OPTIONS.map((option) => (
1264
- <option key={option.bytes} value={option.bytes}>
1265
- {option.label}
1266
- </option>
1267
- ))}
1268
- </select>
1269
- </div>
1270
-
1271
- <div>
1272
- <h2 className="text-sm font-semibold mb-2">This session</h2>
1273
- <p className="text-xs text-text-muted mb-3">
1274
- Removes your unlocked keys from this browser tab&rsquo;s memory right now, without
1275
- affecting any other device. You&rsquo;ll be asked to unlock again the next time you read
1276
- or send encrypted mail here.
1277
- </p>
1278
- <Button type="button" variant="secondary" className="!w-auto text-danger" onClick={handleDestroyKeysNow}>
1279
- Destroy keys on this device now
1280
- </Button>
1281
- </div>
1282
- </div>
1283
- </div>
1284
- );
1285
- }
1286
-
1287
- export default SettingsEncryptionPage;
1288
-
1289
- /** The tab's title: `Brand: Settings` (see `pageTitle()`). */
1290
- export const title = pageTitle("Settings");
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { pageTitle } from "../../../shared/navigation/pageTitle.js";
6
+ import React, { FormEvent, useEffect, useState } from "react";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import {
9
+ KeyVault,
10
+ MasterKeyWrap,
11
+ PublicKey,
12
+ WrappedPrivateKey,
13
+ addMasterKeyWrap,
14
+ cancelSignEnrollment,
15
+ findActivePublicKey,
16
+ getCurrentSignEnrollment,
17
+ getEscrowInfo,
18
+ getKeyVault,
19
+ rekey,
20
+ removeMasterKeyWrap,
21
+ startSignEnrollment,
22
+ } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
23
+ import {
24
+ ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE,
25
+ SIGNING_PRIVATE_KEY_AAD_PURPOSE,
26
+ UnlockedKeys,
27
+ destroyUnlockedKeys,
28
+ getUnlockedKeys,
29
+ unlockWithPassword,
30
+ } from "@rapidmx/react-shared/crypto/keySession.js";
31
+ import { IDLE_TIMEOUT_OPTIONS_MINUTES, getIdleTimeoutMinutes, setIdleTimeoutMinutes } from "@rapidmx/react-shared/crypto/idleTimeout.js";
32
+ import {
33
+ LOCAL_INDEX_SIZE_OPTIONS,
34
+ getDefaultLocalIndexByteBudget,
35
+ getLocalIndexByteBudget,
36
+ setLocalIndexByteBudget,
37
+ } from "../../../shared/search/localIndexSizePreference.js";
38
+ import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
39
+ import { KeysLockedError, buildAad, generateMasterKey, openWithKey, sealWithKey } from "@rapidmx/react-shared/crypto/masterKey.js";
40
+ import { buildEscrowWrap, buildPasswordWrap, buildRecoveryWraps } from "@rapidmx/react-shared/crypto/masterKeyWraps.js";
41
+ import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "@rapidmx/react-shared/crypto/keys.js";
42
+ import { getMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
43
+ import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
44
+ import KeyEnrollmentGate from "../../../shared/components/layout/KeyEnrollmentGate.js";
45
+ import { useUnlockPrompt } from "../../../shared/components/layout/UnlockPromptProvider.js";
46
+ import { destroyLocalIndex } from "../../../shared/search/localIndexRpcClient.js";
47
+ import { notifyApiError } from "../../../shared/notifications/apiErrors.js";
48
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
49
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
50
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
51
+ import SigningCertificateCard, { SigningCertificateMode } from "../../../shared/components/settings/SigningCertificateCard.js";
52
+ import { readStoredSignEnrollment, storeSignEnrollment } from "../../../shared/signing/enrollmentStorage.js";
53
+ import {
54
+ checkEnrollmentNow,
55
+ forgetEnrollment,
56
+ recordEnrollmentResult,
57
+ useEnrollmentSnapshot,
58
+ watchCurrentEnrollment,
59
+ watchEnrollment,
60
+ } from "../../../shared/signing/enrollmentTracker.js";
61
+ import { STALE_REQUEST_TEXT, beforeRequestText, healthWarning, isInstalling, timeOf } from "../../../shared/signing/enrollmentView.js";
62
+ import { useSigningEnrollmentInfo } from "../../../shared/signing/signingInfo.js";
63
+
64
+ const MIN_PASSWORD_LENGTH = 8;
65
+
66
+ /** Mirrors restapi's `BaseKeyVaultRoute` `MAX_MASTER_KEY_WRAPS` - `addMasterKeyWrap()` refuses a vault
67
+ * already holding this many wraps (escrow included). */
68
+ export const MAX_MASTER_KEY_WRAPS = 20;
69
+
70
+ /** How often, and how many times, the page asks for the mailbox's keys while a certificate is issued but not installed yet. */
71
+ const INSTALL_WATCH_INTERVAL_MS = 15_000;
72
+ const INSTALL_WATCH_ATTEMPTS = 40;
73
+
74
+ const KEYS_LOCKED_MESSAGE = "Your encryption keys were locked before this could finish. Unlock them and try again.";
75
+
76
+ /** Thrown inside `handleRotateKeys()` when the escrow wrap of the new master key can't be built - the rotation is
77
+ * then abandoned before `rekey()`. */
78
+ class EscrowWrapUnavailableError extends Error {
79
+ constructor(readonly cause: unknown) {
80
+ super("Could not prepare escrow protection for the new master key.");
81
+ }
82
+ }
83
+
84
+ /** Thrown by `rewrapVaultPrivateKeys()` when a vault entry can't be opened with the current master key. */
85
+ class UncoveredVaultKeysError extends Error {
86
+ constructor(readonly fingerprints: string[]) {
87
+ super(`Could not open ${fingerprints.length} wrapped private key(s).`);
88
+ }
89
+ }
90
+
91
+ /** Thrown when this session's master key no longer opens the vault - another device rotated the keys since this
92
+ * session unlocked. Anything wrapped or sealed under it would be unusable (and a signing key sealed under it would
93
+ * be installed at the vault's current generation, blocking rotation), so nothing is written. */
94
+ class StaleSessionKeysError extends Error {
95
+ constructor() {
96
+ super("This session's master key no longer opens the key vault.");
97
+ }
98
+ }
99
+
100
+ const STALE_KEYS_MESSAGE =
101
+ "Your encryption keys were changed on another device (for example, rotated), so the copy unlocked in this session no longer works. Nothing was changed. Unlock again with your current password, then try again.";
102
+
103
+ function wrappedKeyAad(mailboxUid: string, entry: WrappedPrivateKey): Uint8Array {
104
+ return buildAad(mailboxUid, entry.useType === "sign" ? SIGNING_PRIVATE_KEY_AAD_PURPOSE : ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE);
105
+ }
106
+
107
+ /**
108
+ * Whether `masterKey` opens at least one of `vault`'s wrapped private keys - i.e. it is still the vault's current
109
+ * master key. A vault with no wrapped keys has nothing to check against and passes. The opened key bytes are zeroed
110
+ * straight away. `KeysLockedError` (a destroyed master key) propagates.
111
+ */
112
+ async function masterKeyOpensVault(mailboxUid: string, masterKey: Uint8Array, vault: KeyVault): Promise<boolean> {
113
+ if (vault.wrappedKeys.length === 0) {
114
+ return true;
115
+ }
116
+ for (const entry of vault.wrappedKeys) {
117
+ try {
118
+ const raw = await openWithKey(masterKey, entry, wrappedKeyAad(mailboxUid, entry));
119
+ raw.fill(0);
120
+ return true;
121
+ } catch (err) {
122
+ if (err instanceof KeysLockedError) {
123
+ throw err;
124
+ }
125
+ }
126
+ }
127
+ return false;
128
+ }
129
+
130
+ /**
131
+ * Re-seals EVERY private key in the vault (not just the ones this session imported - an inactive or
132
+ * newly issued key would otherwise be silently dropped by `rekey()`, which replaces `wrappedKeys`
133
+ * wholesale) under a brand new master key. Opens each entry with `currentMasterKey` first and refuses
134
+ * (`UncoveredVaultKeysError`) if any can't be opened, before generating anything. Plaintext key bytes
135
+ * are zeroed as soon as they've been re-sealed. `KeysLockedError` (a destroyed master key) propagates.
136
+ */
137
+ async function rewrapVaultPrivateKeys(
138
+ mailboxUid: string,
139
+ currentMasterKey: Uint8Array,
140
+ wrappedKeys: WrappedPrivateKey[],
141
+ ): Promise<{ mk: Uint8Array; wrappedKeys: WrappedPrivateKey[] }> {
142
+ const aadFor = (entry: WrappedPrivateKey) => wrappedKeyAad(mailboxUid, entry);
143
+ const opened: { entry: WrappedPrivateKey; raw: Uint8Array }[] = [];
144
+ try {
145
+ const failed: string[] = [];
146
+ for (const entry of wrappedKeys) {
147
+ try {
148
+ opened.push({ entry, raw: await openWithKey(currentMasterKey, entry, aadFor(entry)) });
149
+ } catch (err) {
150
+ if (err instanceof KeysLockedError) {
151
+ throw err;
152
+ }
153
+ failed.push(entry.fingerprint);
154
+ }
155
+ }
156
+ if (failed.length > 0) {
157
+ throw new UncoveredVaultKeysError(failed);
158
+ }
159
+ const mk = generateMasterKey();
160
+ const rewrapped: WrappedPrivateKey[] = [];
161
+ for (const { entry, raw } of opened) {
162
+ const sealed = await sealWithKey(mk, raw, aadFor(entry));
163
+ rewrapped.push({
164
+ ciphertext: sealed.ciphertext,
165
+ nonce: sealed.nonce,
166
+ algorithm: "AES-256-GCM",
167
+ fingerprint: entry.fingerprint,
168
+ useType: entry.useType,
169
+ });
170
+ }
171
+ return { mk, wrappedKeys: rewrapped };
172
+ } finally {
173
+ for (const { raw } of opened) {
174
+ raw.fill(0);
175
+ }
176
+ }
177
+ }
178
+
179
+ const ROTATION_CONFLICT_MESSAGE =
180
+ "Your keys were not rotated because the server reported a conflicting change. If a signing certificate enrollment is still in progress for this mailbox (it may have been started on another device), wait for it to finish, then try again. If this mailbox is under escrow, its escrow protection may have changed meanwhile - reload this page and try again. Nothing was changed.";
181
+
182
+ const METHOD_LABELS: Record<string, string> = {
183
+ password: "Password",
184
+ passkey: "Passkey",
185
+ recovery: "Recovery code",
186
+ escrow: "Escrow (managed by your organization)",
187
+ };
188
+
189
+ function idleTimeoutLabel(minutes: number): string {
190
+ if (minutes === 0) {
191
+ return "Never";
192
+ }
193
+ if (minutes === 60) {
194
+ return "1 hour";
195
+ }
196
+ return `${minutes} minutes`;
197
+ }
198
+
199
+ export type SettingsEncryptionPageProps = Omit<SettingsShellProps, "active">;
200
+
201
+ function SettingsEncryptionPage(props: SettingsEncryptionPageProps) {
202
+ return (
203
+ <SettingsShell {...props} active="encryption">
204
+ <EncryptionGate userUid={props.userUid} impersonating={props.impersonating} />
205
+ </SettingsShell>
206
+ );
207
+ }
208
+
209
+ /**
210
+ * Unlike `MailShell`, `SettingsShell` doesn't already gate its children behind `KeyEnrollmentGate` (a
211
+ * user can reach Settings without ever having opened Mail this session) - wrapped here instead, so
212
+ * `EncryptionContent` below can always assume `getUnlockedKeys()` has something for this mailbox before
213
+ * it renders. See `KeyEnrollmentGate`'s own doc comment for why it's safe to mount more than once (it
214
+ * short-circuits to `children` immediately for a mailbox already unlocked elsewhere this session).
215
+ */
216
+ function EncryptionGate({ userUid, impersonating }: { userUid?: string; impersonating?: boolean }) {
217
+ const { mailboxUid, mailboxes } = useSettingsShell();
218
+ const mailbox = mailboxes.find((mb) => mb.uid === mailboxUid)!;
219
+ // restapi only lets the mailbox's actual owner write its key vault (`requireMailboxOwner()`), so a
220
+ // delegate (or an admin impersonating the owner) never provisions keys here, and sees no write actions.
221
+ const isOwner = mailbox.ownerUserUid !== undefined && mailbox.ownerUserUid === userUid && !impersonating;
222
+ return (
223
+ <KeyEnrollmentGate
224
+ mailboxUid={mailboxUid}
225
+ mailboxAddress={mailbox.primarySmtpAddress}
226
+ mailboxKeys={mailbox.keys}
227
+ canProvision={isOwner}
228
+ >
229
+ <EncryptionContent canManageKeys={isOwner} />
230
+ </KeyEnrollmentGate>
231
+ );
232
+ }
233
+
234
+ function EncryptionContent({ canManageKeys }: { canManageKeys: boolean }) {
235
+ const { mailboxUid, mailboxes } = useSettingsShell();
236
+ const mailbox = mailboxes.find((mb) => mb.uid === mailboxUid)!;
237
+ const { requestUnlock } = useUnlockPrompt();
238
+
239
+ const [vault, setVault] = useState<KeyVault | null>(null);
240
+ const [loadError, setLoadError] = useState<string | null>(null);
241
+ const [removingMethod, setRemovingMethod] = useState<string | null>(null);
242
+ const [pendingRemoval, setPendingRemoval] = useState<MasterKeyWrap | null>(null);
243
+ const [actionError, setActionError] = useState<string | null>(null);
244
+
245
+ const [newPassword, setNewPassword] = useState("");
246
+ const [confirmNewPassword, setConfirmNewPassword] = useState("");
247
+ const [addingPassword, setAddingPassword] = useState(false);
248
+
249
+ const [regenerating, setRegenerating] = useState(false);
250
+ const [newRecoveryCodes, setNewRecoveryCodes] = useState<string[] | null>(null);
251
+ // Distinguishes the "Save your new recovery codes" screen's copy for the two different actions that
252
+ // land on it - a plain regeneration only invalidates old recovery codes, while a rotation also
253
+ // invalidates every other unlock method, which the copy needs to say plainly.
254
+ const [recoveryCodesReason, setRecoveryCodesReason] = useState<"regenerate" | "rotate">("regenerate");
255
+ const [codesSaved, setCodesSaved] = useState(false);
256
+ const [codesCopied, setCodesCopied] = useState(false);
257
+ // Shown on the "Save your new recovery codes" screen itself - a regeneration or rotation that only
258
+ // partly finished (e.g. some new codes saved but not all, or old codes that couldn't be removed) still
259
+ // lands there, since whatever codes did save must be shown now or never.
260
+ const [codesWarning, setCodesWarning] = useState<string | null>(null);
261
+ // Set when a rotation succeeded but this session couldn't re-unlock under the new master key - the
262
+ // stale keys are destroyed, and the "keys removed" screen explains why.
263
+ const [rotationRelockReason, setRotationRelockReason] = useState<string | null>(null);
264
+
265
+ const [rotationPassword, setRotationPassword] = useState("");
266
+ const [rotationConfirmPassword, setRotationConfirmPassword] = useState("");
267
+ const [rotating, setRotating] = useState(false);
268
+
269
+ const [destroyed, setDestroyed] = useState(false);
270
+ const [idleTimeoutMinutes, setIdleTimeoutMinutesState] = useState(() => getIdleTimeoutMinutes());
271
+ const [localIndexByteBudget, setLocalIndexByteBudgetState] = useState(() => getLocalIndexByteBudget());
272
+ // Both possible defaults (web and Electron) are themselves entries in `LOCAL_INDEX_SIZE_OPTIONS`.
273
+ const localIndexDefaultByteBudgetLabel = LOCAL_INDEX_SIZE_OPTIONS.find((option) => option.bytes === getDefaultLocalIndexByteBudget())!.label;
274
+
275
+ // `mailbox.keys` comes from `SettingsShell`'s one-time `listMailboxes()` fetch - once an ACME
276
+ // enrollment issues, the server has installed a new signing key that fetch never saw. Only this
277
+ // page refetches (via `getMailbox()`) to notice; `null` means "no fresher data yet, use mailbox.keys".
278
+ const [refreshedKeys, setRefreshedKeys] = useState<PublicKey[] | null>(null);
279
+ const displayedKeys = refreshedKeys ?? mailbox.keys ?? [];
280
+ const activeSigningKey = findActivePublicKey(displayedKeys, "sign");
281
+
282
+ // "checking" = a stored enrollment id is being confirmed with the server. Rotation stays disabled unless
283
+ // this is "idle".
284
+ const [signingStatus, setSigningStatus] = useState<"checking" | "idle" | "enrolling" | "pending">(() =>
285
+ readStoredSignEnrollment(mailboxUid!) ? "checking" : "idle",
286
+ );
287
+ const [signingEnrollmentId, setSigningEnrollmentId] = useState<string | null>(null);
288
+ const [signingError, setSigningError] = useState<string | null>(null);
289
+ const rotationBlockedBySigning = signingStatus !== "idle";
290
+ // What is known of the mailbox's signing-certificate enrollment: kept current by the shared tracker (`signing/enrollmentTracker.ts`), which reads
291
+ // the server with backoff while this page is visible, stops when the enrollment ends, and goes on in the app frame when this page is left.
292
+ const enrollment = useEnrollmentSnapshot(mailboxUid);
293
+ const enrollmentResult = enrollment?.result ?? null;
294
+ // How this deployment issues certificates (a public CA by itself, or an administrator by hand): what the card and the request say follows it. Only the owner asks.
295
+ const signingInfo = useSigningEnrollmentInfo(canManageKeys);
296
+
297
+ const [cancelingEnrollment, setCancelingEnrollment] = useState(false);
298
+ const [cancelEnrollmentError, setCancelEnrollmentError] = useState<string | null>(null);
299
+
300
+ // restapi's rekey() drops every old escrow wrap and requires a fresh one for the new master key (see
301
+ // handleRotateKeys), so an escrow wrap in the vault always covers the current master key.
302
+ const hasEscrowWrap = vault?.masterKeyWraps.some((w) => w.method === "escrow") ?? false;
303
+ const [wrappingEscrow, setWrappingEscrow] = useState(false);
304
+ const [escrowError, setEscrowError] = useState<string | null>(null);
305
+
306
+ /**
307
+ * This mailbox's unlocked keys *right now* - never a copy read at render time, which a lock (idle
308
+ * timeout, another tab's sign-out) may since have destroyed. Prompts for the password when locked;
309
+ * rejects if the prompt is dismissed.
310
+ */
311
+ async function currentUnlockedKeys(): Promise<UnlockedKeys> {
312
+ const current = getUnlockedKeys(mailboxUid!);
313
+ if (current && !current.destroyed) {
314
+ return current;
315
+ }
316
+ return requestUnlock(mailboxUid!, displayedKeys);
317
+ }
318
+
319
+ /**
320
+ * `currentUnlockedKeys()`, checked against a freshly fetched vault before anything is wrapped or sealed under its
321
+ * master key: after a rotation on another device the session's master key is dead, and every write built from
322
+ * it would be too. Rejects with `StaleSessionKeysError` then. Resolves to the keys and that fresh vault.
323
+ */
324
+ async function verifiedUnlockedKeys(): Promise<{ current: UnlockedKeys; freshVault: KeyVault }> {
325
+ const current = await currentUnlockedKeys();
326
+ const freshVault = await getKeyVault(mailboxUid!);
327
+ if (!(await masterKeyOpensVault(mailboxUid!, current.masterKey, freshVault))) {
328
+ throw new StaleSessionKeysError();
329
+ }
330
+ return { current, freshVault };
331
+ }
332
+
333
+ /** Drops this session's out-of-date keys (and, as with every key destruction, the local index) and asks for the
334
+ * current password, so the next attempt works with the vault's real master key. */
335
+ function relockStaleKeys() {
336
+ destroyUnlockedKeys(mailboxUid);
337
+ void destroyLocalIndex(mailboxUid!);
338
+ requestUnlock(mailboxUid!, displayedKeys).catch(() => undefined);
339
+ }
340
+
341
+ function errorMessage(err: unknown, fallback: string): string {
342
+ if (err instanceof KeysLockedError) {
343
+ return KEYS_LOCKED_MESSAGE;
344
+ }
345
+ if (err instanceof StaleSessionKeysError) {
346
+ relockStaleKeys();
347
+ return STALE_KEYS_MESSAGE;
348
+ }
349
+ return err instanceof ApiRequestError ? err.message : fallback;
350
+ }
351
+
352
+ async function handleWrapEscrow() {
353
+ setEscrowError(null);
354
+ setWrappingEscrow(true);
355
+ try {
356
+ // Only reachable when mailbox.escrowScopeId is set (see the render guard below).
357
+ const { current } = await verifiedUnlockedKeys();
358
+ const escrowInfo = await getEscrowInfo(mailboxUid!);
359
+ const wrap = await buildEscrowWrap(current.masterKey, escrowInfo.escrowScopeId, fromBase64(escrowInfo.publicKey.publicKey));
360
+ await addMasterKeyWrap(mailboxUid!, wrap);
361
+ await loadVault();
362
+ } catch (err) {
363
+ setEscrowError(errorMessage(err, "Could not add escrow protection for this mailbox."));
364
+ } finally {
365
+ setWrappingEscrow(false);
366
+ }
367
+ }
368
+
369
+ // Follows the enrollment this browser started (its stored id) - or, for the owner, the one the server names as the mailbox's current one (started
370
+ // on another device, or long ago), so a pending one blocks rotation and shows its progress here too.
371
+ useEffect(() => {
372
+ const storedId = readStoredSignEnrollment(mailboxUid!);
373
+ let cancelled = false;
374
+ let release: (() => void) | undefined;
375
+ if (storedId) {
376
+ setSigningStatus("checking");
377
+ release = watchEnrollment(mailboxUid!, storedId);
378
+ } else if (canManageKeys) {
379
+ getCurrentSignEnrollment(mailboxUid!)
380
+ .then((current) => {
381
+ if (!cancelled && current) {
382
+ if (current.status === "pending") {
383
+ storeSignEnrollment(mailboxUid!, current.enrollmentId);
384
+ }
385
+ release = watchCurrentEnrollment(mailboxUid!, current, current.status === "pending");
386
+ }
387
+ })
388
+ .catch(() => undefined);
389
+ }
390
+ return () => {
391
+ cancelled = true;
392
+ release?.();
393
+ };
394
+ }, [mailboxUid, canManageKeys]);
395
+
396
+ // What the tracker knows becomes this page's state: pending (or not heard from yet - assume it is still going, and let the polling keep asking) blocks
397
+ // rotation; an enrollment that ended, or that the server no longer knows, does not.
398
+ useEffect(() => {
399
+ if (!enrollment) {
400
+ return;
401
+ }
402
+ if (enrollmentResult?.status === "pending" || (!enrollmentResult && enrollment.offline)) {
403
+ setSigningEnrollmentId(enrollment.enrollmentId);
404
+ setSigningStatus("pending");
405
+ } else if (enrollmentResult || enrollment.gone) {
406
+ setSigningEnrollmentId(null);
407
+ setSigningStatus("idle");
408
+ }
409
+ }, [enrollment?.enrollmentId, enrollmentResult?.status, enrollment?.gone, enrollment?.offline]);
410
+
411
+ // Once a certificate is issued the server has installed a new signing key that `mailbox.keys` (fetched once by the shell) never saw. The server installs it
412
+ // with a job a few minutes after the CA issues it (`installedAt`): until then the key is not there, so ask again every 15 s (for ten minutes at most).
413
+ const issuedUntil = timeOf(enrollmentResult?.notAfter);
414
+ const keyIsCurrent = !!activeSigningKey && (issuedUntil === undefined || issuedUntil <= activeSigningKey.notAfter + 60_000);
415
+ useEffect(() => {
416
+ if (enrollmentResult?.status !== "issued" || keyIsCurrent) {
417
+ return;
418
+ }
419
+ let cancelled = false;
420
+ let timer: ReturnType<typeof setTimeout> | undefined;
421
+ let attempts = 0;
422
+ const refresh = () => {
423
+ getMailbox(mailboxUid!)
424
+ .then((refreshed) => {
425
+ if (cancelled) {
426
+ return;
427
+ }
428
+ const keys = refreshed.keys ?? [];
429
+ setRefreshedKeys(keys);
430
+ // Not installed yet, and the server said it is on its way: look again.
431
+ if (isInstalling(enrollmentResult) && ++attempts < INSTALL_WATCH_ATTEMPTS && !findActivePublicKey(keys, "sign")) {
432
+ timer = setTimeout(refresh, INSTALL_WATCH_INTERVAL_MS);
433
+ }
434
+ })
435
+ .catch(() => undefined);
436
+ };
437
+ refresh();
438
+ return () => {
439
+ cancelled = true;
440
+ clearTimeout(timer);
441
+ };
442
+ }, [enrollmentResult?.status, enrollmentResult?.installedAt, enrollment?.enrollmentId, keyIsCurrent, mailboxUid]);
443
+
444
+ /** Cancels the pending enrollment (restapi's owner-only DELETE), which is what lets rotation run again. */
445
+ async function handleCancelEnrollment(enrollmentId: string) {
446
+ setCancelEnrollmentError(null);
447
+ setCancelingEnrollment(true);
448
+ try {
449
+ const result = await cancelSignEnrollment(mailboxUid!, enrollmentId);
450
+ if (result.status === "pending") {
451
+ setCancelEnrollmentError("The enrollment couldn't be cancelled yet. Try again.");
452
+ } else if (result.status === "failed") {
453
+ // Cancelled - the "failure" is the cancellation itself, not something to report.
454
+ forgetEnrollment(mailboxUid!);
455
+ storeSignEnrollment(mailboxUid!, null);
456
+ setSigningStatus("idle");
457
+ setSigningEnrollmentId(null);
458
+ } else {
459
+ // Issued before the cancel landed: it finishes like any other issued enrollment.
460
+ recordEnrollmentResult(mailboxUid!, enrollmentId, result);
461
+ }
462
+ } catch (err) {
463
+ if (err instanceof ApiRequestError && err.status === 404) {
464
+ // The server no longer knows this enrollment, so nothing is pending.
465
+ forgetEnrollment(mailboxUid!);
466
+ storeSignEnrollment(mailboxUid!, null);
467
+ setSigningStatus("idle");
468
+ setSigningEnrollmentId(null);
469
+ } else {
470
+ setCancelEnrollmentError(errorMessage(err, "Could not cancel the signing certificate enrollment."));
471
+ }
472
+ } finally {
473
+ setCancelingEnrollment(false);
474
+ }
475
+ }
476
+
477
+ async function handleEnrollSigning() {
478
+ setSigningError(null);
479
+ setSigningStatus("enrolling");
480
+ try {
481
+ const { current } = await verifiedUnlockedKeys();
482
+ const { keyPair, csrPem } = await generateKeyPairWithCsr(mailbox.primarySmtpAddress, "sign");
483
+ const privateKeyRaw = await exportPrivateKeyPkcs8(keyPair.privateKey);
484
+ const wrappedKeySealed = await sealWithKey(
485
+ current.masterKey,
486
+ privateKeyRaw,
487
+ buildAad(mailboxUid!, SIGNING_PRIVATE_KEY_AAD_PURPOSE),
488
+ );
489
+ const { enrollmentId } = await startSignEnrollment(mailboxUid!, {
490
+ csr: csrPem,
491
+ wrappedKey: { ciphertext: wrappedKeySealed.ciphertext, nonce: wrappedKeySealed.nonce, algorithm: "AES-256-GCM" },
492
+ });
493
+ storeSignEnrollment(mailboxUid!, enrollmentId);
494
+ // Followed from now on (and after this page is left): the server has just accepted it, so it is pending - no need to ask first.
495
+ watchEnrollment(mailboxUid!, enrollmentId, { initial: { status: "pending" } });
496
+ setSigningEnrollmentId(enrollmentId);
497
+ setSigningStatus("pending");
498
+ } catch (err) {
499
+ setSigningError(errorMessage(err, "Could not start signing certificate enrollment."));
500
+ setSigningStatus("idle");
501
+ }
502
+ }
503
+
504
+ function handleIdleTimeoutChange(e: React.ChangeEvent<HTMLSelectElement>) {
505
+ const minutes = Number(e.target.value);
506
+ setIdleTimeoutMinutes(minutes);
507
+ setIdleTimeoutMinutesState(minutes);
508
+ }
509
+
510
+ function handleLocalIndexByteBudgetChange(e: React.ChangeEvent<HTMLSelectElement>) {
511
+ const bytes = Number(e.target.value);
512
+ setLocalIndexByteBudget(bytes);
513
+ setLocalIndexByteBudgetState(bytes);
514
+ }
515
+
516
+ function loadVault() {
517
+ return getKeyVault(mailboxUid!)
518
+ .then(setVault)
519
+ .catch((err) => setLoadError(err instanceof ApiRequestError ? err.message : "Could not load your key vault."));
520
+ }
521
+
522
+ useEffect(() => {
523
+ void loadVault();
524
+ }, [mailboxUid]);
525
+
526
+ const passwordWraps = vault?.masterKeyWraps.filter((w) => w.method === "password") ?? [];
527
+ const recoveryWraps = vault?.masterKeyWraps.filter((w) => w.method === "recovery") ?? [];
528
+ const otherWraps = vault?.masterKeyWraps.filter((w) => w.method !== "password" && w.method !== "recovery") ?? [];
529
+
530
+ // The owner's own unlock methods are every non-escrow wrap - restapi refuses (409) to remove the last
531
+ // one. The app unlocks with a password or a single-use recovery code (passkeys have no unlock path yet),
532
+ // so the last password wrap is never offered for removal either: without it only a dwindling set of
533
+ // recovery codes would be left. A forgotten password is replaced after a recovery-code unlock instead
534
+ // (`RecoveryCodeUnlock.tsx`).
535
+ const ownUnlockWrapCount = vault?.masterKeyWraps.filter((w) => w.method !== "escrow").length ?? 0;
536
+ function canRemoveWrap(wrap: MasterKeyWrap): boolean {
537
+ return wrap.method === "password" ? passwordWraps.length > 1 : ownUnlockWrapCount > 1;
538
+ }
539
+
540
+ async function handleRemove(wrap: MasterKeyWrap) {
541
+ setPendingRemoval(null);
542
+ const key = `${wrap.method}:${wrap.methodId ?? ""}`;
543
+ setRemovingMethod(key);
544
+ setActionError(null);
545
+ try {
546
+ await removeMasterKeyWrap(mailboxUid!, wrap.method, wrap.methodId);
547
+ await loadVault();
548
+ } catch (err) {
549
+ // A pop-up (the confirmation is already closed); `actionError` keeps the guidance tied to the forms below.
550
+ notifyApiError(err, "Couldn't remove this unlock method");
551
+ } finally {
552
+ setRemovingMethod(null);
553
+ }
554
+ }
555
+
556
+ /** Only offered while the vault has no password wrap at all - `unlockWithPassword()` only ever tries
557
+ * the first one, so a second password would be accepted here but never unlock anything. */
558
+ async function handleAddPassword(e: FormEvent) {
559
+ e.preventDefault();
560
+ if (newPassword.length < MIN_PASSWORD_LENGTH) {
561
+ setActionError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
562
+ return;
563
+ }
564
+ if (newPassword !== confirmNewPassword) {
565
+ setActionError("Passwords do not match.");
566
+ return;
567
+ }
568
+ setActionError(null);
569
+ setAddingPassword(true);
570
+ try {
571
+ const { current } = await verifiedUnlockedKeys();
572
+ const wrap = await buildPasswordWrap(mailboxUid!, current.masterKey, newPassword);
573
+ await addMasterKeyWrap(mailboxUid!, wrap);
574
+ setNewPassword("");
575
+ setConfirmNewPassword("");
576
+ await loadVault();
577
+ } catch (err) {
578
+ setActionError(errorMessage(err, "Could not add this password."));
579
+ } finally {
580
+ setAddingPassword(false);
581
+ }
582
+ }
583
+
584
+ /**
585
+ * Adds the new recovery wraps *before* removing any old one, so a failure part-way through can never
586
+ * leave the mailbox with fewer working recovery codes than it started with. `buildRecoveryWraps()`
587
+ * labels its wraps `recovery-1..N` - the same labels the old set most likely has, and restapi's
588
+ * `removeMasterKeyWrap()` removes every wrap matching a `methodId` - so each new wrap gets a
589
+ * batch-unique label first, letting the old ones be removed by their own `methodId` without touching
590
+ * the new ones.
591
+ *
592
+ * The vault holds at most `MAX_MASTER_KEY_WRAPS` wraps. Checked against a fresh copy of the vault before
593
+ * anything is written: when the new set only fits once old recovery codes are gone, an old code is
594
+ * removed just before each new one that has no room yet (the working-code count never drops below the
595
+ * original set's), and when it can't fit even then, nothing is written and the user is told how many
596
+ * other methods to remove.
597
+ */
598
+ async function handleRegenerateRecoveryCodes() {
599
+ // Only reachable once the vault has loaded (the button is disabled until then).
600
+ setActionError(null);
601
+ setRegenerating(true);
602
+ let built: { wraps: MasterKeyWrap[]; codes: string[] };
603
+ let oldRecoveryWraps: MasterKeyWrap[];
604
+ let free: number;
605
+ try {
606
+ const { current, freshVault } = await verifiedUnlockedKeys();
607
+ oldRecoveryWraps = freshVault.masterKeyWraps.filter((w) => w.method === "recovery");
608
+ free = MAX_MASTER_KEY_WRAPS - freshVault.masterKeyWraps.length;
609
+ built = await buildRecoveryWraps(mailboxUid!, current.masterKey);
610
+ const shortfall = built.wraps.length - (free + oldRecoveryWraps.length);
611
+ if (shortfall > 0) {
612
+ setVault(freshVault);
613
+ setActionError(
614
+ `Your key vault can hold at most ${MAX_MASTER_KEY_WRAPS} unlock methods and already has ${freshVault.masterKeyWraps.length}. ` +
615
+ `Regenerating needs room for ${built.wraps.length} new recovery codes, so remove ${shortfall} other unlock ` +
616
+ `method${shortfall === 1 ? "" : "s"} (such as an extra password or passkey) below first.`,
617
+ );
618
+ setRegenerating(false);
619
+ return;
620
+ }
621
+ } catch (err) {
622
+ setActionError(errorMessage(err, "Could not regenerate recovery codes."));
623
+ setRegenerating(false);
624
+ return;
625
+ }
626
+ const batch = Date.now().toString(36);
627
+ const pendingOld = [...oldRecoveryWraps];
628
+ let removedEarly = 0;
629
+ const savedCodes: string[] = [];
630
+ let addError: unknown = null;
631
+ for (let i = 0; i < built.wraps.length; i++) {
632
+ // The old wrap removed to make room for this new one, if any - put back if the add then fails, so a
633
+ // failed add never costs a working code.
634
+ let removedForThis: MasterKeyWrap | null = null;
635
+ try {
636
+ if (free <= 0) {
637
+ // Guaranteed non-empty by the shortfall check above.
638
+ const old = pendingOld.shift()!;
639
+ await removeMasterKeyWrap(mailboxUid!, "recovery", old.methodId);
640
+ removedForThis = old;
641
+ removedEarly++;
642
+ free++;
643
+ }
644
+ await addMasterKeyWrap(mailboxUid!, { ...built.wraps[i], methodId: `recovery-${batch}-${i + 1}` });
645
+ free--;
646
+ savedCodes.push(built.codes[i]);
647
+ } catch (err) {
648
+ addError = err;
649
+ if (removedForThis) {
650
+ try {
651
+ await addMasterKeyWrap(mailboxUid!, removedForThis);
652
+ removedEarly--;
653
+ } catch {
654
+ // Still counted in removedEarly, which the messages below report.
655
+ }
656
+ }
657
+ break;
658
+ }
659
+ }
660
+
661
+ const removedEarlyNote =
662
+ removedEarly > 0
663
+ ? `${removedEarly} of your old recovery codes had to be removed to make room; the rest were kept and still work`
664
+ : "Your old recovery codes were kept and still work";
665
+
666
+ if (savedCodes.length === 0) {
667
+ const message = addError instanceof ApiRequestError ? addError.message : "Could not regenerate recovery codes.";
668
+ setActionError(removedEarly > 0 ? `${message} ${removedEarlyNote}.` : message);
669
+ setRegenerating(false);
670
+ await loadVault();
671
+ return;
672
+ }
673
+
674
+ let warning: string | null = null;
675
+ if (savedCodes.length < built.wraps.length) {
676
+ // Remaining old codes are deliberately left in place - the new set is incomplete, so the old one
677
+ // is still part of the user's recovery safety net.
678
+ warning = `Only ${savedCodes.length} of ${built.wraps.length} new recovery codes could be saved${
679
+ addError instanceof ApiRequestError ? ` (${addError.message})` : ""
680
+ }. ${removedEarlyNote} - save the codes below, then try regenerating again.`;
681
+ } else {
682
+ let notRemoved = 0;
683
+ for (const wrap of pendingOld) {
684
+ try {
685
+ await removeMasterKeyWrap(mailboxUid!, "recovery", wrap.methodId);
686
+ } catch {
687
+ notRemoved++;
688
+ }
689
+ }
690
+ if (notRemoved > 0) {
691
+ warning = `${notRemoved} of your old recovery codes could not be removed and still work. Remove them from the unlock methods list, or regenerate again.`;
692
+ }
693
+ }
694
+
695
+ setRecoveryCodesReason("regenerate");
696
+ setCodesWarning(warning);
697
+ setNewRecoveryCodes(savedCodes);
698
+ setCodesSaved(false);
699
+ setRegenerating(false);
700
+ await loadVault();
701
+ }
702
+
703
+ async function handleCopyCodes() {
704
+ try {
705
+ // Only reachable via the button below, which never renders while newRecoveryCodes is null.
706
+ await navigator.clipboard.writeText(newRecoveryCodes!.join("\n"));
707
+ setCodesCopied(true);
708
+ setTimeout(() => setCodesCopied(false), 2000);
709
+ } catch {
710
+ // Clipboard access can be denied by the browser - the codes are still selectable/copyable by
711
+ // hand from the list below.
712
+ }
713
+ }
714
+
715
+ /**
716
+ * Real revocation for a captured wrap (`keyvaultApi.ts`'s `rekey()` - see that function's own doc
717
+ * comment): re-wraps every private key in a freshly fetched vault under a brand new master key
718
+ * (`rewrapVaultPrivateKeys()` - all of them, active or not, since `rekey()` replaces `wrappedKeys`
719
+ * wholesale), wraps that new MK under a freshly entered password and a fresh set of recovery codes,
720
+ * and atomically replaces the vault - the enrolled keypair/certificate itself is unchanged (restapi's
721
+ * own `rekey()` rejects anything else, so `keys` is a fresh copy of the mailbox's, not this page's
722
+ * possibly stale list), only how it's protected. Every *other* unlock method this mailbox had stops
723
+ * working the instant this succeeds, since `rekey()` replaces `masterKeyWraps` wholesale - the whole
724
+ * point, for a captured-wrap scenario where it's unclear which method was compromised.
725
+ */
726
+ async function handleRotateKeys(e: FormEvent) {
727
+ e.preventDefault();
728
+ if (rotationBlockedBySigning) {
729
+ // The form's controls are disabled in this state too; this also covers an implicit submit.
730
+ return;
731
+ }
732
+ if (rotationPassword.length < MIN_PASSWORD_LENGTH) {
733
+ setActionError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
734
+ return;
735
+ }
736
+ if (rotationPassword !== rotationConfirmPassword) {
737
+ setActionError("Passwords do not match.");
738
+ return;
739
+ }
740
+ setActionError(null);
741
+ setEscrowError(null);
742
+ setRotating(true);
743
+ let keys: PublicKey[];
744
+ let codes: string[];
745
+ try {
746
+ const current = await currentUnlockedKeys();
747
+ const [freshVault, freshMailbox] = await Promise.all([getKeyVault(mailboxUid!), getMailbox(mailboxUid!)]);
748
+ keys = freshMailbox.keys ?? [];
749
+ // A session key that opens nothing is stale (rotated elsewhere) rather than missing some keys.
750
+ if (!(await masterKeyOpensVault(mailboxUid!, current.masterKey, freshVault))) {
751
+ throw new StaleSessionKeysError();
752
+ }
753
+ const rewrapped = await rewrapVaultPrivateKeys(mailboxUid!, current.masterKey, freshVault.wrappedKeys);
754
+ const mk = rewrapped.mk;
755
+ const passwordWrap = await buildPasswordWrap(mailboxUid!, mk, rotationPassword);
756
+ const recovery = await buildRecoveryWraps(mailboxUid!, mk);
757
+ codes = recovery.codes;
758
+ const masterKeyWraps = [passwordWrap, ...recovery.wraps];
759
+ // restapi's rekey() drops the old escrow wraps, and requires a replacement escrow wrap only when the vault
760
+ // already holds one and the mailbox's escrow scope still exists - so one is carried over exactly then, in
761
+ // the same request. A mailbox assigned a scope but never escrowed isn't silently escrowed by a rotation,
762
+ // and a deleted scope (escrow-info 404s) needs nothing. Any other failure to build it aborts the rotation -
763
+ // rotating without it would end escrow coverage. restapi's own 409 stays the final word.
764
+ if (freshVault.masterKeyWraps.some((w) => w.method === "escrow")) {
765
+ try {
766
+ const escrowInfo = await getEscrowInfo(mailboxUid!);
767
+ masterKeyWraps.push(await buildEscrowWrap(mk, escrowInfo.escrowScopeId, fromBase64(escrowInfo.publicKey.publicKey)));
768
+ } catch (err) {
769
+ if (!(err instanceof ApiRequestError && err.status === 404)) {
770
+ throw new EscrowWrapUnavailableError(err);
771
+ }
772
+ }
773
+ }
774
+ await rekey(mailboxUid!, { wrappedKeys: rewrapped.wrappedKeys, masterKeyWraps, keys });
775
+ } catch (err) {
776
+ if (err instanceof UncoveredVaultKeysError) {
777
+ setActionError(
778
+ `Your key vault holds ${err.fingerprints.length === 1 ? "a private key" : `${err.fingerprints.length} private keys`} this session can't open (${err.fingerprints.join(", ")}), so rotating now would lose ${err.fingerprints.length === 1 ? "it" : "them"}. Nothing was changed.`,
779
+ );
780
+ } else if (err instanceof EscrowWrapUnavailableError) {
781
+ const reason = err.cause instanceof ApiRequestError ? ` (${err.cause.message})` : "";
782
+ setActionError(
783
+ `Your keys were not rotated: this mailbox is under escrow, and escrow protection for the new keys couldn't be prepared${reason}. Nothing was changed.`,
784
+ );
785
+ } else if (err instanceof ApiRequestError && err.status === 409) {
786
+ setActionError(ROTATION_CONFLICT_MESSAGE);
787
+ } else {
788
+ setActionError(errorMessage(err, "Could not rotate your encryption keys."));
789
+ }
790
+ setRotating(false);
791
+ return;
792
+ }
793
+
794
+ // The rotation is committed - every old unlock method is already dead, so these codes must be on
795
+ // screen right now, before anything below that could fail or hang.
796
+ const newPassword = rotationPassword;
797
+ setRotationPassword("");
798
+ setRotationConfirmPassword("");
799
+ setRecoveryCodesReason("rotate");
800
+ setCodesWarning(null);
801
+ setNewRecoveryCodes(codes);
802
+ setCodesSaved(false);
803
+ setRefreshedKeys(keys);
804
+ try {
805
+ // Refreshes this session's own cached keys against the new MK, via the password we just set -
806
+ // the underlying private key material didn't change, but the stale MK in memory would silently
807
+ // build wrong future wraps if left as-is. If that fails, the stale keys are destroyed instead.
808
+ try {
809
+ await unlockWithPassword(mailboxUid!, keys, newPassword);
810
+ } catch {
811
+ destroyUnlockedKeys(mailboxUid);
812
+ void destroyLocalIndex(mailboxUid!);
813
+ setRotationRelockReason(
814
+ "Your keys were rotated, but this session couldn't unlock them again with your new password. Reload the page and unlock with your new password.",
815
+ );
816
+ setDestroyed(true);
817
+ }
818
+ await loadVault();
819
+ } finally {
820
+ setRotating(false);
821
+ }
822
+ }
823
+
824
+ function handleDestroyKeysNow() {
825
+ destroyUnlockedKeys(mailboxUid);
826
+ // Spec §11: the Tier 2 local index MUST be destroyed on the same events that destroy unlocked
827
+ // keys. Not awaited - this page's own "keys removed" confirmation shouldn't wait on it, and
828
+ // destroyLocalIndex() never throws either way. Reached directly here (rather than relying on
829
+ // LocalIndexLifecycle.tsx's polling, which only runs while Mail's own MailShell is mounted) since
830
+ // this button lives on the Settings page, which never mounts that component.
831
+ void destroyLocalIndex(mailboxUid!);
832
+ setDestroyed(true);
833
+ }
834
+
835
+ // Checked before `destroyed` - a rotation whose re-unlock failed destroys this session's keys, but its
836
+ // new recovery codes still have to be seen (and acknowledged) first.
837
+ if (newRecoveryCodes) {
838
+ return (
839
+ <div className="flex-1 min-w-0 overflow-y-auto p-6">
840
+ <div className="max-w-xl">
841
+ <h1 className="text-lg font-bold tracking-tight mb-1">Save your new recovery codes</h1>
842
+ {codesWarning && <Alert>{codesWarning}</Alert>}
843
+ <p className="text-sm text-text-muted mb-4">
844
+ {recoveryCodesReason === "rotate"
845
+ ? "Your keys have been rotated - every previous unlock method (password, recovery codes, or anything else on file) has stopped working. "
846
+ : codesWarning
847
+ ? ""
848
+ : "Your old recovery codes no longer work. "}
849
+ If you lose your password, these new codes are the only way to recover your encrypted mail.
850
+ Each code can be used once. Store them somewhere safe — they will not be shown again.
851
+ </p>
852
+ <ul className="grid grid-cols-2 gap-2 mb-3 font-mono text-sm">
853
+ {newRecoveryCodes.map((code) => (
854
+ <li key={code} className="bg-surface-alt rounded-sm py-1.5 px-2 text-center">
855
+ {code}
856
+ </li>
857
+ ))}
858
+ </ul>
859
+ <Button type="button" variant="secondary" className="!w-auto mb-5" onClick={handleCopyCodes}>
860
+ {codesCopied ? "Copied" : "Copy codes to clipboard"}
861
+ </Button>
862
+ <label className="flex items-center gap-2 text-sm mb-4">
863
+ <input type="checkbox" checked={codesSaved} onChange={(e) => setCodesSaved(e.target.checked)} />
864
+ I have saved these recovery codes in a safe place.
865
+ </label>
866
+ <Button type="button" disabled={!codesSaved} onClick={() => setNewRecoveryCodes(null)} className="!w-auto">
867
+ Done
868
+ </Button>
869
+ </div>
870
+ </div>
871
+ );
872
+ }
873
+
874
+ if (destroyed) {
875
+ return (
876
+ <div className="flex-1 min-w-0 overflow-y-auto p-6">
877
+ <div className="max-w-xl">
878
+ <Alert>
879
+ {rotationRelockReason ??
880
+ "Your encryption keys have been removed from this session. Reload the page (or open Mail again) to unlock them when you need to read or send encrypted mail."}
881
+ </Alert>
882
+ </div>
883
+ </div>
884
+ );
885
+ }
886
+
887
+ // The "Digital signatures" section: a pending enrollment first (even while an older certificate is still active - it is a renewal); else the
888
+ // active certificate; else how the last enrollment ended (a failure, or a certificate that has since expired); else nothing has been requested.
889
+ const expiredSigningKey = displayedKeys
890
+ .filter((key) => key.useType === "sign" && !key.revokedAt && key.notAfter <= Date.now())
891
+ .sort((a, b) => b.notAfter - a.notAfter)[0];
892
+ // A certificate the CA has issued but the server has not installed yet counts as issued too (there is no key to show yet: the card says it is installing).
893
+ const installing = canManageKeys && isInstalling(enrollmentResult);
894
+ const certificateMode: SigningCertificateMode | null =
895
+ signingStatus === "pending"
896
+ ? "pending"
897
+ : activeSigningKey || installing
898
+ ? canManageKeys
899
+ ? "issued"
900
+ : null
901
+ : canManageKeys && enrollmentResult?.status === "failed"
902
+ ? "failed"
903
+ : canManageKeys && expiredSigningKey
904
+ ? "expired"
905
+ : null;
906
+ const certificateEnrollment = certificateMode === "issued" && enrollmentResult?.status !== "issued" ? null : certificateMode === "expired" ? null : enrollmentResult;
907
+
908
+ return (
909
+ <div className="flex-1 min-w-0 overflow-y-auto p-6">
910
+ <div className="max-w-xl flex flex-col gap-6">
911
+ <div>
912
+ <h1 className="text-lg font-bold tracking-tight mb-1">Encryption</h1>
913
+ <p className="text-sm text-text-muted">
914
+ Manage how {mailbox.displayName} unlocks its encryption keys on this and other devices.
915
+ </p>
916
+ </div>
917
+
918
+ {loadError && <Alert>{loadError}</Alert>}
919
+ {actionError && <Alert>{actionError}</Alert>}
920
+ {!canManageKeys && (
921
+ <p className="text-sm text-text-muted">
922
+ Only this mailbox&rsquo;s owner, signed in as themselves, can change its encryption keys and
923
+ unlock methods.
924
+ </p>
925
+ )}
926
+
927
+ <div>
928
+ <h2 className="text-sm font-semibold mb-2">Encryption keys</h2>
929
+ {displayedKeys.length > 0 ? (
930
+ <ul className="flex flex-col gap-1 text-sm">
931
+ {displayedKeys.map((key) => (
932
+ <li key={key.fingerprint} className="font-mono text-xs">
933
+ {key.useType === "sign" ? "Signing" : "Encryption"} key: {key.fingerprint}
934
+ {key.revokedAt &&
935
+ (key.revocationReason === "superseded" ? (
936
+ <span className="text-text-muted"> (superseded)</span>
937
+ ) : (
938
+ <span className="text-danger"> (revoked)</span>
939
+ ))}
940
+ </li>
941
+ ))}
942
+ </ul>
943
+ ) : (
944
+ <p className="text-sm text-text-muted">No keys enrolled yet.</p>
945
+ )}
946
+ </div>
947
+
948
+ <div>
949
+ <h2 className="text-sm font-semibold mb-2">Digital signatures</h2>
950
+ {activeSigningKey && signingStatus !== "pending" && (
951
+ <p className={["text-sm text-text-muted", certificateMode ? "mb-3" : ""].join(" ")}>
952
+ Enabled — outgoing mail from this mailbox is signed with a publicly-trusted
953
+ certificate.
954
+ </p>
955
+ )}
956
+ {certificateMode && (
957
+ <div className="flex flex-col gap-2">
958
+ {certificateMode === "failed" && signingError && <Alert>{signingError}</Alert>}
959
+ <SigningCertificateCard
960
+ mode={certificateMode}
961
+ enrollment={certificateEnrollment}
962
+ snapshot={enrollment}
963
+ info={signingInfo}
964
+ address={mailbox.primarySmtpAddress}
965
+ keyNotAfter={certificateMode === "expired" ? expiredSigningKey?.notAfter : activeSigningKey?.notAfter}
966
+ canRequest={canManageKeys}
967
+ requesting={signingStatus === "enrolling"}
968
+ onCheck={canManageKeys ? () => void checkEnrollmentNow(mailboxUid!) : undefined}
969
+ onRequest={handleEnrollSigning}
970
+ />
971
+ </div>
972
+ )}
973
+ {!activeSigningKey && !certificateMode && !canManageKeys ? (
974
+ <p className="text-sm text-text-muted">Not enabled.</p>
975
+ ) : activeSigningKey || certificateMode ? null : (
976
+ <div className="flex flex-col gap-2">
977
+ <p className="text-xs text-text-muted">
978
+ Lets recipients verify that mail from this mailbox is genuinely from you.
979
+ Optional — encryption already works without it.
980
+ </p>
981
+ {beforeRequestText(signingInfo, mailbox.primarySmtpAddress) && (
982
+ <p className="text-xs text-text-muted">{beforeRequestText(signingInfo, mailbox.primarySmtpAddress)}</p>
983
+ )}
984
+ {healthWarning(signingInfo) && <Alert>{healthWarning(signingInfo)}</Alert>}
985
+ {enrollment?.gone && <Alert>{STALE_REQUEST_TEXT}</Alert>}
986
+ {signingError && <Alert>{signingError}</Alert>}
987
+ {signingInfo?.backend !== "none" && (
988
+ <Button
989
+ type="button"
990
+ variant="secondary"
991
+ className="!w-auto"
992
+ loading={signingStatus === "enrolling"}
993
+ disabled={signingStatus !== "idle"}
994
+ onClick={handleEnrollSigning}
995
+ >
996
+ Enable digital signatures
997
+ </Button>
998
+ )}
999
+ </div>
1000
+ )}
1001
+ </div>
1002
+
1003
+ {mailbox.escrowScopeId && (
1004
+ <div>
1005
+ <h2 className="text-sm font-semibold mb-2">Escrow</h2>
1006
+ {escrowError && <Alert>{escrowError}</Alert>}
1007
+ {hasEscrowWrap ? (
1008
+ <p className="text-sm text-text-muted">
1009
+ This mailbox is under legal/compliance escrow — an authorized holder in your
1010
+ organization can recover its encrypted mail if needed. This does not weaken
1011
+ protection against anyone else.
1012
+ </p>
1013
+ ) : (
1014
+ <div className="flex flex-col gap-2">
1015
+ <p className="text-xs text-text-muted">
1016
+ Your organization has assigned this mailbox to an escrow scope, but nothing has been
1017
+ protected yet — an authorized holder cannot recover this mailbox&rsquo;s encrypted mail
1018
+ until you complete this step.{" "}
1019
+ This does not weaken protection against anyone else.
1020
+ </p>
1021
+ {canManageKeys && (
1022
+ <Button
1023
+ type="button"
1024
+ variant="secondary"
1025
+ className="!w-auto"
1026
+ loading={wrappingEscrow}
1027
+ disabled={wrappingEscrow}
1028
+ onClick={handleWrapEscrow}
1029
+ >
1030
+ Add escrow protection
1031
+ </Button>
1032
+ )}
1033
+ </div>
1034
+ )}
1035
+ </div>
1036
+ )}
1037
+
1038
+ <div>
1039
+ <h2 className="text-sm font-semibold mb-2">Unlock methods</h2>
1040
+ <p className="text-xs text-text-muted mb-3">
1041
+ Removing a method here stops it from being usable to unlock this mailbox going forward,
1042
+ but it is <strong>not</strong> full revocation — anyone who already captured a wrapped
1043
+ copy and knows its secret could still use it. For real revocation (e.g. after a lost
1044
+ device), rotate your keys entirely below instead.
1045
+ </p>
1046
+ {vault && vault.masterKeyWraps.length > 0 ? (
1047
+ <ul className="flex flex-col gap-2">
1048
+ {[...passwordWraps, ...recoveryWraps, ...otherWraps].map((wrap) => {
1049
+ const key = `${wrap.method}:${wrap.methodId ?? ""}`;
1050
+ return (
1051
+ <li key={key} className="flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm">
1052
+ <span>{METHOD_LABELS[wrap.method] ?? wrap.method}</span>
1053
+ {wrap.method !== "escrow" &&
1054
+ canManageKeys &&
1055
+ (canRemoveWrap(wrap) ? (
1056
+ <Button
1057
+ type="button"
1058
+ variant="text"
1059
+ className="!w-auto text-danger"
1060
+ loading={removingMethod === key}
1061
+ disabled={removingMethod !== null}
1062
+ onClick={() => setPendingRemoval(wrap)}
1063
+ >
1064
+ Remove
1065
+ </Button>
1066
+ ) : (
1067
+ <span className="text-xs text-text-muted">
1068
+ {ownUnlockWrapCount > 1 ? "Needed to unlock" : "Your only unlock method"}
1069
+ </span>
1070
+ ))}
1071
+ </li>
1072
+ );
1073
+ })}
1074
+ </ul>
1075
+ ) : (
1076
+ <p className="text-sm text-text-muted">No unlock methods on file.</p>
1077
+ )}
1078
+ <Modal open={pendingRemoval !== null} onClose={() => setPendingRemoval(null)} title="Remove unlock method">
1079
+ <p className="text-sm mb-5">
1080
+ Remove this unlock method? It will no longer unlock this mailbox&rsquo;s encrypted mail on any device.
1081
+ </p>
1082
+ <div className="flex gap-3 justify-end">
1083
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setPendingRemoval(null)}>
1084
+ Cancel
1085
+ </Button>
1086
+ <Button
1087
+ type="button"
1088
+ className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger"
1089
+ onClick={() => handleRemove(pendingRemoval!)}
1090
+ >
1091
+ Remove method
1092
+ </Button>
1093
+ </div>
1094
+ </Modal>
1095
+ </div>
1096
+
1097
+ {canManageKeys && (
1098
+ <>
1099
+ {passwordWraps.length === 0 ? (
1100
+ vault && (
1101
+ <form onSubmit={handleAddPassword} className="flex flex-col gap-2">
1102
+ <h2 className="text-sm font-semibold">Add a password</h2>
1103
+ <input
1104
+ type="password"
1105
+ aria-label="New password"
1106
+ placeholder="New password"
1107
+ className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
1108
+ value={newPassword}
1109
+ onChange={(e) => setNewPassword(e.target.value)}
1110
+ disabled={addingPassword}
1111
+ autoComplete="new-password"
1112
+ />
1113
+ <input
1114
+ type="password"
1115
+ aria-label="Confirm new password"
1116
+ placeholder="Confirm new password"
1117
+ className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
1118
+ value={confirmNewPassword}
1119
+ onChange={(e) => setConfirmNewPassword(e.target.value)}
1120
+ disabled={addingPassword}
1121
+ autoComplete="new-password"
1122
+ />
1123
+ <div>
1124
+ <Button type="submit" loading={addingPassword} disabled={addingPassword} className="!w-auto">
1125
+ Add password
1126
+ </Button>
1127
+ </div>
1128
+ </form>
1129
+ )
1130
+ ) : (
1131
+ <div>
1132
+ <h2 className="text-sm font-semibold mb-2">Changing your password</h2>
1133
+ <p className="text-xs text-text-muted">
1134
+ Only one password can unlock this mailbox. To change it, rotate your keys below
1135
+ - that sets a new password and replaces every other unlock method.
1136
+ </p>
1137
+ </div>
1138
+ )}
1139
+ <div>
1140
+ <h2 className="text-sm font-semibold mb-2">Recovery codes</h2>
1141
+ <p className="text-xs text-text-muted mb-3">
1142
+ Regenerating replaces all of your existing recovery codes — old ones stop working
1143
+ immediately.
1144
+ </p>
1145
+ <Button
1146
+ type="button"
1147
+ variant="secondary"
1148
+ loading={regenerating}
1149
+ disabled={regenerating || !vault}
1150
+ onClick={handleRegenerateRecoveryCodes}
1151
+ className="!w-auto"
1152
+ >
1153
+ Regenerate recovery codes
1154
+ </Button>
1155
+ </div>
1156
+
1157
+ <form onSubmit={handleRotateKeys} className="flex flex-col gap-2 border-t border-border pt-6">
1158
+ <h2 className="text-sm font-semibold">Rotate keys</h2>
1159
+ <p className="text-xs text-text-muted mb-1">
1160
+ Real revocation, for when a device or an unlock method may have been compromised.
1161
+ Re-protects your existing encryption key under a brand new master key - your signing/
1162
+ encryption keypair itself doesn&rsquo;t change, so mail you&rsquo;ve already sent or
1163
+ received still decrypts normally. Every current unlock method (password, recovery codes,
1164
+ and anything else on file) stops working immediately; you&rsquo;ll set a new password and
1165
+ get new recovery codes below.
1166
+ </p>
1167
+ {rotationBlockedBySigning && (
1168
+ <p className="text-xs text-text-muted">
1169
+ {signingStatus === "checking"
1170
+ ? "Checking whether a signing certificate enrollment is still in progress..."
1171
+ : "Rotation is unavailable while a signing certificate enrollment is in progress - rotating now would lose the key being enrolled. Try again once it finishes, or cancel the enrollment."}
1172
+ </p>
1173
+ )}
1174
+ {signingStatus === "pending" && signingEnrollmentId && (
1175
+ <div className="flex flex-col gap-2">
1176
+ {cancelEnrollmentError && <Alert>{cancelEnrollmentError}</Alert>}
1177
+ <div>
1178
+ <Button
1179
+ type="button"
1180
+ variant="secondary"
1181
+ className="!w-auto"
1182
+ loading={cancelingEnrollment}
1183
+ disabled={cancelingEnrollment}
1184
+ onClick={() => handleCancelEnrollment(signingEnrollmentId)}
1185
+ >
1186
+ Cancel enrollment
1187
+ </Button>
1188
+ </div>
1189
+ </div>
1190
+ )}
1191
+ <input
1192
+ type="password"
1193
+ aria-label="New password for rotated keys"
1194
+ placeholder="New password"
1195
+ className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
1196
+ value={rotationPassword}
1197
+ onChange={(e) => setRotationPassword(e.target.value)}
1198
+ disabled={rotating || rotationBlockedBySigning}
1199
+ autoComplete="new-password"
1200
+ />
1201
+ <input
1202
+ type="password"
1203
+ aria-label="Confirm new password for rotated keys"
1204
+ placeholder="Confirm new password"
1205
+ className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
1206
+ value={rotationConfirmPassword}
1207
+ onChange={(e) => setRotationConfirmPassword(e.target.value)}
1208
+ disabled={rotating || rotationBlockedBySigning}
1209
+ autoComplete="new-password"
1210
+ />
1211
+ <div>
1212
+ <Button
1213
+ type="submit"
1214
+ variant="secondary"
1215
+ className="!w-auto text-danger"
1216
+ loading={rotating}
1217
+ disabled={rotating || rotationBlockedBySigning}
1218
+ >
1219
+ Rotate keys now
1220
+ </Button>
1221
+ </div>
1222
+ </form>
1223
+ </>
1224
+ )}
1225
+
1226
+ <div>
1227
+ <h2 className="text-sm font-semibold mb-2">Session timeout</h2>
1228
+ <p className="text-xs text-text-muted mb-3">
1229
+ Automatically destroys your unlocked keys on this device after this much time with no
1230
+ activity anywhere in the app - not just Mail or Settings. Applies the next time you open
1231
+ or reload the app.
1232
+ </p>
1233
+ <select
1234
+ aria-label="Session timeout"
1235
+ className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
1236
+ value={idleTimeoutMinutes}
1237
+ onChange={handleIdleTimeoutChange}
1238
+ >
1239
+ {IDLE_TIMEOUT_OPTIONS_MINUTES.map((minutes) => (
1240
+ <option key={minutes} value={minutes}>
1241
+ {idleTimeoutLabel(minutes)}
1242
+ </option>
1243
+ ))}
1244
+ </select>
1245
+ </div>
1246
+
1247
+ <div>
1248
+ <h2 className="text-sm font-semibold mb-2">Local search index size</h2>
1249
+ <p className="text-xs text-text-muted mb-3">
1250
+ How much decrypted mail this device keeps in a local encrypted search index, so recent
1251
+ search results and inbox previews work instantly without contacting the server every
1252
+ time. Defaults to {localIndexDefaultByteBudgetLabel} on this device. Applies the next
1253
+ time this mailbox&rsquo;s index rebuilds (e.g. the next time you unlock it) — lowering
1254
+ it doesn&rsquo;t delete anything already indexed elsewhere, it just narrows what this
1255
+ device keeps a local copy of.
1256
+ </p>
1257
+ <select
1258
+ aria-label="Local search index size"
1259
+ className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
1260
+ value={localIndexByteBudget}
1261
+ onChange={handleLocalIndexByteBudgetChange}
1262
+ >
1263
+ {LOCAL_INDEX_SIZE_OPTIONS.map((option) => (
1264
+ <option key={option.bytes} value={option.bytes}>
1265
+ {option.label}
1266
+ </option>
1267
+ ))}
1268
+ </select>
1269
+ </div>
1270
+
1271
+ <div>
1272
+ <h2 className="text-sm font-semibold mb-2">This session</h2>
1273
+ <p className="text-xs text-text-muted mb-3">
1274
+ Removes your unlocked keys from this browser tab&rsquo;s memory right now, without
1275
+ affecting any other device. You&rsquo;ll be asked to unlock again the next time you read
1276
+ or send encrypted mail here.
1277
+ </p>
1278
+ <Button type="button" variant="secondary" className="!w-auto text-danger" onClick={handleDestroyKeysNow}>
1279
+ Destroy keys on this device now
1280
+ </Button>
1281
+ </div>
1282
+ </div>
1283
+ </div>
1284
+ );
1285
+ }
1286
+
1287
+ export default SettingsEncryptionPage;
1288
+
1289
+ /** The tab's title: `Brand: Settings` (see `pageTitle()`). */
1290
+ export const title = pageTitle("Settings");