@rapidmx/web-client 0.36.0 → 0.37.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 (227) hide show
  1. package/apps/shared/calendar/calendarReminders.ts +35 -0
  2. package/apps/shared/calendar/sealedEvents.ts +42 -9
  3. package/apps/shared/calendar/useCalendarReminders.ts +28 -9
  4. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
  5. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +29 -1
  6. package/apps/shared/components/calendar/EventDetails.tsx +13 -2
  7. package/apps/shared/components/calendar/EventEditor.tsx +124 -17
  8. package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
  9. package/apps/shared/components/calendar/EventModal.tsx +19 -2
  10. package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
  11. package/apps/shared/components/calendar/eventForm.ts +2 -0
  12. package/apps/shared/components/contacts/ContactCard.tsx +13 -1
  13. package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
  14. package/apps/shared/components/contacts/ContactForm.tsx +56 -7
  15. package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
  16. package/apps/shared/components/contacts/contactCardData.ts +6 -3
  17. package/apps/shared/components/contacts/contactKeys.ts +3 -3
  18. package/apps/shared/components/layout/AppShell.tsx +34 -5
  19. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +11 -4
  20. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +37 -6
  21. package/apps/shared/components/mail/InviteCard.tsx +11 -1
  22. package/apps/shared/components/mail/MessageDetailPane.tsx +25 -14
  23. package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
  24. package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
  25. package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
  26. package/apps/shared/components/mail/invite/inviteStore.ts +11 -1
  27. package/apps/shared/components/mail/pinnedSigners.ts +14 -0
  28. package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
  29. package/apps/shared/components/settings/KeyExportSetting.tsx +4 -1
  30. package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
  31. package/apps/shared/components/settings/ZeroAccessSetting.tsx +13 -2
  32. package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
  33. package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
  34. package/apps/shared/mail/outbox/sendJob.ts +349 -50
  35. package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
  36. package/apps/shared/search/LocalIndexLifecycle.tsx +62 -33
  37. package/apps/shared/search/localIndexBuilder.ts +35 -24
  38. package/apps/shared/search/localIndexRpcClient.ts +91 -11
  39. package/apps/shared/search/localIndexSchema.ts +18 -2
  40. package/apps/shared/search/localIndexTransport.ts +6 -3
  41. package/apps/shared/search/localIndexWorker.ts +35 -18
  42. package/apps/shared/search/searchTier2.ts +5 -0
  43. package/apps/shared/search/zeroAccessIndexSync.ts +232 -40
  44. package/apps/shared/search/zeroAccessSyncState.ts +70 -0
  45. package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
  46. package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
  47. package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
  48. package/apps/www/_shell.tsx +2 -0
  49. package/apps/www/calendar/index.tsx +49 -5
  50. package/apps/www/contacts/index.tsx +67 -11
  51. package/apps/www/index.tsx +10 -1
  52. package/apps/www/settings/encryption/index.tsx +16 -1
  53. package/apps/www/settings/privacy/index.tsx +87 -38
  54. package/apps/www/tasks/index.tsx +24 -6
  55. package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
  56. package/dist/apps/shared/calendar/calendarReminders.js +26 -0
  57. package/dist/apps/shared/calendar/sealedEvents.d.ts +3 -2
  58. package/dist/apps/shared/calendar/sealedEvents.js +40 -9
  59. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
  60. package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
  61. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
  62. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +9 -2
  63. package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
  64. package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
  65. package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
  66. package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
  67. package/dist/apps/shared/components/calendar/EventModal.js +15 -2
  68. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
  69. package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
  70. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
  71. package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
  72. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
  73. package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
  74. package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
  75. package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
  76. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
  77. package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
  78. package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
  79. package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
  80. package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
  81. package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
  82. package/dist/apps/shared/components/layout/AppShell.js +17 -6
  83. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -4
  84. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +3 -1
  85. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +30 -4
  86. package/dist/apps/shared/components/mail/InviteCard.js +11 -1
  87. package/dist/apps/shared/components/mail/MessageDetailPane.js +15 -11
  88. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
  89. package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
  90. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
  91. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
  92. package/dist/apps/shared/components/mail/invite/inviteStore.js +11 -1
  93. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
  94. package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
  95. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
  96. package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
  97. package/dist/apps/shared/components/settings/KeyExportSetting.js +3 -1
  98. package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
  99. package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
  100. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +2 -0
  101. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +12 -3
  102. package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
  103. package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
  104. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  105. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  106. package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
  107. package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
  108. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
  109. package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
  110. package/dist/apps/shared/search/LocalIndexLifecycle.js +55 -33
  111. package/dist/apps/shared/search/localIndexBuilder.d.ts +4 -1
  112. package/dist/apps/shared/search/localIndexBuilder.js +31 -20
  113. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
  114. package/dist/apps/shared/search/localIndexRpcClient.js +126 -10
  115. package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
  116. package/dist/apps/shared/search/localIndexSchema.js +16 -2
  117. package/dist/apps/shared/search/localIndexTransport.d.ts +6 -3
  118. package/dist/apps/shared/search/localIndexWorker.d.ts +9 -0
  119. package/dist/apps/shared/search/localIndexWorker.js +22 -15
  120. package/dist/apps/shared/search/searchTier2.js +5 -0
  121. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +37 -6
  122. package/dist/apps/shared/search/zeroAccessIndexSync.js +199 -37
  123. package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
  124. package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
  125. package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
  126. package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
  127. package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
  128. package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
  129. package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
  130. package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
  131. package/dist/apps/www/_shell.d.ts +1 -1
  132. package/dist/apps/www/_shell.js +2 -2
  133. package/dist/apps/www/calendar/index.js +47 -5
  134. package/dist/apps/www/contacts/index.js +64 -11
  135. package/dist/apps/www/index.js +10 -1
  136. package/dist/apps/www/settings/encryption/index.js +14 -4
  137. package/dist/apps/www/settings/privacy/index.js +46 -5
  138. package/dist/apps/www/tasks/index.js +21 -7
  139. package/dist/lib/admin/domainsApi.d.ts +1 -1
  140. package/dist/lib/calendar/calendarApi.d.ts +46 -6
  141. package/dist/lib/calendar/calendarApi.js +110 -17
  142. package/dist/lib/calendar/calendarMutations.d.ts +20 -7
  143. package/dist/lib/calendar/calendarMutations.js +73 -14
  144. package/dist/lib/calendar/inviteApi.d.ts +17 -3
  145. package/dist/lib/calendar/inviteApi.js +61 -4
  146. package/dist/lib/calendar/protectedEvents.d.ts +31 -0
  147. package/dist/lib/calendar/protectedEvents.js +113 -0
  148. package/dist/lib/calendar/recurrence.d.ts +2 -0
  149. package/dist/lib/calendar/recurrence.js +16 -0
  150. package/dist/lib/calendar/sealedInvites.d.ts +6 -8
  151. package/dist/lib/calendar/sealedInvites.js +43 -6
  152. package/dist/lib/contacts/contactsApi.d.ts +29 -3
  153. package/dist/lib/contacts/contactsApi.js +23 -7
  154. package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
  155. package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
  156. package/dist/lib/crypto/composeSecurity.d.ts +11 -3
  157. package/dist/lib/crypto/composeSecurity.js +13 -5
  158. package/dist/lib/crypto/keyExport.js +2 -2
  159. package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
  160. package/dist/lib/crypto/keyPairMatch.js +65 -0
  161. package/dist/lib/crypto/keySession.d.ts +19 -2
  162. package/dist/lib/crypto/keySession.js +89 -13
  163. package/dist/lib/crypto/keyvaultApi.d.ts +41 -5
  164. package/dist/lib/crypto/keyvaultApi.js +13 -3
  165. package/dist/lib/crypto/meetingKeys.d.ts +25 -0
  166. package/dist/lib/crypto/meetingKeys.js +87 -0
  167. package/dist/lib/crypto/messageSecurity.d.ts +26 -2
  168. package/dist/lib/crypto/messageSecurity.js +122 -10
  169. package/dist/lib/crypto/openPgpKey.d.ts +39 -0
  170. package/dist/lib/crypto/openPgpKey.js +89 -0
  171. package/dist/lib/crypto/openpgp.d.ts +47 -0
  172. package/dist/lib/crypto/openpgp.js +88 -0
  173. package/dist/lib/crypto/pgpMessage.d.ts +51 -0
  174. package/dist/lib/crypto/pgpMessage.js +271 -0
  175. package/dist/lib/crypto/sharedMailboxKeys.d.ts +15 -3
  176. package/dist/lib/crypto/sharedMailboxKeys.js +102 -29
  177. package/dist/lib/crypto/smimeMessage.d.ts +21 -0
  178. package/dist/lib/crypto/smimeMessage.js +11 -7
  179. package/dist/lib/crypto/zeroAccessKey.js +5 -0
  180. package/dist/lib/mail/mailApi.d.ts +33 -0
  181. package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
  182. package/dist/lib/mail/mailboxImportApi.js +15 -0
  183. package/dist/lib/search/zeroAccessIndexApi.d.ts +4 -0
  184. package/dist/lib/tasks/tasksApi.d.ts +11 -1
  185. package/dist/lib/tasks/tasksApi.js +10 -4
  186. package/dist/lib/util/clientPlatform.d.ts +14 -0
  187. package/dist/lib/util/clientPlatform.js +26 -0
  188. package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
  189. package/dist/lib/videoconf/protectedMeetings.js +181 -0
  190. package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
  191. package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
  192. package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
  193. package/dist/lib/zeroAccess/sealedMoves.js +63 -0
  194. package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
  195. package/dist/lib/zeroAccess/sealedRecords.js +136 -0
  196. package/lib/admin/domainsApi.ts +1 -1
  197. package/lib/calendar/calendarApi.ts +140 -17
  198. package/lib/calendar/calendarMutations.ts +79 -12
  199. package/lib/calendar/inviteApi.ts +75 -5
  200. package/lib/calendar/protectedEvents.ts +143 -0
  201. package/lib/calendar/recurrence.ts +17 -0
  202. package/lib/calendar/sealedInvites.ts +47 -7
  203. package/lib/contacts/contactsApi.ts +43 -17
  204. package/lib/contacts/useContactPhotoSrc.ts +103 -13
  205. package/lib/crypto/composeSecurity.ts +20 -5
  206. package/lib/crypto/keyExport.ts +2 -2
  207. package/lib/crypto/keyPairMatch.ts +69 -0
  208. package/lib/crypto/keySession.ts +102 -12
  209. package/lib/crypto/keyvaultApi.ts +49 -7
  210. package/lib/crypto/meetingKeys.ts +118 -0
  211. package/lib/crypto/messageSecurity.ts +174 -13
  212. package/lib/crypto/openPgpKey.ts +118 -0
  213. package/lib/crypto/openpgp.ts +115 -0
  214. package/lib/crypto/pgpMessage.ts +336 -0
  215. package/lib/crypto/sharedMailboxKeys.ts +106 -28
  216. package/lib/crypto/smimeMessage.ts +11 -7
  217. package/lib/crypto/zeroAccessKey.ts +5 -0
  218. package/lib/mail/mailApi.ts +27 -0
  219. package/lib/mail/mailboxImportApi.ts +24 -0
  220. package/lib/search/zeroAccessIndexApi.ts +4 -0
  221. package/lib/tasks/tasksApi.ts +19 -5
  222. package/lib/util/clientPlatform.ts +50 -0
  223. package/lib/videoconf/protectedMeetings.ts +221 -0
  224. package/lib/videoconf/videoMeetingsApi.ts +15 -4
  225. package/lib/zeroAccess/sealedMoves.ts +79 -0
  226. package/lib/zeroAccess/sealedRecords.ts +153 -0
  227. package/package.json +12 -3
@@ -7,6 +7,7 @@ import { getUnlockedKeys, personallyUnlockedKeys, subscribeKeySession } from "..
7
7
  import type { Mailbox } from "../../../../lib/mail/mailApi.js";
8
8
  import type { ApiClient } from "../../../../lib/util/api.js";
9
9
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
10
+ import { notify } from "../../notifications/store.js";
10
11
 
11
12
  /** How often the shared mailboxes are looked after again while some own mailbox stays unlocked. */
12
13
  export const SHARED_MAILBOX_CHECK_INTERVAL_MS = 5 * 60 * 1000;
@@ -19,6 +20,14 @@ const loadSharedKeys = () =>
19
20
  throw err;
20
21
  }));
21
22
 
23
+ /** The refused rotations already shown, per mailbox (the reason): each is shown once a session, not on every pass that retries it. */
24
+ const shownRefusals = new Map<string, string>();
25
+
26
+ /** Forgets which refused rotations were shown (tests). */
27
+ export function resetSharedMailboxKeyManager(): void {
28
+ shownRefusals.clear();
29
+ }
30
+
22
31
  export interface SharedMailboxKeyManagerProps {
23
32
  /** Every mailbox the user can open. */
24
33
  mailboxes: Mailbox[];
@@ -29,7 +38,7 @@ export interface SharedMailboxKeyManagerProps {
29
38
  * Looks after the zero-access keys of the user's shared mailboxes (`lib/crypto/sharedMailboxKeys.ts`) whenever a mailbox of their own is
30
39
  * unlocked, and every `SHARED_MAILBOX_CHECK_INTERVAL_MS` after that: opens each mailbox shared with them through their member key wraps (no
31
40
  * password), and - for a mailbox they own or manage - provisions, rotates and shares its key with members still waiting. Best effort and
32
- * silent: a pass that fails is tried again on the next. Renders nothing; mounted by `MailShell`.
41
+ * silent (a pass that fails is tried again on the next), except for a rotation the server refuses, shown once as a warning. Renders nothing; mounted by `MailShell`.
33
42
  */
34
43
  export default function SharedMailboxKeyManager({ mailboxes, userUid }: SharedMailboxKeyManagerProps) {
35
44
  const client = useApiClient();
@@ -75,17 +84,39 @@ async function look(
75
84
  mailbox: Mailbox,
76
85
  me: string | undefined,
77
86
  client: ApiClient | undefined,
78
- unlockSharedMailbox: (uid: string, client?: ApiClient) => Promise<string>,
79
- maintainSharedMailbox: (uid: string, address: string, client?: ApiClient) => Promise<unknown>,
87
+ unlockSharedMailbox: (uid: string, client?: ApiClient, refresh?: boolean) => Promise<string>,
88
+ maintainSharedMailbox: (
89
+ uid: string,
90
+ address: string,
91
+ client?: ApiClient,
92
+ ) => Promise<{ provisioned: boolean; rotated: boolean; rotationRefused?: string }>,
80
93
  ): Promise<void> {
81
94
  const owned = !!mailbox.ownerUserUid && mailbox.ownerUserUid === me;
82
- if (!owned && !getUnlockedKeys(mailbox.uid)) {
83
- await unlockSharedMailbox(mailbox.uid, client);
95
+ if (!owned) {
96
+ // Opened, or - once open - opened again when a rotation left it without the active key.
97
+ await unlockSharedMailbox(mailbox.uid, client, true);
84
98
  }
85
99
  const unlocked = getUnlockedKeys(mailbox.uid);
86
100
  // An owner looks after their mailbox with its own keys; a member, with full access, whether or not it is open yet (a mailbox
87
101
  // without an owner may have no key to open).
88
102
  if (owned ? unlocked && !unlocked.shared : true) {
89
- await maintainSharedMailbox(mailbox.uid, mailbox.primarySmtpAddress, client);
103
+ const done = await maintainSharedMailbox(mailbox.uid, mailbox.primarySmtpAddress, client);
104
+ // The server kept the current key (its vault can't take the new key's member wraps): say so once, members keep reading.
105
+ if (done.rotationRefused && shownRefusals.get(mailbox.uid) !== done.rotationRefused) {
106
+ shownRefusals.set(mailbox.uid, done.rotationRefused);
107
+ notify({
108
+ kind: "warning",
109
+ title: `${mailbox.displayName || mailbox.primarySmtpAddress}: the zero-access key wasn't replaced`,
110
+ message: "A member has left, but the server refused a new key for this shared mailbox. The current key stays active.",
111
+ details: done.rotationRefused,
112
+ dedupeKey: `shared-rotation-refused:${mailbox.uid}`,
113
+ });
114
+ } else if (!done.rotationRefused) {
115
+ shownRefusals.delete(mailbox.uid);
116
+ }
117
+ // A key this member just made (a mailbox without an owner) goes into their session now, not on the next pass.
118
+ if (!owned && (done.provisioned || done.rotated)) {
119
+ await unlockSharedMailbox(mailbox.uid, client, true);
120
+ }
90
121
  }
91
122
  }
@@ -15,6 +15,7 @@ import {
15
15
  respondToMessageInvite,
16
16
  } from "../../../../lib/calendar/inviteApi.js";
17
17
  import { guestPermissionsOf } from "../../../../lib/calendar/calendarApi.js";
18
+ import { openSealedEvent } from "../../calendar/sealedEvents.js";
18
19
  import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
19
20
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
20
21
  import Button from "../../../../lib/components/buttons/Button.js";
@@ -334,7 +335,7 @@ export default function InviteCard({ messageUid, headingLevel = 2 }: InviteCardP
334
335
  aria-busy={pending === "acceptProposal" || undefined}
335
336
  loading={pending === "acceptProposal"}
336
337
  disabled={busy}
337
- onClick={() => void run("acceptProposal", "Couldn't accept the proposed time", () => acceptProposal(messageUid, client))}
338
+ onClick={() => void run("acceptProposal", "Couldn't accept the proposed time", () => acceptProposal(messageUid, client, invite.calendarEventUid, openSealedEvent))}
338
339
  >
339
340
  Accept proposal
340
341
  </Button>
@@ -387,6 +388,15 @@ export default function InviteCard({ messageUid, headingLevel = 2 }: InviteCardP
387
388
  description: invite.description,
388
389
  descriptionHtml: invite.descriptionHtml,
389
390
  guestAddresses: invite.attendees.map((attendee) => attendee.address),
391
+ // The invitation's own content: what a protected copy of the event is named by.
392
+ invitationContent: invite.summary
393
+ ? {
394
+ title: invite.summary,
395
+ location: invite.location ?? undefined,
396
+ description: invite.description ?? undefined,
397
+ descriptionHtml: invite.descriptionHtml ?? undefined,
398
+ }
399
+ : undefined,
390
400
  }}
391
401
  canChange={!!invite.canRequestChange}
392
402
  canInvite={!!invite.canRequestInvite}
@@ -50,11 +50,11 @@ import { isCalendarAttachment } from "./invite/inviteFormat.js";
50
50
  import { useMessageInvite } from "./invite/inviteStore.js";
51
51
  import { rememberSealedInvite } from "../../../../lib/calendar/sealedInvites.js";
52
52
  import { getUnlockedKeys, subscribeKeySession } from "../../../../lib/crypto/keySession.js";
53
- import type { MessageSecurityResult, SignatureFailureReason } from "../../../../lib/crypto/messageSecurity.js";
53
+ import type { MessageSecurityResult, SignatureFailureReason, SignerPinsInput } from "../../../../lib/crypto/messageSecurity.js";
54
54
  import { extractAddresses, type MimeAttachment } from "../../../../lib/crypto/mime.js";
55
55
  import { SignerKeyConflictError, signingKeyFingerprints, trustSigner } from "../../../../lib/crypto/keyvaultApi.js";
56
56
  import { isLikelyMailingList } from "../../../../lib/crypto/composeSecurity.js";
57
- import { SenderKeyState, clearPinnedSignerCache, getPinnedSignerFingerprints, getSignerKeyState } from "./pinnedSigners.js";
57
+ import { SenderKeyState, clearPinnedSignerCache, getPinnedSigners, getSignerKeyState } from "./pinnedSigners.js";
58
58
  import { currentVerificationSeal, getVaultGeneration, sendVerificationSeal } from "./verificationSeals.js";
59
59
  import { getMyMailboxAccess } from "../../../../lib/mail/mailboxAccessApi.js";
60
60
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
@@ -612,9 +612,9 @@ function MessageDetailContent({
612
612
  // mailbox's own signing keys when it sent the message itself. A failed lookup means no pins, which can only
613
613
  // ever make a signature "signer not verified", never verified.
614
614
  const senderAddress = message.from.address;
615
- const pinsPromise = getPinnedSignerFingerprints(message.mailboxUid, senderAddress, client).then(
616
- (fingerprints) => ({ loaded: true, fingerprints }),
617
- () => ({ loaded: false, fingerprints: [] as string[] }),
615
+ const pinsPromise = getPinnedSigners(message.mailboxUid, senderAddress, client).then(
616
+ (signers) => ({ loaded: true, ...signers }),
617
+ () => ({ loaded: false, fingerprints: [] as string[], openPgpKeys: [] as string[] }),
618
618
  );
619
619
  // Verification seals (see `verificationSeals.ts`) need unlocked keys and the vault's current master key
620
620
  // generation; without either the message is evaluated exactly as before, with no seal. The sender's key records
@@ -640,7 +640,9 @@ function MessageDetailContent({
640
640
  const ownPins = signingKeyFingerprints(ownKeys);
641
641
  const [contactPins, sealContext] = await Promise.all([pinsPromise, sealContextPromise]);
642
642
  const pins = [...new Set([...contactPins.fingerprints, ...ownPins])];
643
- const pinned = pins.length > 0 ? pins : undefined;
643
+ // An OpenPGP signature is checked against the sender's pinned OpenPGP certificates themselves (it names only a key ID).
644
+ const pinned: SignerPinsInput =
645
+ contactPins.openPgpKeys.length > 0 ? { fingerprints: pins, openPgpKeys: contactPins.openPgpKeys } : pins.length > 0 ? pins : undefined;
644
646
  // The raw content is a byte string - only ever handed to evaluateMessageSecurity(), never shown. Keys
645
647
  // are read after the pin lookup's await, so a lock that happened meanwhile is honored.
646
648
  const unlocked = getUnlockedKeys(message.mailboxUid);
@@ -677,7 +679,7 @@ function MessageDetailContent({
677
679
  const viaAlias = await messageSecurity.evaluateMessageSecurity(rawMime, unlocked, pinned, alias);
678
680
  result = { ...result, notAddressedToReader: viaAlias.notAddressedToReader };
679
681
  }
680
- const unpinned = contactPins.loaded && pins.length === 0;
682
+ const unpinned = contactPins.loaded && pins.length === 0 && contactPins.openPgpKeys.length === 0;
681
683
  const keyChanged = signerKeyChanged(result);
682
684
  const [keyState, access] = await Promise.all([
683
685
  keyChanged || (unpinned && UNVERIFIED_SIGNER_STATES.has(result.state)) ? loadKeyState() : undefined,
@@ -783,11 +785,11 @@ function MessageDetailContent({
783
785
  // Only ever invoked from the "Trust this signer" dialog, which only opens for an unverified-signer result carrying
784
786
  // `signerCertificate`. The pin goes on the contact in the mailbox the message belongs to; the evaluation is then
785
787
  // re-run against a fresh pinned-signer lookup, so the badge turns verified.
786
- async function handleTrustSigner(address: string, certificate: string) {
788
+ async function handleTrustSigner(address: string, certificate: string, type?: "openpgp") {
787
789
  setTrusting(true);
788
790
  setTrustError(null);
789
791
  try {
790
- await trustSigner(message.mailboxUid, { address, certificate }, client);
792
+ await trustSigner(message.mailboxUid, { address, certificate, ...(type ? { type } : {}) }, client);
791
793
  clearPinnedSignerCache();
792
794
  setTrustConfirmOpen(false);
793
795
  setUnlockRefresh((n) => n + 1);
@@ -1112,7 +1114,10 @@ function MessageDetailContent({
1112
1114
  ? security.signerCertificate
1113
1115
  : undefined;
1114
1116
  const keyChanged = security && signerKeyChanged(security) ? security : undefined;
1115
- const pinnedSignerKey = senderKeyState?.pinned[0];
1117
+ // An OpenPGP signer's key is pinned as the contact's OpenPGP certificate, not a signing certificate.
1118
+ const openPgpSigner = keyChanged?.signerKeyType === "openpgp";
1119
+ const pinnedSignerKey = openPgpSigner ? senderKeyState?.openPgp?.[0] : senderKeyState?.pinned[0];
1120
+ const pinnedSignerSince = openPgpSigner ? senderKeyState?.openPgpPinnedSince : senderKeyState?.pinnedSince;
1116
1121
  const recordedConflict =
1117
1122
  keyChanged && sameFingerprint(senderKeyState?.conflict?.observedKey.fingerprint, keyChanged.signerFingerprint)
1118
1123
  ? senderKeyState!.conflict
@@ -1148,7 +1153,7 @@ function MessageDetailContent({
1148
1153
  // Registered while rendering, not in an effect: the invitation lookup below must find it the moment it is enabled. Writing the same
1149
1154
  // file for the same message again is a no-op.
1150
1155
  if (sealedCalendar !== undefined) {
1151
- rememberSealedInvite(message.uid, sealedCalendar);
1156
+ rememberSealedInvite(message.uid, sealedCalendar, message.mailboxUid);
1152
1157
  }
1153
1158
  const mayHoldInvite =
1154
1159
  !message.encrypted &&
@@ -1349,6 +1354,11 @@ function MessageDetailContent({
1349
1354
  )}
1350
1355
  {security && <SecurityIndicator security={security} zeroAccess={!!message.zeroAccess} />}
1351
1356
  </div>
1357
+ {security?.signerKeyUnknown && (
1358
+ <p role="status" className="mt-2 text-sm text-text-muted">
1359
+ Signed with an OpenPGP key RapidMX doesn&rsquo;t have for this sender yet, so the signature couldn&rsquo;t be checked.
1360
+ </p>
1361
+ )}
1352
1362
  {senderNameCheck.misleading && (
1353
1363
  <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
1354
1364
  The sender&rsquo;s name &ldquo;{senderName}&rdquo; looks like an email address, but this message was
@@ -1577,9 +1587,10 @@ function MessageDetailContent({
1577
1587
  <KeyChangeReview
1578
1588
  mailboxUid={message.mailboxUid}
1579
1589
  address={senderAddress}
1580
- useType="sign"
1590
+ useType={openPgpSigner ? "encrypt" : "sign"}
1591
+ {...(openPgpSigner ? { keyType: "openpgp" as const } : {})}
1581
1592
  ownerName="the sender"
1582
- current={pinnedSignerKey && { fingerprint: pinnedSignerKey.fingerprint, since: senderKeyState.pinnedSince! }}
1593
+ current={pinnedSignerKey && { fingerprint: pinnedSignerKey.fingerprint, since: pinnedSignerSince! }}
1583
1594
  proposed={{
1584
1595
  fingerprint: keyChanged.signerFingerprint!,
1585
1596
  emails: keyChanged.signerEmails,
@@ -1879,7 +1890,7 @@ function MessageDetailContent({
1879
1890
  className="!w-auto"
1880
1891
  loading={trusting}
1881
1892
  disabled={trusting}
1882
- onClick={() => handleTrustSigner(senderAddress, trustableCertificate!)}
1893
+ onClick={() => handleTrustSigner(senderAddress, trustableCertificate!, security?.signerKeyType)}
1883
1894
  >
1884
1895
  Trust
1885
1896
  </Button>
@@ -48,7 +48,7 @@ import { notify } from "../../../notifications/store.js";
48
48
  import { isFileDrag, isInlineImage } from "./dropFiles.js";
49
49
  import { evaluateEncryptionRequirement } from "./encryptionRequirement.js";
50
50
  import { decideSend, SendBlock } from "../../../mail/outbox/sendDecision.js";
51
- import { SendRequest, sendDecisionInput, startSend } from "../../../mail/outbox/sendJob.js";
51
+ import { SendRequest, sendDecisionInput, senderCanUseOpenPgp, startSend } from "../../../mail/outbox/sendJob.js";
52
52
  import { useCompose, type ComposeSession } from "./ComposeContext.js";
53
53
  import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
54
54
  import { SHORTCUTS, ShortcutDef } from "../../../keyboard/keymap.js";
@@ -492,7 +492,14 @@ export default function ComposeWindow({
492
492
  return;
493
493
  }
494
494
  lookupFailuresRef.current.delete(key);
495
- const status = resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, recipient.address, result.lookup);
495
+ const status = resolveRecipientEncryption(
496
+ mailbox.primarySmtpAddress,
497
+ ownPrefersMutual,
498
+ encryptionPolicy,
499
+ recipient.address,
500
+ result.lookup,
501
+ senderCanUseOpenPgp(getUnlockedKeys(mailbox.uid)),
502
+ );
496
503
  setRecipientStatuses((prev) => ({ ...prev, [key]: status }));
497
504
  });
498
505
  }
@@ -5,6 +5,7 @@
5
5
  import React, { ChangeEvent, KeyboardEvent, useEffect, useLayoutEffect, useRef, useState } from "react";
6
6
  import { createPortal } from "react-dom";
7
7
  import { HiOutlineXMark } from "react-icons/hi2";
8
+ import { fetchSuggestionsWithProtectedContacts } from "./protectedContactSuggestions.js";
8
9
  import {
9
10
  fetchRecipientSuggestions,
10
11
  RECIPIENT_SUGGESTION_MIN_QUERY_LENGTH,
@@ -111,7 +112,7 @@ export default function RecipientInput({
111
112
  onBlur,
112
113
  onCommit,
113
114
  mailboxUid,
114
- fetchSuggestions = fetchRecipientSuggestions,
115
+ fetchSuggestions = fetchSuggestionsWithProtectedContacts,
115
116
  debounceMs = RECIPIENT_SUGGESTION_DEBOUNCE_MS,
116
117
  disabled,
117
118
  autoFocus,
@@ -0,0 +1,121 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Recipient suggestions that include contacts zero-access encryption protects. Their names are sealed (restapi `specs/search.md` §3 *What
7
+ * It Covers*), so the server's contact search finds them only by address; this matches their opened names on the device and puts them
8
+ * first. Names are opened only - never the rest of a contact - and kept for ten minutes, and only while the mailbox is unlocked: its lock
9
+ * (and signing out) forgets them, as does a search that finds it locked. Reading them never holds up the server's suggestions: what isn't read by the time those arrive (plus
10
+ * `PROTECTED_NAMES_WAIT_MS`) is left to the next keystroke.
11
+ */
12
+ import { type Contact, listContacts, PINNED_FINGERPRINT_PAGE_SIZE } from "../../../../../lib/contacts/contactsApi.js";
13
+ import { getUnlockedKeys, subscribeKeySession } from "../../../../../lib/crypto/keySession.js";
14
+ import {
15
+ type ContactSuggestionOptions,
16
+ fetchRecipientSuggestions,
17
+ mergeRecipientSuggestions,
18
+ RECIPIENT_SUGGESTION_DEFAULT_LIMIT,
19
+ type RecipientSuggestion,
20
+ } from "../../../../../lib/mail/directoryApi.js";
21
+ import { listFolders } from "../../../../../lib/mail/mailApi.js";
22
+ import type { ApiClient } from "../../../../../lib/util/api.js";
23
+ import { isProtectedRecord, openSealedRecords } from "../../../../../lib/zeroAccess/sealedRecords.js";
24
+ import { onSigningOut } from "./composeFlushRegistry.js";
25
+
26
+ /** How long a mailbox's protected contact names are kept: reading them is a crawl of the whole address book. */
27
+ export const PROTECTED_NAMES_TTL_MS = 10 * 60_000;
28
+
29
+ /** How long suggestions wait, once the server's have arrived, for the protected contacts still being read. */
30
+ export const PROTECTED_NAMES_WAIT_MS = 250;
31
+
32
+ /** At most this many contacts are read per contacts folder. */
33
+ const MAX_PAGES = 20;
34
+
35
+ const cache = new Map<string, { loadedAt: number; contacts: Promise<Contact[]> }>();
36
+
37
+ /** Forgets every mailbox's names (signing out, or the test seam). */
38
+ export function clearProtectedContactNames(): void {
39
+ cache.clear();
40
+ }
41
+
42
+ onSigningOut(clearProtectedContactNames);
43
+
44
+ let watchingKeys = false;
45
+
46
+ /** A mailbox's names go the moment it locks, so nothing opened outlives its key session. Subscribed with the first crawl, not on import. */
47
+ function forgetOnLock(): void {
48
+ if (watchingKeys) {
49
+ return;
50
+ }
51
+ watchingKeys = true;
52
+ subscribeKeySession(({ mailboxUid, state }) => {
53
+ if (state === "locked") {
54
+ cache.delete(mailboxUid);
55
+ }
56
+ });
57
+ }
58
+
59
+ /** `mailboxUid`'s protected contacts with their names opened. */
60
+ async function loadProtectedContacts(mailboxUid: string, client: ApiClient | undefined): Promise<Contact[]> {
61
+ const found: Contact[] = [];
62
+ // The folders side by side, each one's pages in turn.
63
+ await Promise.all(
64
+ (await listFolders(mailboxUid, client))
65
+ .filter((f) => f.type === "contacts")
66
+ .map(async (folder) => {
67
+ for (let page = 0; page < MAX_PAGES; page++) {
68
+ const batch = await listContacts(folder.uid, { limit: PINNED_FINGERPRINT_PAGE_SIZE, page }, client);
69
+ found.push(...batch.filter((contact) => isProtectedRecord(contact)));
70
+ if (batch.length < PINNED_FINGERPRINT_PAGE_SIZE) {
71
+ break;
72
+ }
73
+ }
74
+ }),
75
+ );
76
+ return openSealedRecords(found, ["displayName"]);
77
+ }
78
+
79
+ function protectedContacts(mailboxUid: string, client: ApiClient | undefined): Promise<Contact[]> {
80
+ let entry = cache.get(mailboxUid);
81
+ if (!entry || Date.now() - entry.loadedAt > PROTECTED_NAMES_TTL_MS) {
82
+ forgetOnLock();
83
+ const created = { loadedAt: Date.now(), contacts: loadProtectedContacts(mailboxUid, client) };
84
+ created.contacts.catch(() => cache.delete(mailboxUid));
85
+ cache.set(mailboxUid, created);
86
+ entry = created;
87
+ }
88
+ return entry.contacts;
89
+ }
90
+
91
+ /** Every address of the protected contacts of `mailboxUid` whose opened name contains `query` (case-insensitively). */
92
+ export async function searchProtectedContactNames(mailboxUid: string, query: string, client?: ApiClient): Promise<RecipientSuggestion[]> {
93
+ const wanted = query.trim().toLowerCase();
94
+ if (wanted.length < 2) {
95
+ return [];
96
+ }
97
+ if (!getUnlockedKeys(mailboxUid)?.zeroAccessKeys?.length) {
98
+ // Locked: nothing opened is kept, and nothing could be opened now.
99
+ cache.delete(mailboxUid);
100
+ return [];
101
+ }
102
+ return (await protectedContacts(mailboxUid, client))
103
+ .filter((contact) => contact.displayName.toLowerCase().includes(wanted))
104
+ .flatMap((contact) => contact.emails.map((email) => ({ displayName: contact.displayName, address: email.address, kind: "contact" as const })));
105
+ }
106
+
107
+ /** `fetchRecipientSuggestions()` with the composing mailbox's protected contacts matched by name on the device, first - those read by the
108
+ * time the server's suggestions are in (plus `PROTECTED_NAMES_WAIT_MS`): a first search of a large address book shows the server's alone
109
+ * rather than waiting for the crawl, which goes on for the next keystroke. A failure to read them only leaves them out. */
110
+ export async function fetchSuggestionsWithProtectedContacts(
111
+ query: string,
112
+ options: ContactSuggestionOptions = {},
113
+ client?: ApiClient,
114
+ ): Promise<RecipientSuggestion[]> {
115
+ const local: Promise<RecipientSuggestion[]> = options.mailboxUid ? searchProtectedContactNames(options.mailboxUid, query, client).catch(() => []) : Promise.resolve([]);
116
+ const server = await fetchRecipientSuggestions(query, options, client);
117
+ let timer: ReturnType<typeof setTimeout> | undefined;
118
+ const ready = await Promise.race([local, new Promise<RecipientSuggestion[]>((resolve) => (timer = setTimeout(() => resolve([]), PROTECTED_NAMES_WAIT_MS)))]);
119
+ clearTimeout(timer);
120
+ return mergeRecipientSuggestions(ready, server, options.limit ?? RECIPIENT_SUGGESTION_DEFAULT_LIMIT);
121
+ }
@@ -4,7 +4,7 @@
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import { useCallback, useEffect, useReducer } from "react";
6
6
  import { MessageInvite, getMessageInvite } from "../../../../../lib/calendar/inviteApi.js";
7
- import { clearSealedInvites, sealedInviteCalendar } from "../../../../../lib/calendar/sealedInvites.js";
7
+ import { clearSealedInvites, onSealedInvitesForgotten, sealedInviteCalendar } from "../../../../../lib/calendar/sealedInvites.js";
8
8
  import { onSigningOut } from "../compose/composeFlushRegistry.js";
9
9
  import { ApiClient } from "../../../../../lib/util/api.js";
10
10
  import { useApiClient } from "../../../../../lib/util/apiClientContext.js";
@@ -126,3 +126,13 @@ export function useMessageInvite(uid: string, enabled = true): { invite: Message
126
126
  }
127
127
 
128
128
  onSigningOut(clearSealedInvites);
129
+ onSigningOut(clearInviteCache);
130
+ // What the server read from a zero-access message's decrypted calendar file (its summary, description) goes with the file when the mailbox
131
+ // locks: mounted cards, chips and popovers draw nothing until it is read again.
132
+ onSealedInvitesForgotten((uids) => {
133
+ for (const uid of uids) {
134
+ if (entries.delete(uid)) {
135
+ notify(uid);
136
+ }
137
+ }
138
+ });
@@ -28,6 +28,7 @@ import {
28
28
  PINNED_FINGERPRINT_PAGE_SIZE,
29
29
  SignerKeyState,
30
30
  listContacts,
31
+ pinnedOpenPgpKeysFor,
31
32
  pinnedSigningFingerprintsFor,
32
33
  signerKeyStateFor,
33
34
  } from "../../../../lib/contacts/contactsApi.js";
@@ -35,6 +36,7 @@ import { keyPinnedSince } from "../contacts/contactKeys.js";
35
36
  import { listFolders } from "../../../../lib/mail/mailApi.js";
36
37
  import { subscribeKeySession } from "../../../../lib/crypto/keySession.js";
37
38
  import type { ApiClient } from "../../../../lib/util/api.js";
39
+ import type { SignerPins } from "../../../../lib/crypto/messageSecurity.js";
38
40
 
39
41
  /** How long one mailbox's loaded contact list is reused before it is fetched again. */
40
42
  export const CONTACTS_CACHE_TTL_MS = 60_000;
@@ -120,12 +122,21 @@ export async function getPinnedSignerFingerprints(mailboxUid: string, address: s
120
122
  return pinnedSigningFingerprintsFor(await cachedMailboxContacts(mailboxUid, client), address);
121
123
  }
122
124
 
125
+ /** The sender `address`'s pinned signing fingerprints and pinned OpenPGP certificates from `mailboxUid`'s contacts - what
126
+ * `evaluateMessageSecurity()` takes as `SignerPins`. Rejects if they can't be loaded. */
127
+ export async function getPinnedSigners(mailboxUid: string, address: string, client?: ApiClient): Promise<SignerPins> {
128
+ const contacts = await cachedMailboxContacts(mailboxUid, client);
129
+ return { fingerprints: pinnedSigningFingerprintsFor(contacts, address), openPgpKeys: pinnedOpenPgpKeysFor(contacts, address) };
130
+ }
131
+
123
132
  /** A sender's signing-key state (`signerKeyStateFor()`) plus what a key-changed notice needs to show and link. */
124
133
  export interface SenderKeyState extends SignerKeyState {
125
134
  /** The contact holding the first pinned signing key, else the one holding the conflict, else the first match. */
126
135
  contactUid?: string;
127
136
  /** When the first pinned signing key started being trusted - see `keyPinnedSince()`. */
128
137
  pinnedSince?: number;
138
+ /** When the first pinned OpenPGP certificate (`openPgp[0]`) started being trusted. */
139
+ openPgpPinnedSince?: number;
129
140
  }
130
141
 
131
142
  /** The sender `address`'s signing-key state from `mailboxUid`'s (cached) contacts, for a "signing key changed" comparison
@@ -142,9 +153,12 @@ export async function getSignerKeyState(mailboxUid: string, address: string, cli
142
153
  holder ??
143
154
  matching.find((candidate) => (candidate.keyConflicts ?? []).some((conflict) => conflict.useType === "sign")) ??
144
155
  matching[0];
156
+ const openPgpKey = state.openPgp?.[0];
157
+ const openPgpHolder = openPgpKey && matching.find((candidate) => (candidate.keys ?? []).includes(openPgpKey));
145
158
  return {
146
159
  ...state,
147
160
  ...(contact ? { contactUid: contact.uid } : {}),
148
161
  ...(holder ? { pinnedSince: keyPinnedSince(holder, pinned) } : {}),
162
+ ...(openPgpHolder ? { openPgpPinnedSince: keyPinnedSince(openPgpHolder, openPgpKey) } : {}),
149
163
  };
150
164
  }
@@ -39,13 +39,13 @@ export async function olderConversationMessages(message: Message, client?: ApiCl
39
39
  }
40
40
 
41
41
  /**
42
- * One earlier message of a conversation as it prints: the body the reading pane would show. An encrypted one is decrypted here when this device has
43
- * its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext. `client` is the explicit `ApiClient` every
42
+ * One earlier message of a conversation as it prints: the body the reading pane would show. An encrypted one - end to end, or sealed to its mailbox's
43
+ * zero-access key, whose subject and body the server doesn't hold readable - is decrypted here when this device has its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext. `client` is the explicit `ApiClient` every
44
44
  * request goes through when given (see `useApiClient()`); without one they are the cookie requests they always were.
45
45
  */
46
46
  export async function printableOlderMessage(message: Message, client?: ApiClient): Promise<PrintableMessage> {
47
47
  const attachments = await listAttachments(message.folderUid, message.uid, client).catch(() => []);
48
- if (message.encrypted) {
48
+ if (message.encrypted || message.zeroAccess) {
49
49
  const unlocked = getUnlockedKeys(message.mailboxUid);
50
50
  if (unlocked) {
51
51
  const { evaluateMessageSecurity } = await import("../../../../../lib/crypto/messageSecurity.js");
@@ -20,6 +20,9 @@ export interface KeyExportSettingProps {
20
20
 
21
21
  const INPUT_CLASS = "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface";
22
22
 
23
+ /** How long the file's object URL is kept, since some browsers (WebKit, older Firefox) still read it after the click returns. */
24
+ const DOWNLOAD_URL_LIFETIME_MS = 60_000;
25
+
23
26
  /** Hands `bytes` to the browser as a file download. */
24
27
  function saveFile(bytes: Uint8Array, filename: string): void {
25
28
  const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type: "application/x-pkcs12" }));
@@ -29,7 +32,7 @@ function saveFile(bytes: Uint8Array, filename: string): void {
29
32
  document.body.appendChild(link);
30
33
  link.click();
31
34
  link.remove();
32
- URL.revokeObjectURL(url);
35
+ setTimeout(() => URL.revokeObjectURL(url), DOWNLOAD_URL_LIFETIME_MS);
33
36
  }
34
37
 
35
38
  /**