@rapidmx/web-client 0.4.0 → 0.5.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 (169) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/data-requests/index.tsx +480 -485
  3. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  4. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  5. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  6. package/apps/admin/index.tsx +19 -1
  7. package/apps/admin/ingest-queue/index.tsx +44 -5
  8. package/apps/admin/mailboxes/[uid].tsx +211 -184
  9. package/apps/admin/quarantine/index.tsx +46 -14
  10. package/apps/admin/transport-rules/[uid].tsx +30 -1
  11. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  12. package/apps/admin/transport-rules/index.tsx +24 -6
  13. package/apps/admin/transport-rules/new/index.tsx +30 -1
  14. package/apps/escrow/_layout.tsx +2 -3
  15. package/apps/escrow/audit-log/index.tsx +196 -168
  16. package/apps/escrow/matters/[uid].tsx +621 -521
  17. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  18. package/apps/shared/components/admin/layout/AdminShell.tsx +3 -1
  19. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  20. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  21. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  22. package/apps/shared/components/admin/settings/BrandingForm.tsx +15 -4
  23. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +18 -7
  24. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +10 -2
  25. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +6 -0
  26. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +45 -12
  27. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -595
  28. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -98
  29. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +49 -13
  30. package/apps/shared/components/admin/setup/SetupWizard.tsx +150 -34
  31. package/apps/shared/components/admin/signOut.ts +79 -0
  32. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  33. package/apps/shared/components/calendar/EventModal.tsx +717 -549
  34. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  35. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  36. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  37. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  38. package/apps/shared/components/calendar/allDay.ts +124 -0
  39. package/apps/shared/components/calendar/layout/CalendarShell.tsx +1 -1
  40. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  41. package/apps/shared/components/contacts/ContactForm.tsx +377 -348
  42. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  43. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  44. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  45. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  46. package/apps/shared/components/layout/AppShell.tsx +87 -15
  47. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  48. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +389 -298
  49. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  50. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  51. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -128
  52. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  53. package/apps/shared/components/mail/MessageDetailPane.tsx +1318 -630
  54. package/apps/shared/components/mail/compose/ComposeContext.tsx +20 -15
  55. package/apps/shared/components/mail/compose/ComposeWindow.tsx +854 -132
  56. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  57. package/apps/shared/components/mail/layout/MailShell.tsx +12 -2
  58. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  59. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  60. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  61. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  62. package/apps/shared/mail/listAllPages.ts +39 -0
  63. package/apps/shared/search/LocalIndexLifecycle.tsx +64 -55
  64. package/apps/shared/search/localIndexBuilder.ts +481 -179
  65. package/apps/shared/search/localIndexRpcClient.ts +228 -28
  66. package/apps/shared/search/localIndexSchema.ts +15 -6
  67. package/apps/shared/search/localIndexStorage.ts +98 -0
  68. package/apps/shared/search/localIndexVFS.ts +32 -1
  69. package/apps/shared/search/localIndexWorker.ts +648 -186
  70. package/apps/www/calendar/index.tsx +438 -417
  71. package/apps/www/contacts/[uid].tsx +21 -2
  72. package/apps/www/contacts/index.tsx +116 -12
  73. package/apps/www/index.tsx +1314 -1041
  74. package/apps/www/messages/[uid].tsx +18 -6
  75. package/apps/www/settings/auto-reply/index.tsx +134 -129
  76. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  77. package/apps/www/settings/encryption/index.tsx +739 -216
  78. package/apps/www/settings/filters/[uid].tsx +171 -155
  79. package/apps/www/settings/filters/new/index.tsx +8 -1
  80. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  81. package/apps/www/settings/privacy/index.tsx +494 -436
  82. package/apps/www/settings/read-receipts/index.tsx +148 -125
  83. package/apps/www/settings/sharing/index.tsx +17 -14
  84. package/apps/www/tasks/index.tsx +638 -579
  85. package/dist/apps/admin/audit-log/index.js +38 -8
  86. package/dist/apps/admin/data-requests/index.js +52 -72
  87. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  88. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  89. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  90. package/dist/apps/admin/index.js +4 -1
  91. package/dist/apps/admin/ingest-queue/index.js +24 -6
  92. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  93. package/dist/apps/admin/quarantine/index.js +20 -11
  94. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  95. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  96. package/dist/apps/admin/transport-rules/index.js +22 -6
  97. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  98. package/dist/apps/escrow/_layout.js +3 -3
  99. package/dist/apps/escrow/audit-log/index.js +26 -1
  100. package/dist/apps/escrow/matters/[uid].js +77 -42
  101. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  102. package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -1
  103. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  104. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  105. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  106. package/dist/apps/shared/components/admin/settings/BrandingForm.js +15 -4
  107. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +13 -7
  108. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +7 -2
  109. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +6 -0
  110. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +34 -12
  111. package/dist/apps/shared/components/admin/settings/PluginsManager.js +390 -78
  112. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +40 -11
  113. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +29 -13
  114. package/dist/apps/shared/components/admin/setup/SetupWizard.js +87 -21
  115. package/dist/apps/shared/components/admin/signOut.js +75 -0
  116. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  117. package/dist/apps/shared/components/calendar/EventModal.js +142 -21
  118. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  119. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  120. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  121. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  122. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  123. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +1 -1
  124. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  125. package/dist/apps/shared/components/contacts/ContactForm.js +32 -10
  126. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  127. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  128. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  129. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  130. package/dist/apps/shared/components/layout/AppShell.js +83 -14
  131. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  132. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +49 -12
  133. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  134. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  135. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +73 -14
  136. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  137. package/dist/apps/shared/components/mail/MessageDetailPane.js +441 -36
  138. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  139. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +699 -128
  140. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  141. package/dist/apps/shared/components/mail/layout/MailShell.js +12 -3
  142. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  143. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  144. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  145. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  146. package/dist/apps/shared/mail/listAllPages.js +25 -0
  147. package/dist/apps/shared/search/LocalIndexLifecycle.js +58 -54
  148. package/dist/apps/shared/search/localIndexBuilder.js +292 -37
  149. package/dist/apps/shared/search/localIndexRpcClient.js +188 -25
  150. package/dist/apps/shared/search/localIndexSchema.js +12 -6
  151. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  152. package/dist/apps/shared/search/localIndexVFS.js +68 -40
  153. package/dist/apps/shared/search/localIndexWorker.js +519 -173
  154. package/dist/apps/www/calendar/index.js +28 -10
  155. package/dist/apps/www/contacts/[uid].js +13 -2
  156. package/dist/apps/www/contacts/index.js +80 -10
  157. package/dist/apps/www/index.js +339 -108
  158. package/dist/apps/www/messages/[uid].js +18 -6
  159. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  160. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  161. package/dist/apps/www/settings/encryption/index.js +514 -97
  162. package/dist/apps/www/settings/filters/[uid].js +22 -7
  163. package/dist/apps/www/settings/filters/new/index.js +8 -2
  164. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  165. package/dist/apps/www/settings/privacy/index.js +57 -22
  166. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  167. package/dist/apps/www/settings/sharing/index.js +7 -11
  168. package/dist/apps/www/tasks/index.js +50 -10
  169. package/package.json +2 -2
@@ -5,10 +5,13 @@
5
5
  import React, { FormEvent, useEffect, useState } from "react";
6
6
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
7
  import {
8
+ EnrollmentResult,
8
9
  KeyVault,
9
10
  MasterKeyWrap,
10
11
  PublicKey,
12
+ WrappedPrivateKey,
11
13
  addMasterKeyWrap,
14
+ cancelSignEnrollment,
12
15
  checkSignEnrollmentStatus,
13
16
  findActivePublicKey,
14
17
  getEscrowInfo,
@@ -18,7 +21,9 @@ import {
18
21
  startSignEnrollment,
19
22
  } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
20
23
  import {
24
+ ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE,
21
25
  SIGNING_PRIVATE_KEY_AAD_PURPOSE,
26
+ UnlockedKeys,
22
27
  destroyUnlockedKeys,
23
28
  getUnlockedKeys,
24
29
  unlockWithPassword,
@@ -31,23 +36,165 @@ import {
31
36
  setLocalIndexByteBudget,
32
37
  } from "../../../shared/search/localIndexSizePreference.js";
33
38
  import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
34
- import { buildAad, sealWithKey } from "@rapidmx/react-shared/crypto/masterKey.js";
39
+ import { KeysLockedError, buildAad, generateMasterKey, openWithKey, sealWithKey } from "@rapidmx/react-shared/crypto/masterKey.js";
35
40
  import { buildEscrowWrap, buildPasswordWrap, buildRecoveryWraps } from "@rapidmx/react-shared/crypto/masterKeyWraps.js";
36
- import { rewrapPrivateKeysUnderNewMasterKey } from "@rapidmx/react-shared/crypto/keyRotation.js";
37
41
  import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "@rapidmx/react-shared/crypto/keys.js";
38
42
  import { getMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
39
43
  import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
40
44
  import KeyEnrollmentGate from "../../../shared/components/layout/KeyEnrollmentGate.js";
45
+ import { useUnlockPrompt } from "../../../shared/components/layout/UnlockPromptProvider.js";
41
46
  import { destroyLocalIndex } from "../../../shared/search/localIndexRpcClient.js";
42
47
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
43
48
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
49
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
44
50
 
45
51
  const MIN_PASSWORD_LENGTH = 8;
46
52
 
53
+ /** Mirrors restapi's `BaseKeyVaultRoute` `MAX_MASTER_KEY_WRAPS` - `addMasterKeyWrap()` refuses a vault
54
+ * already holding this many wraps (escrow included). */
55
+ export const MAX_MASTER_KEY_WRAPS = 20;
56
+
57
+ const KEYS_LOCKED_MESSAGE = "Your encryption keys were locked before this could finish. Unlock them and try again.";
58
+
59
+ /** Thrown inside `handleRotateKeys()` when the escrow wrap of the new master key can't be built - the rotation is
60
+ * then abandoned before `rekey()`. */
61
+ class EscrowWrapUnavailableError extends Error {
62
+ constructor(readonly cause: unknown) {
63
+ super("Could not prepare escrow protection for the new master key.");
64
+ }
65
+ }
66
+
67
+ /** Thrown by `rewrapVaultPrivateKeys()` when a vault entry can't be opened with the current master key. */
68
+ class UncoveredVaultKeysError extends Error {
69
+ constructor(readonly fingerprints: string[]) {
70
+ super(`Could not open ${fingerprints.length} wrapped private key(s).`);
71
+ }
72
+ }
73
+
74
+ /** Thrown when this session's master key no longer opens the vault - another device rotated the keys since this
75
+ * session unlocked. Anything wrapped or sealed under it would be unusable (and a signing key sealed under it would
76
+ * be installed at the vault's current generation, blocking rotation), so nothing is written. */
77
+ class StaleSessionKeysError extends Error {
78
+ constructor() {
79
+ super("This session's master key no longer opens the key vault.");
80
+ }
81
+ }
82
+
83
+ const STALE_KEYS_MESSAGE =
84
+ "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.";
85
+
86
+ function wrappedKeyAad(mailboxUid: string, entry: WrappedPrivateKey): Uint8Array {
87
+ return buildAad(mailboxUid, entry.useType === "sign" ? SIGNING_PRIVATE_KEY_AAD_PURPOSE : ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE);
88
+ }
89
+
90
+ /**
91
+ * Whether `masterKey` opens at least one of `vault`'s wrapped private keys - i.e. it is still the vault's current
92
+ * master key. A vault with no wrapped keys has nothing to check against and passes. The opened key bytes are zeroed
93
+ * straight away. `KeysLockedError` (a destroyed master key) propagates.
94
+ */
95
+ async function masterKeyOpensVault(mailboxUid: string, masterKey: Uint8Array, vault: KeyVault): Promise<boolean> {
96
+ if (vault.wrappedKeys.length === 0) {
97
+ return true;
98
+ }
99
+ for (const entry of vault.wrappedKeys) {
100
+ try {
101
+ const raw = await openWithKey(masterKey, entry, wrappedKeyAad(mailboxUid, entry));
102
+ raw.fill(0);
103
+ return true;
104
+ } catch (err) {
105
+ if (err instanceof KeysLockedError) {
106
+ throw err;
107
+ }
108
+ }
109
+ }
110
+ return false;
111
+ }
112
+
113
+ /**
114
+ * Re-seals EVERY private key in the vault (not just the ones this session imported - an inactive or
115
+ * newly issued key would otherwise be silently dropped by `rekey()`, which replaces `wrappedKeys`
116
+ * wholesale) under a brand new master key. Opens each entry with `currentMasterKey` first and refuses
117
+ * (`UncoveredVaultKeysError`) if any can't be opened, before generating anything. Plaintext key bytes
118
+ * are zeroed as soon as they've been re-sealed. `KeysLockedError` (a destroyed master key) propagates.
119
+ */
120
+ async function rewrapVaultPrivateKeys(
121
+ mailboxUid: string,
122
+ currentMasterKey: Uint8Array,
123
+ wrappedKeys: WrappedPrivateKey[],
124
+ ): Promise<{ mk: Uint8Array; wrappedKeys: WrappedPrivateKey[] }> {
125
+ const aadFor = (entry: WrappedPrivateKey) => wrappedKeyAad(mailboxUid, entry);
126
+ const opened: { entry: WrappedPrivateKey; raw: Uint8Array }[] = [];
127
+ try {
128
+ const failed: string[] = [];
129
+ for (const entry of wrappedKeys) {
130
+ try {
131
+ opened.push({ entry, raw: await openWithKey(currentMasterKey, entry, aadFor(entry)) });
132
+ } catch (err) {
133
+ if (err instanceof KeysLockedError) {
134
+ throw err;
135
+ }
136
+ failed.push(entry.fingerprint);
137
+ }
138
+ }
139
+ if (failed.length > 0) {
140
+ throw new UncoveredVaultKeysError(failed);
141
+ }
142
+ const mk = generateMasterKey();
143
+ const rewrapped: WrappedPrivateKey[] = [];
144
+ for (const { entry, raw } of opened) {
145
+ const sealed = await sealWithKey(mk, raw, aadFor(entry));
146
+ rewrapped.push({
147
+ ciphertext: sealed.ciphertext,
148
+ nonce: sealed.nonce,
149
+ algorithm: "AES-256-GCM",
150
+ fingerprint: entry.fingerprint,
151
+ useType: entry.useType,
152
+ });
153
+ }
154
+ return { mk, wrappedKeys: rewrapped };
155
+ } finally {
156
+ for (const { raw } of opened) {
157
+ raw.fill(0);
158
+ }
159
+ }
160
+ }
161
+
47
162
  // RFC 8823 ACME issuance is a real email round-trip with a public CA - "likely minutes," not seconds -
48
163
  // so this polls infrequently rather than hammering the endpoint.
49
164
  const SIGNING_ENROLLMENT_POLL_INTERVAL_MS = 15_000;
50
165
 
166
+ /**
167
+ * A started signing enrollment's id, per mailbox. Kept only so a reload can ask the server
168
+ * (`checkSignEnrollmentStatus()`) whether that enrollment is still pending: a rotation while it is pending
169
+ * would strand the enrollment's already-submitted private key under a master key that no longer exists. The
170
+ * stored id is never trusted on its own - the server's answer decides the state, and restapi's own `rekey()`
171
+ * refuses (409) a rotation during an enrollment this browser never saw (e.g. one started on another device).
172
+ */
173
+ const SIGN_ENROLLMENT_STORAGE_PREFIX = "rapidmx.signEnrollment.";
174
+
175
+ function readStoredSignEnrollment(mailboxUid: string): string | null {
176
+ try {
177
+ return localStorage.getItem(SIGN_ENROLLMENT_STORAGE_PREFIX + mailboxUid);
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
183
+ function storeSignEnrollment(mailboxUid: string, enrollmentId: string | null): void {
184
+ try {
185
+ if (enrollmentId) {
186
+ localStorage.setItem(SIGN_ENROLLMENT_STORAGE_PREFIX + mailboxUid, enrollmentId);
187
+ } else {
188
+ localStorage.removeItem(SIGN_ENROLLMENT_STORAGE_PREFIX + mailboxUid);
189
+ }
190
+ } catch {
191
+ // Storage blocked - restapi's own 409 on rekey still guards a rotation.
192
+ }
193
+ }
194
+
195
+ const ROTATION_CONFLICT_MESSAGE =
196
+ "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.";
197
+
51
198
  const METHOD_LABELS: Record<string, string> = {
52
199
  password: "Password",
53
200
  passkey: "Passkey",
@@ -70,7 +217,7 @@ export type SettingsEncryptionPageProps = Omit<SettingsShellProps, "active">;
70
217
  export default function SettingsEncryptionPage(props: SettingsEncryptionPageProps) {
71
218
  return (
72
219
  <SettingsShell {...props} active="encryption">
73
- <EncryptionGate />
220
+ <EncryptionGate userUid={props.userUid} impersonating={props.impersonating} />
74
221
  </SettingsShell>
75
222
  );
76
223
  }
@@ -82,23 +229,33 @@ export default function SettingsEncryptionPage(props: SettingsEncryptionPageProp
82
229
  * it renders. See `KeyEnrollmentGate`'s own doc comment for why it's safe to mount more than once (it
83
230
  * short-circuits to `children` immediately for a mailbox already unlocked elsewhere this session).
84
231
  */
85
- function EncryptionGate() {
232
+ function EncryptionGate({ userUid, impersonating }: { userUid?: string; impersonating?: boolean }) {
86
233
  const { mailboxUid, mailboxes } = useSettingsShell();
87
234
  const mailbox = mailboxes.find((mb) => mb.uid === mailboxUid)!;
235
+ // restapi only lets the mailbox's actual owner write its key vault (`requireMailboxOwner()`), so a
236
+ // delegate (or an admin impersonating the owner) never provisions keys here, and sees no write actions.
237
+ const isOwner = mailbox.ownerUserUid !== undefined && mailbox.ownerUserUid === userUid && !impersonating;
88
238
  return (
89
- <KeyEnrollmentGate mailboxUid={mailboxUid} mailboxAddress={mailbox.primarySmtpAddress} mailboxKeys={mailbox.keys}>
90
- <EncryptionContent />
239
+ <KeyEnrollmentGate
240
+ mailboxUid={mailboxUid}
241
+ mailboxAddress={mailbox.primarySmtpAddress}
242
+ mailboxKeys={mailbox.keys}
243
+ canProvision={isOwner}
244
+ >
245
+ <EncryptionContent canManageKeys={isOwner} />
91
246
  </KeyEnrollmentGate>
92
247
  );
93
248
  }
94
249
 
95
- function EncryptionContent() {
250
+ function EncryptionContent({ canManageKeys }: { canManageKeys: boolean }) {
96
251
  const { mailboxUid, mailboxes } = useSettingsShell();
97
252
  const mailbox = mailboxes.find((mb) => mb.uid === mailboxUid)!;
253
+ const { requestUnlock } = useUnlockPrompt();
98
254
 
99
255
  const [vault, setVault] = useState<KeyVault | null>(null);
100
256
  const [loadError, setLoadError] = useState<string | null>(null);
101
257
  const [removingMethod, setRemovingMethod] = useState<string | null>(null);
258
+ const [pendingRemoval, setPendingRemoval] = useState<MasterKeyWrap | null>(null);
102
259
  const [actionError, setActionError] = useState<string | null>(null);
103
260
 
104
261
  const [newPassword, setNewPassword] = useState("");
@@ -113,6 +270,13 @@ function EncryptionContent() {
113
270
  const [recoveryCodesReason, setRecoveryCodesReason] = useState<"regenerate" | "rotate">("regenerate");
114
271
  const [codesSaved, setCodesSaved] = useState(false);
115
272
  const [codesCopied, setCodesCopied] = useState(false);
273
+ // Shown on the "Save your new recovery codes" screen itself - a regeneration or rotation that only
274
+ // partly finished (e.g. some new codes saved but not all, or old codes that couldn't be removed) still
275
+ // lands there, since whatever codes did save must be shown now or never.
276
+ const [codesWarning, setCodesWarning] = useState<string | null>(null);
277
+ // Set when a rotation succeeded but this session couldn't re-unlock under the new master key - the
278
+ // stale keys are destroyed, and the "keys removed" screen explains why.
279
+ const [rotationRelockReason, setRotationRelockReason] = useState<string | null>(null);
116
280
 
117
281
  const [rotationPassword, setRotationPassword] = useState("");
118
282
  const [rotationConfirmPassword, setRotationConfirmPassword] = useState("");
@@ -121,8 +285,8 @@ function EncryptionContent() {
121
285
  const [destroyed, setDestroyed] = useState(false);
122
286
  const [idleTimeoutMinutes, setIdleTimeoutMinutesState] = useState(() => getIdleTimeoutMinutes());
123
287
  const [localIndexByteBudget, setLocalIndexByteBudgetState] = useState(() => getLocalIndexByteBudget());
124
- const localIndexDefaultByteBudgetLabel =
125
- LOCAL_INDEX_SIZE_OPTIONS.find((option) => option.bytes === getDefaultLocalIndexByteBudget())?.label ?? "500 MB";
288
+ // Both possible defaults (web and Electron) are themselves entries in `LOCAL_INDEX_SIZE_OPTIONS`.
289
+ const localIndexDefaultByteBudgetLabel = LOCAL_INDEX_SIZE_OPTIONS.find((option) => option.bytes === getDefaultLocalIndexByteBudget())!.label;
126
290
 
127
291
  // `mailbox.keys` comes from `SettingsShell`'s one-time `listMailboxes()` fetch - once an ACME
128
292
  // enrollment issues, the server has installed a new signing key that fetch never saw. Only this
@@ -131,55 +295,151 @@ function EncryptionContent() {
131
295
  const displayedKeys = refreshedKeys ?? mailbox.keys ?? [];
132
296
  const activeSigningKey = findActivePublicKey(displayedKeys, "sign");
133
297
 
134
- const [signingStatus, setSigningStatus] = useState<"idle" | "enrolling" | "pending">("idle");
298
+ // "checking" = a stored enrollment id is being confirmed with the server. Rotation stays disabled unless
299
+ // this is "idle".
300
+ const [signingStatus, setSigningStatus] = useState<"checking" | "idle" | "enrolling" | "pending">(() =>
301
+ readStoredSignEnrollment(mailboxUid!) ? "checking" : "idle",
302
+ );
135
303
  const [signingEnrollmentId, setSigningEnrollmentId] = useState<string | null>(null);
136
304
  const [signingError, setSigningError] = useState<string | null>(null);
305
+ const rotationBlockedBySigning = signingStatus !== "idle";
137
306
 
307
+ const [cancelingEnrollment, setCancelingEnrollment] = useState(false);
308
+ const [cancelEnrollmentError, setCancelEnrollmentError] = useState<string | null>(null);
309
+
310
+ // restapi's rekey() drops every old escrow wrap and requires a fresh one for the new master key (see
311
+ // handleRotateKeys), so an escrow wrap in the vault always covers the current master key.
138
312
  const hasEscrowWrap = vault?.masterKeyWraps.some((w) => w.method === "escrow") ?? false;
139
313
  const [wrappingEscrow, setWrappingEscrow] = useState(false);
140
314
  const [escrowError, setEscrowError] = useState<string | null>(null);
141
315
 
316
+ /**
317
+ * This mailbox's unlocked keys *right now* - never a copy read at render time, which a lock (idle
318
+ * timeout, another tab's sign-out) may since have destroyed. Prompts for the password when locked;
319
+ * rejects if the prompt is dismissed.
320
+ */
321
+ async function currentUnlockedKeys(): Promise<UnlockedKeys> {
322
+ const current = getUnlockedKeys(mailboxUid!);
323
+ if (current && !current.destroyed) {
324
+ return current;
325
+ }
326
+ return requestUnlock(mailboxUid!, displayedKeys);
327
+ }
328
+
329
+ /**
330
+ * `currentUnlockedKeys()`, checked against a freshly fetched vault before anything is wrapped or sealed under its
331
+ * master key: after a rotation on another device the session's master key is dead, and every write built from
332
+ * it would be too. Rejects with `StaleSessionKeysError` then. Resolves to the keys and that fresh vault.
333
+ */
334
+ async function verifiedUnlockedKeys(): Promise<{ current: UnlockedKeys; freshVault: KeyVault }> {
335
+ const current = await currentUnlockedKeys();
336
+ const freshVault = await getKeyVault(mailboxUid!);
337
+ if (!(await masterKeyOpensVault(mailboxUid!, current.masterKey, freshVault))) {
338
+ throw new StaleSessionKeysError();
339
+ }
340
+ return { current, freshVault };
341
+ }
342
+
343
+ /** Drops this session's out-of-date keys (and, as with every key destruction, the local index) and asks for the
344
+ * current password, so the next attempt works with the vault's real master key. */
345
+ function relockStaleKeys() {
346
+ destroyUnlockedKeys(mailboxUid);
347
+ void destroyLocalIndex(mailboxUid!);
348
+ requestUnlock(mailboxUid!, displayedKeys).catch(() => undefined);
349
+ }
350
+
351
+ function errorMessage(err: unknown, fallback: string): string {
352
+ if (err instanceof KeysLockedError) {
353
+ return KEYS_LOCKED_MESSAGE;
354
+ }
355
+ if (err instanceof StaleSessionKeysError) {
356
+ relockStaleKeys();
357
+ return STALE_KEYS_MESSAGE;
358
+ }
359
+ return err instanceof ApiRequestError ? err.message : fallback;
360
+ }
361
+
142
362
  async function handleWrapEscrow() {
143
363
  setEscrowError(null);
144
364
  setWrappingEscrow(true);
145
365
  try {
146
- // Only reachable when mailbox.escrowScopeId is set (see the render guard below) and
147
- // `unlocked` is defined - see `handleAddPassword`'s identical note on the latter.
366
+ // Only reachable when mailbox.escrowScopeId is set (see the render guard below).
367
+ const { current } = await verifiedUnlockedKeys();
148
368
  const escrowInfo = await getEscrowInfo(mailboxUid!);
149
- const wrap = await buildEscrowWrap(unlocked!.masterKey, escrowInfo.escrowScopeId, fromBase64(escrowInfo.publicKey.publicKey));
369
+ const wrap = await buildEscrowWrap(current.masterKey, escrowInfo.escrowScopeId, fromBase64(escrowInfo.publicKey.publicKey));
150
370
  await addMasterKeyWrap(mailboxUid!, wrap);
151
371
  await loadVault();
152
372
  } catch (err) {
153
- setEscrowError(err instanceof ApiRequestError ? err.message : "Could not add escrow protection for this mailbox.");
373
+ setEscrowError(errorMessage(err, "Could not add escrow protection for this mailbox."));
154
374
  } finally {
155
375
  setWrappingEscrow(false);
156
376
  }
157
377
  }
158
378
 
379
+ /** Applies a server-reported enrollment status - shared by the reload check and the poll below. */
380
+ async function applyEnrollmentResult(enrollmentId: string, result: EnrollmentResult, isCancelled: () => boolean) {
381
+ if (result.status === "pending") {
382
+ setSigningEnrollmentId(enrollmentId);
383
+ setSigningStatus("pending");
384
+ return;
385
+ }
386
+ storeSignEnrollment(mailboxUid!, null);
387
+ setSigningStatus("idle");
388
+ setSigningEnrollmentId(null);
389
+ if (result.status === "failed") {
390
+ setSigningError(result.error ?? "Signing certificate enrollment failed.");
391
+ return;
392
+ }
393
+ try {
394
+ const refreshed = await getMailbox(mailboxUid!);
395
+ if (!isCancelled()) {
396
+ setRefreshedKeys(refreshed.keys ?? []);
397
+ }
398
+ } catch {
399
+ // The new key shows on the next load - the enrollment itself is finished either way.
400
+ }
401
+ }
402
+
403
+ // Confirms an enrollment started before a reload with the server before rotation is offered again.
159
404
  useEffect(() => {
160
- if (signingStatus !== "pending" || !signingEnrollmentId) {
405
+ const storedId = readStoredSignEnrollment(mailboxUid!);
406
+ if (!storedId) {
161
407
  return;
162
408
  }
163
409
  let cancelled = false;
164
- const interval = setInterval(async () => {
165
- try {
166
- const result = await checkSignEnrollmentStatus(mailboxUid!, signingEnrollmentId);
410
+ setSigningStatus("checking");
411
+ checkSignEnrollmentStatus(mailboxUid!, storedId)
412
+ .then((result) => (cancelled ? undefined : applyEnrollmentResult(storedId, result, () => cancelled)))
413
+ .catch((err) => {
167
414
  if (cancelled) {
168
415
  return;
169
416
  }
170
- if (result.status === "issued") {
417
+ if (err instanceof ApiRequestError && err.status === 404) {
418
+ // The server no longer knows this enrollment, so nothing is pending.
419
+ storeSignEnrollment(mailboxUid!, null);
171
420
  setSigningStatus("idle");
172
- setSigningEnrollmentId(null);
173
- const refreshed = await getMailbox(mailboxUid!);
174
- if (!cancelled) {
175
- setRefreshedKeys(refreshed.keys ?? []);
176
- }
177
- } else if (result.status === "failed") {
178
- setSigningStatus("idle");
179
- setSigningEnrollmentId(null);
180
- setSigningError(result.error ?? "Signing certificate enrollment failed.");
421
+ return;
422
+ }
423
+ // Unknown (e.g. a network error): assume it is still pending, and let polling keep asking.
424
+ setSigningEnrollmentId(storedId);
425
+ setSigningStatus("pending");
426
+ });
427
+ return () => {
428
+ cancelled = true;
429
+ };
430
+ }, [mailboxUid]);
431
+
432
+ useEffect(() => {
433
+ if (signingStatus !== "pending" || !signingEnrollmentId) {
434
+ return;
435
+ }
436
+ let cancelled = false;
437
+ const interval = setInterval(async () => {
438
+ try {
439
+ const result = await checkSignEnrollmentStatus(mailboxUid!, signingEnrollmentId);
440
+ if (!cancelled) {
441
+ await applyEnrollmentResult(signingEnrollmentId, result, () => cancelled);
181
442
  }
182
- // "pending" leaves state as-is - the interval below just tries again.
183
443
  } catch {
184
444
  // Transient network error - keep polling rather than surfacing a one-off failure.
185
445
  }
@@ -190,15 +450,46 @@ function EncryptionContent() {
190
450
  };
191
451
  }, [signingStatus, signingEnrollmentId, mailboxUid]);
192
452
 
453
+ /** Cancels the pending enrollment (restapi's owner-only DELETE), which is what lets rotation run again. */
454
+ async function handleCancelEnrollment(enrollmentId: string) {
455
+ setCancelEnrollmentError(null);
456
+ setCancelingEnrollment(true);
457
+ try {
458
+ const result = await cancelSignEnrollment(mailboxUid!, enrollmentId);
459
+ if (result.status === "pending") {
460
+ setCancelEnrollmentError("The enrollment couldn't be cancelled yet. Try again.");
461
+ } else if (result.status === "failed") {
462
+ // Cancelled - the "failure" is the cancellation itself, not something to report.
463
+ storeSignEnrollment(mailboxUid!, null);
464
+ setSigningStatus("idle");
465
+ setSigningEnrollmentId(null);
466
+ } else {
467
+ // Issued before the cancel landed: it finishes like any other issued enrollment.
468
+ await applyEnrollmentResult(enrollmentId, result, () => false);
469
+ }
470
+ } catch (err) {
471
+ if (err instanceof ApiRequestError && err.status === 404) {
472
+ // The server no longer knows this enrollment, so nothing is pending.
473
+ storeSignEnrollment(mailboxUid!, null);
474
+ setSigningStatus("idle");
475
+ setSigningEnrollmentId(null);
476
+ } else {
477
+ setCancelEnrollmentError(errorMessage(err, "Could not cancel the signing certificate enrollment."));
478
+ }
479
+ } finally {
480
+ setCancelingEnrollment(false);
481
+ }
482
+ }
483
+
193
484
  async function handleEnrollSigning() {
194
485
  setSigningError(null);
195
486
  setSigningStatus("enrolling");
196
487
  try {
197
- // Only reachable once `unlocked` is defined - see `handleAddPassword`'s identical note.
488
+ const { current } = await verifiedUnlockedKeys();
198
489
  const { keyPair, csrPem } = await generateKeyPairWithCsr(mailbox.primarySmtpAddress, "sign");
199
490
  const privateKeyRaw = await exportPrivateKeyPkcs8(keyPair.privateKey);
200
491
  const wrappedKeySealed = await sealWithKey(
201
- unlocked!.masterKey,
492
+ current.masterKey,
202
493
  privateKeyRaw,
203
494
  buildAad(mailboxUid!, SIGNING_PRIVATE_KEY_AAD_PURPOSE),
204
495
  );
@@ -206,10 +497,11 @@ function EncryptionContent() {
206
497
  csr: csrPem,
207
498
  wrappedKey: { ciphertext: wrappedKeySealed.ciphertext, nonce: wrappedKeySealed.nonce, algorithm: "AES-256-GCM" },
208
499
  });
500
+ storeSignEnrollment(mailboxUid!, enrollmentId);
209
501
  setSigningEnrollmentId(enrollmentId);
210
502
  setSigningStatus("pending");
211
503
  } catch (err) {
212
- setSigningError(err instanceof ApiRequestError ? err.message : "Could not start signing certificate enrollment.");
504
+ setSigningError(errorMessage(err, "Could not start signing certificate enrollment."));
213
505
  setSigningStatus("idle");
214
506
  }
215
507
  }
@@ -233,16 +525,25 @@ function EncryptionContent() {
233
525
  }
234
526
 
235
527
  useEffect(() => {
236
- loadVault();
237
- // eslint-disable-next-line react-hooks/exhaustive-deps
528
+ void loadVault();
238
529
  }, [mailboxUid]);
239
530
 
240
- const unlocked = getUnlockedKeys(mailboxUid!);
241
531
  const passwordWraps = vault?.masterKeyWraps.filter((w) => w.method === "password") ?? [];
242
532
  const recoveryWraps = vault?.masterKeyWraps.filter((w) => w.method === "recovery") ?? [];
243
533
  const otherWraps = vault?.masterKeyWraps.filter((w) => w.method !== "password" && w.method !== "recovery") ?? [];
244
534
 
535
+ // The owner's own unlock methods are every non-escrow wrap - restapi refuses (409) to remove the last
536
+ // one. The app unlocks with a password or a single-use recovery code (passkeys have no unlock path yet),
537
+ // so the last password wrap is never offered for removal either: without it only a dwindling set of
538
+ // recovery codes would be left. A forgotten password is replaced after a recovery-code unlock instead
539
+ // (`RecoveryCodeUnlock.tsx`).
540
+ const ownUnlockWrapCount = vault?.masterKeyWraps.filter((w) => w.method !== "escrow").length ?? 0;
541
+ function canRemoveWrap(wrap: MasterKeyWrap): boolean {
542
+ return wrap.method === "password" ? passwordWraps.length > 1 : ownUnlockWrapCount > 1;
543
+ }
544
+
245
545
  async function handleRemove(wrap: MasterKeyWrap) {
546
+ setPendingRemoval(null);
246
547
  const key = `${wrap.method}:${wrap.methodId ?? ""}`;
247
548
  setRemovingMethod(key);
248
549
  setActionError(null);
@@ -256,6 +557,8 @@ function EncryptionContent() {
256
557
  }
257
558
  }
258
559
 
560
+ /** Only offered while the vault has no password wrap at all - `unlockWithPassword()` only ever tries
561
+ * the first one, so a second password would be accepted here but never unlock anything. */
259
562
  async function handleAddPassword(e: FormEvent) {
260
563
  e.preventDefault();
261
564
  if (newPassword.length < MIN_PASSWORD_LENGTH) {
@@ -269,40 +572,136 @@ function EncryptionContent() {
269
572
  setActionError(null);
270
573
  setAddingPassword(true);
271
574
  try {
272
- // Only reachable once `unlocked` is defined - `EncryptionGate` guarantees this mailbox is
273
- // unlocked before `EncryptionContent` ever mounts.
274
- const wrap = await buildPasswordWrap(mailboxUid!, unlocked!.masterKey, newPassword);
575
+ const { current } = await verifiedUnlockedKeys();
576
+ const wrap = await buildPasswordWrap(mailboxUid!, current.masterKey, newPassword);
275
577
  await addMasterKeyWrap(mailboxUid!, wrap);
276
578
  setNewPassword("");
277
579
  setConfirmNewPassword("");
278
580
  await loadVault();
279
581
  } catch (err) {
280
- setActionError(err instanceof ApiRequestError ? err.message : "Could not add this password.");
582
+ setActionError(errorMessage(err, "Could not add this password."));
281
583
  } finally {
282
584
  setAddingPassword(false);
283
585
  }
284
586
  }
285
587
 
588
+ /**
589
+ * Adds the new recovery wraps *before* removing any old one, so a failure part-way through can never
590
+ * leave the mailbox with fewer working recovery codes than it started with. `buildRecoveryWraps()`
591
+ * labels its wraps `recovery-1..N` - the same labels the old set most likely has, and restapi's
592
+ * `removeMasterKeyWrap()` removes every wrap matching a `methodId` - so each new wrap gets a
593
+ * batch-unique label first, letting the old ones be removed by their own `methodId` without touching
594
+ * the new ones.
595
+ *
596
+ * The vault holds at most `MAX_MASTER_KEY_WRAPS` wraps. Checked against a fresh copy of the vault before
597
+ * anything is written: when the new set only fits once old recovery codes are gone, an old code is
598
+ * removed just before each new one that has no room yet (the working-code count never drops below the
599
+ * original set's), and when it can't fit even then, nothing is written and the user is told how many
600
+ * other methods to remove.
601
+ */
286
602
  async function handleRegenerateRecoveryCodes() {
603
+ // Only reachable once the vault has loaded (the button is disabled until then).
287
604
  setActionError(null);
288
605
  setRegenerating(true);
606
+ let built: { wraps: MasterKeyWrap[]; codes: string[] };
607
+ let oldRecoveryWraps: MasterKeyWrap[];
608
+ let free: number;
289
609
  try {
290
- for (const wrap of recoveryWraps) {
291
- await removeMasterKeyWrap(mailboxUid!, "recovery", wrap.methodId);
292
- }
293
- const { wraps, codes } = await buildRecoveryWraps(mailboxUid!, unlocked!.masterKey);
294
- for (const wrap of wraps) {
295
- await addMasterKeyWrap(mailboxUid!, wrap);
610
+ const { current, freshVault } = await verifiedUnlockedKeys();
611
+ oldRecoveryWraps = freshVault.masterKeyWraps.filter((w) => w.method === "recovery");
612
+ free = MAX_MASTER_KEY_WRAPS - freshVault.masterKeyWraps.length;
613
+ built = await buildRecoveryWraps(mailboxUid!, current.masterKey);
614
+ const shortfall = built.wraps.length - (free + oldRecoveryWraps.length);
615
+ if (shortfall > 0) {
616
+ setVault(freshVault);
617
+ setActionError(
618
+ `Your key vault can hold at most ${MAX_MASTER_KEY_WRAPS} unlock methods and already has ${freshVault.masterKeyWraps.length}. ` +
619
+ `Regenerating needs room for ${built.wraps.length} new recovery codes, so remove ${shortfall} other unlock ` +
620
+ `method${shortfall === 1 ? "" : "s"} (such as an extra password or passkey) below first.`,
621
+ );
622
+ setRegenerating(false);
623
+ return;
296
624
  }
297
- setRecoveryCodesReason("regenerate");
298
- setNewRecoveryCodes(codes);
299
- setCodesSaved(false);
300
- await loadVault();
301
625
  } catch (err) {
302
- setActionError(err instanceof ApiRequestError ? err.message : "Could not regenerate recovery codes.");
303
- } finally {
626
+ setActionError(errorMessage(err, "Could not regenerate recovery codes."));
627
+ setRegenerating(false);
628
+ return;
629
+ }
630
+ const batch = Date.now().toString(36);
631
+ const pendingOld = [...oldRecoveryWraps];
632
+ let removedEarly = 0;
633
+ const savedCodes: string[] = [];
634
+ let addError: unknown = null;
635
+ for (let i = 0; i < built.wraps.length; i++) {
636
+ // The old wrap removed to make room for this new one, if any - put back if the add then fails, so a
637
+ // failed add never costs a working code.
638
+ let removedForThis: MasterKeyWrap | null = null;
639
+ try {
640
+ if (free <= 0) {
641
+ // Guaranteed non-empty by the shortfall check above.
642
+ const old = pendingOld.shift()!;
643
+ await removeMasterKeyWrap(mailboxUid!, "recovery", old.methodId);
644
+ removedForThis = old;
645
+ removedEarly++;
646
+ free++;
647
+ }
648
+ await addMasterKeyWrap(mailboxUid!, { ...built.wraps[i], methodId: `recovery-${batch}-${i + 1}` });
649
+ free--;
650
+ savedCodes.push(built.codes[i]);
651
+ } catch (err) {
652
+ addError = err;
653
+ if (removedForThis) {
654
+ try {
655
+ await addMasterKeyWrap(mailboxUid!, removedForThis);
656
+ removedEarly--;
657
+ } catch {
658
+ // Still counted in removedEarly, which the messages below report.
659
+ }
660
+ }
661
+ break;
662
+ }
663
+ }
664
+
665
+ const removedEarlyNote =
666
+ removedEarly > 0
667
+ ? `${removedEarly} of your old recovery codes had to be removed to make room; the rest were kept and still work`
668
+ : "Your old recovery codes were kept and still work";
669
+
670
+ if (savedCodes.length === 0) {
671
+ const message = addError instanceof ApiRequestError ? addError.message : "Could not regenerate recovery codes.";
672
+ setActionError(removedEarly > 0 ? `${message} ${removedEarlyNote}.` : message);
304
673
  setRegenerating(false);
674
+ await loadVault();
675
+ return;
676
+ }
677
+
678
+ let warning: string | null = null;
679
+ if (savedCodes.length < built.wraps.length) {
680
+ // Remaining old codes are deliberately left in place - the new set is incomplete, so the old one
681
+ // is still part of the user's recovery safety net.
682
+ warning = `Only ${savedCodes.length} of ${built.wraps.length} new recovery codes could be saved${
683
+ addError instanceof ApiRequestError ? ` (${addError.message})` : ""
684
+ }. ${removedEarlyNote} - save the codes below, then try regenerating again.`;
685
+ } else {
686
+ let notRemoved = 0;
687
+ for (const wrap of pendingOld) {
688
+ try {
689
+ await removeMasterKeyWrap(mailboxUid!, "recovery", wrap.methodId);
690
+ } catch {
691
+ notRemoved++;
692
+ }
693
+ }
694
+ if (notRemoved > 0) {
695
+ warning = `${notRemoved} of your old recovery codes could not be removed and still work. Remove them from the unlock methods list, or regenerate again.`;
696
+ }
305
697
  }
698
+
699
+ setRecoveryCodesReason("regenerate");
700
+ setCodesWarning(warning);
701
+ setNewRecoveryCodes(savedCodes);
702
+ setCodesSaved(false);
703
+ setRegenerating(false);
704
+ await loadVault();
306
705
  }
307
706
 
308
707
  async function handleCopyCodes() {
@@ -319,16 +718,21 @@ function EncryptionContent() {
319
718
 
320
719
  /**
321
720
  * Real revocation for a captured wrap (`keyvaultApi.ts`'s `rekey()` - see that function's own doc
322
- * comment): re-wraps this mailbox's already-unlocked private keys under a brand new master key
323
- * (`rewrapPrivateKeysUnderNewMasterKey()`), wraps that new MK under a freshly entered password and a
324
- * fresh set of recovery codes, and atomically replaces the vault - the enrolled keypair/certificate
325
- * itself is unchanged (restapi's own `rekey()` rejects anything else), only how it's protected.
326
- * Every *other* unlock method this mailbox had (a second password, a passkey, an old set of recovery
327
- * codes) stops working the instant this succeeds, since `rekey()` replaces `masterKeyWraps` wholesale
328
- * - the whole point, for a captured-wrap scenario where it's unclear which method was compromised.
721
+ * comment): re-wraps every private key in a freshly fetched vault under a brand new master key
722
+ * (`rewrapVaultPrivateKeys()` - all of them, active or not, since `rekey()` replaces `wrappedKeys`
723
+ * wholesale), wraps that new MK under a freshly entered password and a fresh set of recovery codes,
724
+ * and atomically replaces the vault - the enrolled keypair/certificate itself is unchanged (restapi's
725
+ * own `rekey()` rejects anything else, so `keys` is a fresh copy of the mailbox's, not this page's
726
+ * possibly stale list), only how it's protected. Every *other* unlock method this mailbox had stops
727
+ * working the instant this succeeds, since `rekey()` replaces `masterKeyWraps` wholesale - the whole
728
+ * point, for a captured-wrap scenario where it's unclear which method was compromised.
329
729
  */
330
730
  async function handleRotateKeys(e: FormEvent) {
331
731
  e.preventDefault();
732
+ if (rotationBlockedBySigning) {
733
+ // The form's controls are disabled in this state too; this also covers an implicit submit.
734
+ return;
735
+ }
332
736
  if (rotationPassword.length < MIN_PASSWORD_LENGTH) {
333
737
  setActionError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
334
738
  return;
@@ -340,48 +744,82 @@ function EncryptionContent() {
340
744
  setActionError(null);
341
745
  setEscrowError(null);
342
746
  setRotating(true);
747
+ let keys: PublicKey[];
748
+ let codes: string[];
343
749
  try {
344
- // Only reachable once `unlocked` is defined - see `handleAddPassword`'s identical note.
345
- const { mk, wrappedKeys } = await rewrapPrivateKeysUnderNewMasterKey(mailboxUid!, unlocked!);
750
+ const current = await currentUnlockedKeys();
751
+ const [freshVault, freshMailbox] = await Promise.all([getKeyVault(mailboxUid!), getMailbox(mailboxUid!)]);
752
+ keys = freshMailbox.keys ?? [];
753
+ // A session key that opens nothing is stale (rotated elsewhere) rather than missing some keys.
754
+ if (!(await masterKeyOpensVault(mailboxUid!, current.masterKey, freshVault))) {
755
+ throw new StaleSessionKeysError();
756
+ }
757
+ const rewrapped = await rewrapVaultPrivateKeys(mailboxUid!, current.masterKey, freshVault.wrappedKeys);
758
+ const mk = rewrapped.mk;
346
759
  const passwordWrap = await buildPasswordWrap(mailboxUid!, mk, rotationPassword);
347
- const { wraps: newRecoveryWraps, codes } = await buildRecoveryWraps(mailboxUid!, mk);
348
- await rekey(mailboxUid!, { wrappedKeys, masterKeyWraps: [passwordWrap, ...newRecoveryWraps], keys: mailbox.keys ?? [] });
349
-
350
- // restapi's own rekey() can never accept a fresh escrow wrap (its validateMasterKeyWrap()
351
- // always passes allowEscrow: false there) - it preserves this mailbox's existing escrow wrap
352
- // verbatim instead, which now encrypts a master key nobody has any longer. Re-wrap it
353
- // separately, via the same addMasterKeyWrap() path "Add escrow protection" above already
354
- // uses, so rotating keys for an unrelated reason (lost device, password hygiene) doesn't
355
- // silently drop real escrow coverage while this page keeps claiming it's still active. A
356
- // failure here is reported via escrowError, not as a rotation failure - the rotation itself
357
- // (password/recovery codes) already succeeded by this point and must not be rolled back for
358
- // an escrow-specific hiccup the user can retry independently.
359
- if (hasEscrowWrap) {
760
+ const recovery = await buildRecoveryWraps(mailboxUid!, mk);
761
+ codes = recovery.codes;
762
+ const masterKeyWraps = [passwordWrap, ...recovery.wraps];
763
+ // restapi's rekey() drops the old escrow wraps, and requires a replacement escrow wrap only when the vault
764
+ // already holds one and the mailbox's escrow scope still exists - so one is carried over exactly then, in
765
+ // the same request. A mailbox assigned a scope but never escrowed isn't silently escrowed by a rotation,
766
+ // and a deleted scope (escrow-info 404s) needs nothing. Any other failure to build it aborts the rotation -
767
+ // rotating without it would end escrow coverage. restapi's own 409 stays the final word.
768
+ if (freshVault.masterKeyWraps.some((w) => w.method === "escrow")) {
360
769
  try {
361
770
  const escrowInfo = await getEscrowInfo(mailboxUid!);
362
- const escrowWrap = await buildEscrowWrap(mk, escrowInfo.escrowScopeId, fromBase64(escrowInfo.publicKey.publicKey));
363
- await addMasterKeyWrap(mailboxUid!, escrowWrap);
771
+ masterKeyWraps.push(await buildEscrowWrap(mk, escrowInfo.escrowScopeId, fromBase64(escrowInfo.publicKey.publicKey)));
364
772
  } catch (err) {
365
- setEscrowError(
366
- err instanceof ApiRequestError
367
- ? err.message
368
- : 'Your keys were rotated, but escrow protection could not be re-established automatically. Use "Add escrow protection" below to restore it.',
369
- );
773
+ if (!(err instanceof ApiRequestError && err.status === 404)) {
774
+ throw new EscrowWrapUnavailableError(err);
775
+ }
370
776
  }
371
777
  }
778
+ await rekey(mailboxUid!, { wrappedKeys: rewrapped.wrappedKeys, masterKeyWraps, keys });
779
+ } catch (err) {
780
+ if (err instanceof UncoveredVaultKeysError) {
781
+ setActionError(
782
+ `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.`,
783
+ );
784
+ } else if (err instanceof EscrowWrapUnavailableError) {
785
+ const reason = err.cause instanceof ApiRequestError ? ` (${err.cause.message})` : "";
786
+ setActionError(
787
+ `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.`,
788
+ );
789
+ } else if (err instanceof ApiRequestError && err.status === 409) {
790
+ setActionError(ROTATION_CONFLICT_MESSAGE);
791
+ } else {
792
+ setActionError(errorMessage(err, "Could not rotate your encryption keys."));
793
+ }
794
+ setRotating(false);
795
+ return;
796
+ }
372
797
 
798
+ // The rotation is committed - every old unlock method is already dead, so these codes must be on
799
+ // screen right now, before anything below that could fail or hang.
800
+ const newPassword = rotationPassword;
801
+ setRotationPassword("");
802
+ setRotationConfirmPassword("");
803
+ setRecoveryCodesReason("rotate");
804
+ setCodesWarning(null);
805
+ setNewRecoveryCodes(codes);
806
+ setCodesSaved(false);
807
+ setRefreshedKeys(keys);
808
+ try {
373
809
  // Refreshes this session's own cached keys against the new MK, via the password we just set -
374
810
  // the underlying private key material didn't change, but the stale MK in memory would silently
375
- // build wrong future wraps (e.g. a second "Add a password") if left as-is.
376
- await unlockWithPassword(mailboxUid!, mailbox.keys ?? [], rotationPassword);
377
- setRotationPassword("");
378
- setRotationConfirmPassword("");
379
- setRecoveryCodesReason("rotate");
380
- setNewRecoveryCodes(codes);
381
- setCodesSaved(false);
811
+ // build wrong future wraps if left as-is. If that fails, the stale keys are destroyed instead.
812
+ try {
813
+ await unlockWithPassword(mailboxUid!, keys, newPassword);
814
+ } catch {
815
+ destroyUnlockedKeys(mailboxUid);
816
+ void destroyLocalIndex(mailboxUid!);
817
+ setRotationRelockReason(
818
+ "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.",
819
+ );
820
+ setDestroyed(true);
821
+ }
382
822
  await loadVault();
383
- } catch (err) {
384
- setActionError(err instanceof ApiRequestError ? err.message : "Could not rotate your encryption keys.");
385
823
  } finally {
386
824
  setRotating(false);
387
825
  }
@@ -398,28 +836,20 @@ function EncryptionContent() {
398
836
  setDestroyed(true);
399
837
  }
400
838
 
401
- if (destroyed) {
402
- return (
403
- <div className="flex-1 min-w-0 overflow-y-auto p-6">
404
- <div className="max-w-xl">
405
- <Alert>
406
- Your encryption keys have been removed from this session. Reload the page (or open Mail
407
- again) to unlock them when you need to read or send encrypted mail.
408
- </Alert>
409
- </div>
410
- </div>
411
- );
412
- }
413
-
839
+ // Checked before `destroyed` - a rotation whose re-unlock failed destroys this session's keys, but its
840
+ // new recovery codes still have to be seen (and acknowledged) first.
414
841
  if (newRecoveryCodes) {
415
842
  return (
416
843
  <div className="flex-1 min-w-0 overflow-y-auto p-6">
417
844
  <div className="max-w-xl">
418
845
  <h1 className="text-lg font-bold tracking-tight mb-1">Save your new recovery codes</h1>
846
+ {codesWarning && <Alert>{codesWarning}</Alert>}
419
847
  <p className="text-sm text-text-muted mb-4">
420
848
  {recoveryCodesReason === "rotate"
421
849
  ? "Your keys have been rotated - every previous unlock method (password, recovery codes, or anything else on file) has stopped working. "
422
- : "Your old recovery codes no longer work. "}
850
+ : codesWarning
851
+ ? ""
852
+ : "Your old recovery codes no longer work. "}
423
853
  If you lose your password, these new codes are the only way to recover your encrypted mail.
424
854
  Each code can be used once. Store them somewhere safe — they will not be shown again.
425
855
  </p>
@@ -445,6 +875,19 @@ function EncryptionContent() {
445
875
  );
446
876
  }
447
877
 
878
+ if (destroyed) {
879
+ return (
880
+ <div className="flex-1 min-w-0 overflow-y-auto p-6">
881
+ <div className="max-w-xl">
882
+ <Alert>
883
+ {rotationRelockReason ??
884
+ "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."}
885
+ </Alert>
886
+ </div>
887
+ </div>
888
+ );
889
+ }
890
+
448
891
  return (
449
892
  <div className="flex-1 min-w-0 overflow-y-auto p-6">
450
893
  <div className="max-w-xl flex flex-col gap-6">
@@ -457,6 +900,12 @@ function EncryptionContent() {
457
900
 
458
901
  {loadError && <Alert>{loadError}</Alert>}
459
902
  {actionError && <Alert>{actionError}</Alert>}
903
+ {!canManageKeys && (
904
+ <p className="text-sm text-text-muted">
905
+ Only this mailbox&rsquo;s owner, signed in as themselves, can change its encryption keys and
906
+ unlock methods.
907
+ </p>
908
+ )}
460
909
 
461
910
  <div>
462
911
  <h2 className="text-sm font-semibold mb-2">Encryption keys</h2>
@@ -465,7 +914,12 @@ function EncryptionContent() {
465
914
  {displayedKeys.map((key) => (
466
915
  <li key={key.fingerprint} className="font-mono text-xs">
467
916
  {key.useType === "sign" ? "Signing" : "Encryption"} key: {key.fingerprint}
468
- {key.revokedAt && <span className="text-danger"> (revoked)</span>}
917
+ {key.revokedAt &&
918
+ (key.revocationReason === "superseded" ? (
919
+ <span className="text-text-muted"> (superseded)</span>
920
+ ) : (
921
+ <span className="text-danger"> (revoked)</span>
922
+ ))}
469
923
  </li>
470
924
  ))}
471
925
  </ul>
@@ -487,6 +941,8 @@ function EncryptionContent() {
487
941
  exchange, which can take a few minutes. You can leave this page; it finishes in
488
942
  the background and takes effect automatically once issued.
489
943
  </p>
944
+ ) : !canManageKeys ? (
945
+ <p className="text-sm text-text-muted">Not enabled.</p>
490
946
  ) : (
491
947
  <div className="flex flex-col gap-2">
492
948
  <p className="text-xs text-text-muted">
@@ -499,7 +955,7 @@ function EncryptionContent() {
499
955
  variant="secondary"
500
956
  className="!w-auto"
501
957
  loading={signingStatus === "enrolling"}
502
- disabled={signingStatus === "enrolling"}
958
+ disabled={signingStatus !== "idle"}
503
959
  onClick={handleEnrollSigning}
504
960
  >
505
961
  Enable digital signatures
@@ -511,9 +967,6 @@ function EncryptionContent() {
511
967
  {mailbox.escrowScopeId && (
512
968
  <div>
513
969
  <h2 className="text-sm font-semibold mb-2">Escrow</h2>
514
- {/* Rendered regardless of hasEscrowWrap - a rotation-triggered re-wrap failure
515
- (see handleRotateKeys) leaves the mailbox's old, now-stale escrow wrap in place,
516
- so hasEscrowWrap alone can't be trusted to hide this. */}
517
970
  {escrowError && <Alert>{escrowError}</Alert>}
518
971
  {hasEscrowWrap ? (
519
972
  <p className="text-sm text-text-muted">
@@ -524,21 +977,23 @@ function EncryptionContent() {
524
977
  ) : (
525
978
  <div className="flex flex-col gap-2">
526
979
  <p className="text-xs text-text-muted">
527
- Your organization has assigned this mailbox to an escrow scope, but nothing
528
- has been protected yet — an authorized holder cannot recover this mailbox's
529
- encrypted mail until you complete this step. This does not weaken protection
530
- against anyone else.
980
+ Your organization has assigned this mailbox to an escrow scope, but nothing has been
981
+ protected yet — an authorized holder cannot recover this mailbox&rsquo;s encrypted mail
982
+ until you complete this step.{" "}
983
+ This does not weaken protection against anyone else.
531
984
  </p>
532
- <Button
533
- type="button"
534
- variant="secondary"
535
- className="!w-auto"
536
- loading={wrappingEscrow}
537
- disabled={wrappingEscrow}
538
- onClick={handleWrapEscrow}
539
- >
540
- Add escrow protection
541
- </Button>
985
+ {canManageKeys && (
986
+ <Button
987
+ type="button"
988
+ variant="secondary"
989
+ className="!w-auto"
990
+ loading={wrappingEscrow}
991
+ disabled={wrappingEscrow}
992
+ onClick={handleWrapEscrow}
993
+ >
994
+ Add escrow protection
995
+ </Button>
996
+ )}
542
997
  </div>
543
998
  )}
544
999
  </div>
@@ -559,18 +1014,24 @@ function EncryptionContent() {
559
1014
  return (
560
1015
  <li key={key} className="flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm">
561
1016
  <span>{METHOD_LABELS[wrap.method] ?? wrap.method}</span>
562
- {wrap.method !== "escrow" && (
563
- <Button
564
- type="button"
565
- variant="text"
566
- className="!w-auto text-danger"
567
- loading={removingMethod === key}
568
- disabled={removingMethod !== null}
569
- onClick={() => handleRemove(wrap)}
570
- >
571
- Remove
572
- </Button>
573
- )}
1017
+ {wrap.method !== "escrow" &&
1018
+ canManageKeys &&
1019
+ (canRemoveWrap(wrap) ? (
1020
+ <Button
1021
+ type="button"
1022
+ variant="text"
1023
+ className="!w-auto text-danger"
1024
+ loading={removingMethod === key}
1025
+ disabled={removingMethod !== null}
1026
+ onClick={() => setPendingRemoval(wrap)}
1027
+ >
1028
+ Remove
1029
+ </Button>
1030
+ ) : (
1031
+ <span className="text-xs text-text-muted">
1032
+ {ownUnlockWrapCount > 1 ? "Needed to unlock" : "Your only unlock method"}
1033
+ </span>
1034
+ ))}
574
1035
  </li>
575
1036
  );
576
1037
  })}
@@ -578,91 +1039,153 @@ function EncryptionContent() {
578
1039
  ) : (
579
1040
  <p className="text-sm text-text-muted">No unlock methods on file.</p>
580
1041
  )}
1042
+ <Modal open={pendingRemoval !== null} onClose={() => setPendingRemoval(null)} title="Remove unlock method">
1043
+ <p className="text-sm mb-5">
1044
+ Remove this unlock method? It will no longer unlock this mailbox&rsquo;s encrypted mail on any device.
1045
+ </p>
1046
+ <div className="flex gap-3 justify-end">
1047
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setPendingRemoval(null)}>
1048
+ Cancel
1049
+ </Button>
1050
+ <Button
1051
+ type="button"
1052
+ className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger"
1053
+ onClick={() => handleRemove(pendingRemoval!)}
1054
+ >
1055
+ Remove method
1056
+ </Button>
1057
+ </div>
1058
+ </Modal>
581
1059
  </div>
582
1060
 
583
- <form onSubmit={handleAddPassword} className="flex flex-col gap-2">
584
- <h2 className="text-sm font-semibold">Add a password</h2>
585
- <input
586
- type="password"
587
- aria-label="New password"
588
- placeholder="New password"
589
- className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
590
- value={newPassword}
591
- onChange={(e) => setNewPassword(e.target.value)}
592
- disabled={addingPassword}
593
- autoComplete="new-password"
594
- />
595
- <input
596
- type="password"
597
- aria-label="Confirm new password"
598
- placeholder="Confirm new password"
599
- className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
600
- value={confirmNewPassword}
601
- onChange={(e) => setConfirmNewPassword(e.target.value)}
602
- disabled={addingPassword}
603
- autoComplete="new-password"
604
- />
605
- <div>
606
- <Button type="submit" loading={addingPassword} disabled={addingPassword} className="!w-auto">
607
- Add password
608
- </Button>
609
- </div>
610
- </form>
611
-
612
- <div>
613
- <h2 className="text-sm font-semibold mb-2">Recovery codes</h2>
614
- <p className="text-xs text-text-muted mb-3">
615
- Regenerating replaces all of your existing recovery codes — old ones stop working
616
- immediately.
617
- </p>
618
- <Button
619
- type="button"
620
- variant="secondary"
621
- loading={regenerating}
622
- disabled={regenerating}
623
- onClick={handleRegenerateRecoveryCodes}
624
- className="!w-auto"
625
- >
626
- Regenerate recovery codes
627
- </Button>
628
- </div>
1061
+ {canManageKeys && (
1062
+ <>
1063
+ {passwordWraps.length === 0 ? (
1064
+ vault && (
1065
+ <form onSubmit={handleAddPassword} className="flex flex-col gap-2">
1066
+ <h2 className="text-sm font-semibold">Add a password</h2>
1067
+ <input
1068
+ type="password"
1069
+ aria-label="New password"
1070
+ placeholder="New password"
1071
+ className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
1072
+ value={newPassword}
1073
+ onChange={(e) => setNewPassword(e.target.value)}
1074
+ disabled={addingPassword}
1075
+ autoComplete="new-password"
1076
+ />
1077
+ <input
1078
+ type="password"
1079
+ aria-label="Confirm new password"
1080
+ placeholder="Confirm new password"
1081
+ className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
1082
+ value={confirmNewPassword}
1083
+ onChange={(e) => setConfirmNewPassword(e.target.value)}
1084
+ disabled={addingPassword}
1085
+ autoComplete="new-password"
1086
+ />
1087
+ <div>
1088
+ <Button type="submit" loading={addingPassword} disabled={addingPassword} className="!w-auto">
1089
+ Add password
1090
+ </Button>
1091
+ </div>
1092
+ </form>
1093
+ )
1094
+ ) : (
1095
+ <div>
1096
+ <h2 className="text-sm font-semibold mb-2">Changing your password</h2>
1097
+ <p className="text-xs text-text-muted">
1098
+ Only one password can unlock this mailbox. To change it, rotate your keys below
1099
+ - that sets a new password and replaces every other unlock method.
1100
+ </p>
1101
+ </div>
1102
+ )}
1103
+ <div>
1104
+ <h2 className="text-sm font-semibold mb-2">Recovery codes</h2>
1105
+ <p className="text-xs text-text-muted mb-3">
1106
+ Regenerating replaces all of your existing recovery codes — old ones stop working
1107
+ immediately.
1108
+ </p>
1109
+ <Button
1110
+ type="button"
1111
+ variant="secondary"
1112
+ loading={regenerating}
1113
+ disabled={regenerating || !vault}
1114
+ onClick={handleRegenerateRecoveryCodes}
1115
+ className="!w-auto"
1116
+ >
1117
+ Regenerate recovery codes
1118
+ </Button>
1119
+ </div>
629
1120
 
630
- <form onSubmit={handleRotateKeys} className="flex flex-col gap-2 border-t border-border pt-6">
631
- <h2 className="text-sm font-semibold">Rotate keys</h2>
632
- <p className="text-xs text-text-muted mb-1">
633
- Real revocation, for when a device or an unlock method may have been compromised.
634
- Re-protects your existing encryption key under a brand new master key - your signing/
635
- encryption keypair itself doesn&rsquo;t change, so mail you&rsquo;ve already sent or
636
- received still decrypts normally. Every current unlock method (password, recovery codes,
637
- and anything else on file) stops working immediately; you&rsquo;ll set a new password and
638
- get new recovery codes below.
639
- </p>
640
- <input
641
- type="password"
642
- aria-label="New password for rotated keys"
643
- placeholder="New password"
644
- className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
645
- value={rotationPassword}
646
- onChange={(e) => setRotationPassword(e.target.value)}
647
- disabled={rotating}
648
- autoComplete="new-password"
649
- />
650
- <input
651
- type="password"
652
- aria-label="Confirm new password for rotated keys"
653
- placeholder="Confirm new password"
654
- className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
655
- value={rotationConfirmPassword}
656
- onChange={(e) => setRotationConfirmPassword(e.target.value)}
657
- disabled={rotating}
658
- autoComplete="new-password"
659
- />
660
- <div>
661
- <Button type="submit" variant="secondary" className="!w-auto text-danger" loading={rotating} disabled={rotating}>
662
- Rotate keys now
663
- </Button>
664
- </div>
665
- </form>
1121
+ <form onSubmit={handleRotateKeys} className="flex flex-col gap-2 border-t border-border pt-6">
1122
+ <h2 className="text-sm font-semibold">Rotate keys</h2>
1123
+ <p className="text-xs text-text-muted mb-1">
1124
+ Real revocation, for when a device or an unlock method may have been compromised.
1125
+ Re-protects your existing encryption key under a brand new master key - your signing/
1126
+ encryption keypair itself doesn&rsquo;t change, so mail you&rsquo;ve already sent or
1127
+ received still decrypts normally. Every current unlock method (password, recovery codes,
1128
+ and anything else on file) stops working immediately; you&rsquo;ll set a new password and
1129
+ get new recovery codes below.
1130
+ </p>
1131
+ {rotationBlockedBySigning && (
1132
+ <p className="text-xs text-text-muted">
1133
+ {signingStatus === "checking"
1134
+ ? "Checking whether a signing certificate enrollment is still in progress..."
1135
+ : "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."}
1136
+ </p>
1137
+ )}
1138
+ {signingStatus === "pending" && signingEnrollmentId && (
1139
+ <div className="flex flex-col gap-2">
1140
+ {cancelEnrollmentError && <Alert>{cancelEnrollmentError}</Alert>}
1141
+ <div>
1142
+ <Button
1143
+ type="button"
1144
+ variant="secondary"
1145
+ className="!w-auto"
1146
+ loading={cancelingEnrollment}
1147
+ disabled={cancelingEnrollment}
1148
+ onClick={() => handleCancelEnrollment(signingEnrollmentId)}
1149
+ >
1150
+ Cancel enrollment
1151
+ </Button>
1152
+ </div>
1153
+ </div>
1154
+ )}
1155
+ <input
1156
+ type="password"
1157
+ aria-label="New password for rotated keys"
1158
+ placeholder="New password"
1159
+ className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
1160
+ value={rotationPassword}
1161
+ onChange={(e) => setRotationPassword(e.target.value)}
1162
+ disabled={rotating || rotationBlockedBySigning}
1163
+ autoComplete="new-password"
1164
+ />
1165
+ <input
1166
+ type="password"
1167
+ aria-label="Confirm new password for rotated keys"
1168
+ placeholder="Confirm new password"
1169
+ className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
1170
+ value={rotationConfirmPassword}
1171
+ onChange={(e) => setRotationConfirmPassword(e.target.value)}
1172
+ disabled={rotating || rotationBlockedBySigning}
1173
+ autoComplete="new-password"
1174
+ />
1175
+ <div>
1176
+ <Button
1177
+ type="submit"
1178
+ variant="secondary"
1179
+ className="!w-auto text-danger"
1180
+ loading={rotating}
1181
+ disabled={rotating || rotationBlockedBySigning}
1182
+ >
1183
+ Rotate keys now
1184
+ </Button>
1185
+ </div>
1186
+ </form>
1187
+ </>
1188
+ )}
666
1189
 
667
1190
  <div>
668
1191
  <h2 className="text-sm font-semibold mb-2">Session timeout</h2>