@rapidmx/web-client 0.34.0 → 0.36.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 (148) hide show
  1. package/apps/escrow/matters/[uid].tsx +4 -3
  2. package/apps/shared/calendar/calendarLiveUpdates.ts +2 -5
  3. package/apps/shared/calendar/sealedEvents.ts +80 -0
  4. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +61 -3
  5. package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -1
  6. package/apps/shared/components/contacts/contactCardData.ts +8 -4
  7. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +23 -1
  8. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +91 -0
  9. package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
  10. package/apps/shared/components/mail/ConversationList.tsx +1 -1
  11. package/apps/shared/components/mail/MessageDetailPane.tsx +91 -20
  12. package/apps/shared/components/mail/compose/draftResume.ts +28 -3
  13. package/apps/shared/components/mail/compose/quotedBody.ts +31 -15
  14. package/apps/shared/components/mail/invite/inviteStore.ts +7 -1
  15. package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
  16. package/apps/shared/components/mail/reading/MessageBody.tsx +4 -1
  17. package/apps/shared/components/mail/reading/bodyContent.ts +27 -17
  18. package/apps/shared/components/mail/reading/conversationPrint.ts +7 -5
  19. package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
  20. package/apps/shared/components/settings/KeyExportSetting.tsx +170 -0
  21. package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
  22. package/apps/shared/components/settings/ZeroAccessSetting.tsx +117 -0
  23. package/apps/shared/live/useFolderLiveUpdates.ts +59 -0
  24. package/apps/shared/mail/decryptedMessages.ts +11 -3
  25. package/apps/shared/mail/outbox/sendJob.ts +2 -2
  26. package/apps/shared/mail/outbox/sendOutcomes.ts +1 -3
  27. package/apps/shared/mail/reportNotices.ts +3 -1
  28. package/apps/shared/mail/zeroAccessSource.ts +27 -0
  29. package/apps/shared/search/LocalIndexLifecycle.tsx +31 -0
  30. package/apps/shared/search/localIndexBuilder.ts +27 -1
  31. package/apps/shared/search/localIndexRpcClient.ts +8 -4
  32. package/apps/shared/search/localIndexSchema.ts +2 -2
  33. package/apps/shared/search/localIndexTransport.ts +2 -1
  34. package/apps/shared/search/localIndexWorker.ts +6 -4
  35. package/apps/shared/search/zeroAccessIndexSync.ts +487 -0
  36. package/apps/www/calendar/index.tsx +180 -58
  37. package/apps/www/contacts/index.tsx +58 -2
  38. package/apps/www/index.tsx +10 -3
  39. package/apps/www/settings/encryption/index.tsx +5 -0
  40. package/apps/www/settings/privacy/index.tsx +2 -1
  41. package/apps/www/tasks/index.tsx +42 -0
  42. package/dist/apps/escrow/matters/[uid].js +2 -2
  43. package/dist/apps/shared/calendar/calendarLiveUpdates.js +2 -5
  44. package/dist/apps/shared/calendar/sealedEvents.d.ts +13 -0
  45. package/dist/apps/shared/calendar/sealedEvents.js +54 -0
  46. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +18 -2
  47. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +3 -1
  48. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  49. package/dist/apps/shared/components/contacts/contactCardData.d.ts +2 -2
  50. package/dist/apps/shared/components/contacts/contactCardData.js +8 -4
  51. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +21 -2
  52. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +15 -0
  53. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +72 -0
  54. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
  55. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
  56. package/dist/apps/shared/components/mail/ConversationList.js +1 -1
  57. package/dist/apps/shared/components/mail/MessageDetailPane.js +73 -17
  58. package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
  59. package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
  60. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +4 -3
  61. package/dist/apps/shared/components/mail/compose/quotedBody.js +28 -15
  62. package/dist/apps/shared/components/mail/invite/inviteStore.js +6 -1
  63. package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
  64. package/dist/apps/shared/components/mail/reading/MessageBody.js +4 -1
  65. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +4 -2
  66. package/dist/apps/shared/components/mail/reading/bodyContent.js +27 -16
  67. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +3 -2
  68. package/dist/apps/shared/components/mail/reading/conversationPrint.js +7 -5
  69. package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
  70. package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
  71. package/dist/apps/shared/components/settings/KeyExportSetting.js +88 -0
  72. package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
  73. package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
  74. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +14 -0
  75. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +71 -0
  76. package/dist/apps/shared/live/useFolderLiveUpdates.d.ts +19 -0
  77. package/dist/apps/shared/live/useFolderLiveUpdates.js +41 -0
  78. package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
  79. package/dist/apps/shared/mail/decryptedMessages.js +11 -3
  80. package/dist/apps/shared/mail/outbox/sendJob.js +2 -2
  81. package/dist/apps/shared/mail/outbox/sendOutcomes.js +1 -3
  82. package/dist/apps/shared/mail/reportNotices.js +3 -1
  83. package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
  84. package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
  85. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
  86. package/dist/apps/shared/search/LocalIndexLifecycle.js +28 -0
  87. package/dist/apps/shared/search/localIndexBuilder.d.ts +5 -0
  88. package/dist/apps/shared/search/localIndexBuilder.js +24 -2
  89. package/dist/apps/shared/search/localIndexRpcClient.d.ts +1 -1
  90. package/dist/apps/shared/search/localIndexRpcClient.js +6 -4
  91. package/dist/apps/shared/search/localIndexSchema.d.ts +1 -1
  92. package/dist/apps/shared/search/localIndexSchema.js +2 -2
  93. package/dist/apps/shared/search/localIndexTransport.d.ts +2 -1
  94. package/dist/apps/shared/search/localIndexWorker.d.ts +2 -0
  95. package/dist/apps/shared/search/localIndexWorker.js +4 -4
  96. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +71 -0
  97. package/dist/apps/shared/search/zeroAccessIndexSync.js +382 -0
  98. package/dist/apps/www/calendar/index.js +48 -22
  99. package/dist/apps/www/contacts/index.js +49 -3
  100. package/dist/apps/www/index.js +10 -3
  101. package/dist/apps/www/settings/encryption/index.js +3 -1
  102. package/dist/apps/www/settings/privacy/index.js +1 -1
  103. package/dist/apps/www/tasks/index.js +29 -1
  104. package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
  105. package/dist/lib/calendar/calendarApi.d.ts +6 -0
  106. package/dist/lib/calendar/inviteApi.js +27 -5
  107. package/dist/lib/calendar/sealedInvites.d.ts +11 -0
  108. package/dist/lib/calendar/sealedInvites.js +28 -0
  109. package/dist/lib/crypto/keyExport.d.ts +54 -0
  110. package/dist/lib/crypto/keyExport.js +161 -0
  111. package/dist/lib/crypto/keySession.d.ts +45 -1
  112. package/dist/lib/crypto/keySession.js +93 -2
  113. package/dist/lib/crypto/keyvaultApi.d.ts +88 -3
  114. package/dist/lib/crypto/keyvaultApi.js +32 -0
  115. package/dist/lib/crypto/messageSecurity.d.ts +30 -1
  116. package/dist/lib/crypto/messageSecurity.js +80 -4
  117. package/dist/lib/crypto/mime.d.ts +5 -0
  118. package/dist/lib/crypto/mime.js +25 -0
  119. package/dist/lib/crypto/sharedMailboxKeys.d.ts +50 -0
  120. package/dist/lib/crypto/sharedMailboxKeys.js +264 -0
  121. package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
  122. package/dist/lib/crypto/zeroAccessKey.js +66 -0
  123. package/dist/lib/mail/conversationsApi.d.ts +6 -0
  124. package/dist/lib/mail/mailApi.d.ts +30 -2
  125. package/dist/lib/mail/mailApi.js +5 -1
  126. package/dist/lib/mail/pushClient.d.ts +10 -3
  127. package/dist/lib/mail/pushClient.js +18 -4
  128. package/dist/lib/search/zeroAccessIndexApi.d.ts +81 -0
  129. package/dist/lib/search/zeroAccessIndexApi.js +35 -0
  130. package/dist/lib/tasks/tasksApi.d.ts +1 -0
  131. package/dist/lib/tasks/tasksApi.js +3 -0
  132. package/lib/admin/escrowAccessRequestsApi.ts +6 -0
  133. package/lib/calendar/calendarApi.ts +3 -0
  134. package/lib/calendar/inviteApi.ts +28 -5
  135. package/lib/calendar/sealedInvites.ts +33 -0
  136. package/lib/crypto/keyExport.ts +229 -0
  137. package/lib/crypto/keySession.ts +117 -3
  138. package/lib/crypto/keyvaultApi.ts +125 -3
  139. package/lib/crypto/messageSecurity.ts +110 -3
  140. package/lib/crypto/mime.ts +26 -0
  141. package/lib/crypto/sharedMailboxKeys.ts +324 -0
  142. package/lib/crypto/zeroAccessKey.ts +97 -0
  143. package/lib/mail/conversationsApi.ts +3 -0
  144. package/lib/mail/mailApi.ts +33 -3
  145. package/lib/mail/pushClient.ts +18 -4
  146. package/lib/search/zeroAccessIndexApi.ts +119 -0
  147. package/lib/tasks/tasksApi.ts +4 -0
  148. package/package.json +3 -3
@@ -34,6 +34,7 @@
34
34
  * accepted or automatic rotation still verifies.
35
35
  */
36
36
  import { MimeAttachment, MimeHeaderField } from "./mime.js";
37
+ import { type DecryptionKey } from "./smime.js";
37
38
  import { ProtectedHeaders } from "./smimeMessage.js";
38
39
  import { type PublicKey } from "./keyvaultApi.js";
39
40
  import { type SealKeyMaterial, type SealedVerificationState } from "./verificationSeal.js";
@@ -49,7 +50,7 @@ export { signingKeyFingerprints } from "./keyvaultApi.js";
49
50
  * `sealedState`, `signerFingerprint`, the recovered content, `liveState`/`liveSignatureFailureReason`, and
50
51
  * `laterCompromised` when the signer key has since been revoked as compromised. A UI should show it as "verified when
51
52
  * first opened" - distinct from the live verified states, and with a warning when `laterCompromised` is set. */
52
- export type MessageSecurityState = "encrypted" | "signed_verified" | "encrypted_verified" | "signed_unverified_signer" | "encrypted_unverified_signer" | "signature_failed" | "verified_at_first_open" | "unprotected";
53
+ export type MessageSecurityState = "encrypted" | "signed_verified" | "encrypted_verified" | "signed_unverified_signer" | "encrypted_unverified_signer" | "signature_failed" | "verified_at_first_open" | "protected_at_rest" | "unprotected";
53
54
  /** Why a message is `"signature_failed"`. `invalid_signature`: the CMS signature itself is malformed or
54
55
  * doesn't verify over the content (or a `multipart/signed` body doesn't hold exactly two parts).
55
56
  * `signer_key_changed`: pinned fingerprints were supplied and the signer certificate matches none of them, but it
@@ -92,6 +93,13 @@ export interface MessageSecurityResult {
92
93
  * since-rotated key, non-AEAD content encryption, or corrupt/foreign data) - the caller should keep
93
94
  * showing whatever `GET /:id/content` already produced alongside this explanation. */
94
95
  decryptError?: string;
96
+ /** Set when the stored message was sealed at rest by the server (restapi `specs/search.md` §3) and opened here with the mailbox's
97
+ * zero-access key. `state` then describes the original message inside: `protected_at_rest` when it arrived unprotected, or its own
98
+ * signed/encrypted state otherwise. */
99
+ zeroAccess?: boolean;
100
+ /** With `zeroAccess`: the original's calendar file (`extractCalendarText()`), which the server can't read from the sealed copy - the
101
+ * client sends it with the invitation endpoints. Absent when the message carries none. */
102
+ calendar?: string;
95
103
  /** See `ParsedEncryptedMessage.headerTamperDetected`'s own doc comment - a deliberately separate,
96
104
  * orthogonal signal from `state`, since RFC 9788's `HP-Outer` is written on every encrypted message
97
105
  * regardless of whether it's also signed. `undefined` for anything that isn't an encrypted message
@@ -200,7 +208,28 @@ export interface MessageSecurityKeys {
200
208
  privateKey: CryptoKey;
201
209
  }[];
202
210
  signingFingerprint?: string;
211
+ /** The mailbox's zero-access keys (`keySession.ts`'s `UnlockedKeys.zeroAccessKeys`) - what opens a message the server sealed at
212
+ * rest on arrival. */
213
+ zeroAccessKeys?: {
214
+ certDer: Uint8Array;
215
+ privateKey: CryptoKey;
216
+ }[];
203
217
  }
218
+ /**
219
+ * The subject and preview of a zero-access message (`Message.zeroAccess.sealedSummary`, base64 CMS of JSON `{ subject, preview }`),
220
+ * decrypted with the mailbox's zero-access keys - a few hundred bytes, so a message list shows real subjects without downloading
221
+ * each message. `undefined` when no key opens it or it isn't a summary.
222
+ */
223
+ export declare function openZeroAccessSummary(sealedSummary: string, keys: DecryptionKey[] | undefined): Promise<{
224
+ subject: string;
225
+ preview: string;
226
+ } | undefined>;
227
+ /**
228
+ * The original MIME (as a binary string) of a stored message the server sealed at rest, opened with `unlocked.zeroAccessKeys` -
229
+ * `undefined` when it isn't a sealed message or no key opens it. For what needs the original itself, e.g. reporting it to the spam
230
+ * filter (`reportMessage()`'s `rawMessage`).
231
+ */
232
+ export declare function openZeroAccessMessage(rawMime: string, unlocked: MessageSecurityKeys | undefined): Promise<string | undefined>;
204
233
  /** Options for `evaluateMessageSecurityWithSeal()`. */
205
234
  export interface VerificationSealOptions {
206
235
  mailboxUid: string;
@@ -37,9 +37,9 @@
37
37
  * `untrusted_signer`. Previously pinned keys count as pins (see `signingKeyFingerprints()`), so mail signed before an
38
38
  * accepted or automatic rotation still verifies.
39
39
  */
40
- import { decodeHeaderText, extractAddresses, parseMimeEntity, parseParameterizedHeader, plainTextToHtml } from "./mime.js";
41
- import { toBase64 } from "./encoding.js";
42
- import { computeCertFingerprint, extractCertificateEmails } from "./smime.js";
40
+ import { bytesToBinaryString, decodeBase64Text, decodeHeaderText, extractAddresses, extractAttachments, extractCalendarText, extractDisplayBody, parseMimeEntity, parseParameterizedHeader, plainTextToHtml, } from "./mime.js";
41
+ import { fromBase64, toBase64 } from "./encoding.js";
42
+ import { computeCertFingerprint, decryptEnvelopedDataWithKeys, extractCertificateEmails } from "./smime.js";
43
43
  import { parseEncryptedMessageWithKeys, parseSignedOnlyMessage } from "./smimeMessage.js";
44
44
  import { isTrustedForVerification } from "./keyvaultApi.js";
45
45
  import { KeysLockedError } from "./masterKey.js";
@@ -174,7 +174,55 @@ function checkIdentityAndHeaders(input, certDer) {
174
174
  export async function evaluateMessageSecurity(rawMime, unlocked, pinnedSignerFingerprints, readerAddress) {
175
175
  return (await evaluateLive(rawMime, unlocked, pinnedSignerFingerprints, readerAddress)).result;
176
176
  }
177
- async function evaluateLive(rawMime, unlocked, pinnedSignerFingerprints, readerAddress) {
177
+ /**
178
+ * The subject and preview of a zero-access message (`Message.zeroAccess.sealedSummary`, base64 CMS of JSON `{ subject, preview }`),
179
+ * decrypted with the mailbox's zero-access keys - a few hundred bytes, so a message list shows real subjects without downloading
180
+ * each message. `undefined` when no key opens it or it isn't a summary.
181
+ */
182
+ export async function openZeroAccessSummary(sealedSummary, keys) {
183
+ if (!keys?.length) {
184
+ return undefined;
185
+ }
186
+ try {
187
+ const json = JSON.parse(new TextDecoder().decode(await decryptEnvelopedDataWithKeys(fromBase64(sealedSummary), keys)));
188
+ const { subject, preview } = (json ?? {});
189
+ return typeof subject === "string" && typeof preview === "string" ? { subject, preview } : undefined;
190
+ }
191
+ catch {
192
+ return undefined;
193
+ }
194
+ }
195
+ /**
196
+ * The original MIME (as a binary string) of a stored message the server sealed at rest, opened with `unlocked.zeroAccessKeys` -
197
+ * `undefined` when it isn't a sealed message or no key opens it. For what needs the original itself, e.g. reporting it to the spam
198
+ * filter (`reportMessage()`'s `rawMessage`).
199
+ */
200
+ export async function openZeroAccessMessage(rawMime, unlocked) {
201
+ const { headers, body } = parseMimeEntity(rawMime);
202
+ const rawContentType = headers["content-type"];
203
+ if (!unlocked?.zeroAccessKeys?.length || !rawContentType || !isEncryptedContentType(parseParameterizedHeader(rawContentType))) {
204
+ return undefined;
205
+ }
206
+ return openZeroAccess(body, unlocked.zeroAccessKeys);
207
+ }
208
+ /**
209
+ * The original MIME of a message the server sealed at rest (restapi `specs/search.md` §3) - the stored S/MIME `enveloped-data`
210
+ * message's content, decrypted with one of `keys` - or `undefined` when none of them opens it. Only the server ever encrypts to a
211
+ * zero-access key (it is never published), so a message that opens with one was sealed at rest, never sent end-to-end.
212
+ */
213
+ async function openZeroAccess(body, keys) {
214
+ const der = decodeBase64Text(body);
215
+ if (!der) {
216
+ return undefined;
217
+ }
218
+ try {
219
+ return bytesToBinaryString(await decryptEnvelopedDataWithKeys(der, keys));
220
+ }
221
+ catch {
222
+ return undefined;
223
+ }
224
+ }
225
+ async function evaluateLive(rawMime, unlocked, pinnedSignerFingerprints, readerAddress, sealedAtRest = false) {
178
226
  const { headers, body, fields } = parseMimeEntity(rawMime);
179
227
  const rawContentType = headers["content-type"];
180
228
  if (!rawContentType) {
@@ -248,6 +296,34 @@ async function evaluateLive(rawMime, unlocked, pinnedSignerFingerprints, readerA
248
296
  kind: "signed",
249
297
  };
250
298
  }
299
+ // A message the server sealed at rest: evaluate the original it holds - which may itself be signed or end-to-end encrypted -
300
+ // and report one that was unprotected when it arrived as `protected_at_rest`, with its content.
301
+ if (isEncryptedContentType(contentType) && !sealedAtRest && unlocked?.zeroAccessKeys?.length) {
302
+ const original = await openZeroAccess(body, unlocked.zeroAccessKeys);
303
+ if (original !== undefined) {
304
+ const inner = await evaluateLive(original, unlocked, pinnedSignerFingerprints, readerAddress, true);
305
+ const entity = parseMimeEntity(original);
306
+ const subject = entity.headers["subject"];
307
+ const calendarText = extractCalendarText(entity);
308
+ const calendar = calendarText !== undefined ? { calendar: calendarText } : {};
309
+ if (inner.result.state !== "unprotected") {
310
+ // The stored message's own Subject is the placeholder: the original's stands in when the inner result has none.
311
+ const fallbackSubject = inner.result.subject === undefined && subject !== undefined ? { subject: decodeHeaderText(subject) } : {};
312
+ return { ...inner, result: { ...inner.result, ...fallbackSubject, ...calendar, zeroAccess: true } };
313
+ }
314
+ return {
315
+ result: {
316
+ state: "protected_at_rest",
317
+ zeroAccess: true,
318
+ ...calendar,
319
+ ...renderDisplayBody(extractDisplayBody(entity)),
320
+ ...(subject !== undefined ? { subject: decodeHeaderText(subject) } : {}),
321
+ attachments: extractAttachments(entity),
322
+ },
323
+ kind: "other",
324
+ };
325
+ }
326
+ }
251
327
  if (isEncryptedContentType(contentType)) {
252
328
  // The active key first, then the retained older ones (see `keySession.ts`'s `retainedEncryptionKeys`), so mail
253
329
  // encrypted to a key since replaced still opens. Only ever used to decrypt here - never to encrypt or sign.
@@ -135,6 +135,11 @@ export interface MimeAttachment {
135
135
  * reported as one attachment, never expanded.
136
136
  */
137
137
  export declare function extractAttachments(entity: MimeEntity, depth?: number): MimeAttachment[];
138
+ /**
139
+ * The text of the first calendar file in `entity` (descending into multipart children, bounded depth): a `text/calendar` or
140
+ * `application/ics` leaf, whether a body alternative (as Outlook sends an invitation) or an attached `.ics`. `undefined` when there is none.
141
+ */
142
+ export declare function extractCalendarText(entity: MimeEntity, depth?: number): string | undefined;
138
143
  /** HTML-escapes plain text and wraps it in a whitespace-preserving `<pre>`, so a `text/plain` body can be
139
144
  * rendered through the same HTML path without ever being interpreted as markup. */
140
145
  export declare function plainTextToHtml(text: string): string;
@@ -478,6 +478,31 @@ export function extractAttachments(entity, depth = 0) {
478
478
  },
479
479
  ];
480
480
  }
481
+ /**
482
+ * The text of the first calendar file in `entity` (descending into multipart children, bounded depth): a `text/calendar` or
483
+ * `application/ics` leaf, whether a body alternative (as Outlook sends an invitation) or an attached `.ics`. `undefined` when there is none.
484
+ */
485
+ export function extractCalendarText(entity, depth = 0) {
486
+ const contentType = parseParameterizedHeader(entity.headers["content-type"] ?? "text/plain");
487
+ if (contentType.value.startsWith("multipart/")) {
488
+ const boundary = contentType.params["boundary"];
489
+ if (!boundary || depth >= MAX_MULTIPART_DEPTH) {
490
+ return undefined;
491
+ }
492
+ for (const part of splitMultipart(entity.body, boundary)) {
493
+ const found = extractCalendarText(parseMimeEntity(part), depth + 1);
494
+ if (found !== undefined) {
495
+ return found;
496
+ }
497
+ }
498
+ return undefined;
499
+ }
500
+ if (contentType.value !== "text/calendar" && contentType.value !== "application/ics") {
501
+ return undefined;
502
+ }
503
+ const bytes = decodeBodyBytes(entity);
504
+ return bytes ? new TextDecoder().decode(bytes) : undefined;
505
+ }
481
506
  /** HTML-escapes plain text and wraps it in a whitespace-preserving `<pre>`, so a `text/plain` body can be
482
507
  * rendered through the same HTML path without ever being interpreted as markup. */
483
508
  export function plainTextToHtml(text) {
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Shared mailboxes' zero-access keys (restapi `specs/search.md` §3 *Shared Mailboxes*). A mailbox someone else owns, or that has no owner,
3
+ * has no master key its members know: its zero-access private key is instead wrapped to each member's own zero-access certificate
4
+ * (member key wraps), and a member opens it with the zero-access key of a mailbox they own - so a member unlocks it without a password,
5
+ * once their own mailbox is unlocked. Members get the ability to read, never the master key, signing key or encryption keys.
6
+ *
7
+ * `unlockSharedMailbox()` opens a shared mailbox for its member. `maintainSharedMailbox()` is what the client of the owner, or of a member
8
+ * with full access, does in the background: provisions a key for a mailbox without an owner, wraps the keys for members still waiting,
9
+ * and - once someone who held the key has left - rotates it, so new mail is encrypted to a key they never had. A mailbox without an owner
10
+ * has no master key for escrow to open, so when it is assigned to an escrow scope each of its keys is also wrapped to the scope's
11
+ * certificate: with a new key in the same write, and for keys the scope is still missing (`escrowPending`) in the background.
12
+ */
13
+ import { type ApiClient } from "../util/api.js";
14
+ /** What `unlockSharedMailbox()` found. */
15
+ export type SharedUnlockOutcome =
16
+ /** Its keys are open in this session. */
17
+ "unlocked"
18
+ /** The caller is a member but holds no wrap yet: another member's client shares the key with them shortly. */
19
+ | "pending"
20
+ /** The caller is a member, but none of their own mailboxes is unlocked to open their wraps with. */
21
+ | "own_keys_locked"
22
+ /** Not a shared mailbox of the caller's (they own it, or have no member access): its own unlock applies. */
23
+ | "not_shared";
24
+ /**
25
+ * Opens shared mailbox `mailboxUid` for its member, with no password: their member wraps, opened with the zero-access keys of a mailbox they
26
+ * own and have unlocked, become this session's keys for it (`installSharedSession()`). See `SharedUnlockOutcome` for when it can't.
27
+ */
28
+ export declare function unlockSharedMailbox(mailboxUid: string, client?: ApiClient): Promise<SharedUnlockOutcome>;
29
+ /** What `maintainSharedMailbox()` did. */
30
+ export interface SharedMaintenance {
31
+ provisioned: boolean;
32
+ rotated: boolean;
33
+ /** Wraps added for waiting members. */
34
+ wrapped: number;
35
+ /** Departed users whose wraps were removed. */
36
+ removed: number;
37
+ /** Keys wrapped for the mailbox's escrow scope. */
38
+ escrowed: number;
39
+ }
40
+ /**
41
+ * The background care of a shared mailbox, run by the client of its owner or of a member with full access (`status.manager`); nothing for
42
+ * anyone else, or while the keys it needs are locked:
43
+ *
44
+ * A mailbox without an owner and without a key gets one (unless the administrator disallows zero-access), wrapped for every member who has a
45
+ * certificate. Once a user who held the active key has left (`rotationPending`), a new key replaces it - enrolled by the owner with their
46
+ * master key (`provisionZeroAccessKey()`), or for a mailbox without an owner with the members' wraps - and then the departed users' wraps
47
+ * are removed. Members still waiting (`pending`) get wraps of every key this client can open, and the escrow scope of a mailbox without an
48
+ * owner gets copies of the keys it is missing (`escrowPending`).
49
+ */
50
+ export declare function maintainSharedMailbox(mailboxUid: string, mailboxAddress: string, client?: ApiClient): Promise<SharedMaintenance>;
@@ -0,0 +1,264 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Shared mailboxes' zero-access keys (restapi `specs/search.md` §3 *Shared Mailboxes*). A mailbox someone else owns, or that has no owner,
7
+ * has no master key its members know: its zero-access private key is instead wrapped to each member's own zero-access certificate
8
+ * (member key wraps), and a member opens it with the zero-access key of a mailbox they own - so a member unlocks it without a password,
9
+ * once their own mailbox is unlocked. Members get the ability to read, never the master key, signing key or encryption keys.
10
+ *
11
+ * `unlockSharedMailbox()` opens a shared mailbox for its member. `maintainSharedMailbox()` is what the client of the owner, or of a member
12
+ * with full access, does in the background: provisions a key for a mailbox without an owner, wraps the keys for members still waiting,
13
+ * and - once someone who held the key has left - rotates it, so new mail is encrypted to a key they never had. A mailbox without an owner
14
+ * has no master key for escrow to open, so when it is assigned to an escrow scope each of its keys is also wrapped to the scope's
15
+ * certificate: with a new key in the same write, and for keys the scope is still missing (`escrowPending`) in the background.
16
+ */
17
+ import { ApiRequestError } from "../util/api.js";
18
+ import { fromBase64, toBase64 } from "./encoding.js";
19
+ import { enrollSharedZeroAccessKey, getEncryptionPolicy, getEscrowInfo, getKeyVault, getMyMemberKeyWraps, getSharedKeyStatus, putMemberKeyWraps, putZeroAccessEscrowWraps, removeMemberKeyWraps, } from "./keyvaultApi.js";
20
+ import { exportPrivateKeyPkcs8, generateKeyPairWithCsr, importPrivateKeyPkcs8 } from "./keys.js";
21
+ import { getUnlockedKeys, installSharedSession, personalSessions, sharedUnlockGeneration, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE, } from "./keySession.js";
22
+ import { buildAad, openWithKey } from "./masterKey.js";
23
+ import { decryptEnvelopedDataWithKeys, encryptForRecipients } from "./smime.js";
24
+ import { provisionZeroAccessKey } from "./zeroAccessKey.js";
25
+ /** Domain-separation prefix of the secret a shared session's `masterKey` stands for. */
26
+ const SESSION_SECRET_LABEL = new TextEncoder().encode("rapidmx-shared-mailbox-session-v1:");
27
+ async function sessionSecret(pkcs8) {
28
+ const input = new Uint8Array(SESSION_SECRET_LABEL.length + pkcs8.length);
29
+ input.set(SESSION_SECRET_LABEL);
30
+ input.set(pkcs8, SESSION_SECRET_LABEL.length);
31
+ try {
32
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", input));
33
+ }
34
+ finally {
35
+ input.fill(0);
36
+ }
37
+ }
38
+ /** The zero-access keys of the mailboxes this user has unlocked themselves - what their member wraps are addressed to. */
39
+ function ownZeroAccessKeys() {
40
+ return personalSessions().flatMap(({ mailboxUid, unlocked }) => (unlocked.zeroAccessKeys ?? []).map((key) => ({ ...key, mailboxUid })));
41
+ }
42
+ /** Opens `wrap` with the user's own keys: the wrapped PKCS#8 (the caller zeroes it) and the own mailbox whose key opened it, or `undefined`
43
+ * when no own key opens it. */
44
+ async function openWrap(wrap, own) {
45
+ const recipient = own.find((key) => key.fingerprint === wrap.recipientFingerprint);
46
+ if (!recipient) {
47
+ return undefined;
48
+ }
49
+ try {
50
+ return { pkcs8: await decryptEnvelopedDataWithKeys(fromBase64(wrap.cms), [recipient]), openedWith: recipient.mailboxUid };
51
+ }
52
+ catch {
53
+ return undefined;
54
+ }
55
+ }
56
+ /** The active key first, then the others newest first - the order `UnlockedKeys.zeroAccessKeys` keeps. */
57
+ function keyOrder(status) {
58
+ const rank = new Map([...status.zeroAccessKeys]
59
+ .sort((a, b) => Number(a.fingerprint !== status.activeKeyFingerprint) - Number(b.fingerprint !== status.activeKeyFingerprint) || b.notBefore - a.notBefore)
60
+ .map((key, index) => [key.fingerprint, index]));
61
+ return (fingerprint) => rank.get(fingerprint) ?? Number.MAX_SAFE_INTEGER;
62
+ }
63
+ /**
64
+ * Opens shared mailbox `mailboxUid` for its member, with no password: their member wraps, opened with the zero-access keys of a mailbox they
65
+ * own and have unlocked, become this session's keys for it (`installSharedSession()`). See `SharedUnlockOutcome` for when it can't.
66
+ */
67
+ export async function unlockSharedMailbox(mailboxUid, client) {
68
+ if (getUnlockedKeys(mailboxUid)) {
69
+ return "unlocked";
70
+ }
71
+ const generation = sharedUnlockGeneration(mailboxUid);
72
+ let status;
73
+ try {
74
+ status = await getSharedKeyStatus(mailboxUid, client);
75
+ }
76
+ catch (err) {
77
+ if (err instanceof ApiRequestError && (err.status === 403 || err.status === 404)) {
78
+ return "not_shared";
79
+ }
80
+ throw err;
81
+ }
82
+ if (status.role !== "member") {
83
+ return "not_shared";
84
+ }
85
+ const wraps = await getMyMemberKeyWraps(mailboxUid, client);
86
+ if (wraps.length === 0) {
87
+ return "pending";
88
+ }
89
+ const own = ownZeroAccessKeys();
90
+ const rank = keyOrder(status);
91
+ const opened = [];
92
+ const openedWith = new Set();
93
+ let secret;
94
+ for (const wrap of [...wraps].sort((a, b) => rank(a.zeroAccessKeyFingerprint) - rank(b.zeroAccessKeyFingerprint))) {
95
+ const certificate = status.zeroAccessKeys.find((key) => key.fingerprint === wrap.zeroAccessKeyFingerprint);
96
+ const unwrapped = certificate ? await openWrap(wrap, own) : undefined;
97
+ if (!certificate || !unwrapped) {
98
+ continue;
99
+ }
100
+ const pkcs8 = unwrapped.pkcs8;
101
+ try {
102
+ secret ?? (secret = await sessionSecret(pkcs8));
103
+ const privateKey = await importPrivateKeyPkcs8(pkcs8, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"]);
104
+ opened.push({ fingerprint: wrap.zeroAccessKeyFingerprint, certDer: fromBase64(certificate.publicKey), privateKey });
105
+ openedWith.add(unwrapped.openedWith);
106
+ }
107
+ catch {
108
+ // A wrap that doesn't hold a usable key is skipped; the others still open the mailbox.
109
+ }
110
+ finally {
111
+ pkcs8.fill(0);
112
+ }
113
+ }
114
+ if (!secret || opened.length === 0) {
115
+ return "own_keys_locked";
116
+ }
117
+ const unlocked = { masterKey: secret, zeroAccessKeys: opened, openedWith: [...openedWith] };
118
+ installSharedSession(mailboxUid, unlocked, generation);
119
+ return "unlocked";
120
+ }
121
+ /** The PKCS#8 of every zero-access key of `mailboxUid` the caller can open, by fingerprint - from the vault with the owner's unlocked master
122
+ * key, or from the caller's own member wraps. The caller zeroes them. */
123
+ async function openPrivateKeys(mailboxUid, status, ownerUnlocked, client) {
124
+ const keys = new Map();
125
+ if (ownerUnlocked) {
126
+ const vault = await getKeyVault(mailboxUid, client);
127
+ const aad = buildAad(mailboxUid, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE);
128
+ for (const key of status.zeroAccessKeys) {
129
+ const wrapped = vault.wrappedKeys.find((w) => w.fingerprint === key.fingerprint && w.useType === "zero-access");
130
+ if (wrapped) {
131
+ try {
132
+ keys.set(key.fingerprint, await openWithKey(ownerUnlocked.masterKey, wrapped, aad));
133
+ }
134
+ catch {
135
+ // Sealed under a master key since replaced: not this device's to share.
136
+ }
137
+ }
138
+ }
139
+ return keys;
140
+ }
141
+ const own = ownZeroAccessKeys();
142
+ for (const wrap of await getMyMemberKeyWraps(mailboxUid, client)) {
143
+ const unwrapped = await openWrap(wrap, own);
144
+ if (unwrapped) {
145
+ keys.set(wrap.zeroAccessKeyFingerprint, unwrapped.pkcs8);
146
+ }
147
+ }
148
+ return keys;
149
+ }
150
+ async function wrapFor(member, pkcs8) {
151
+ return {
152
+ memberUserUid: member.userUid,
153
+ recipientFingerprint: member.certificate.fingerprint,
154
+ cms: toBase64(await encryptForRecipients(pkcs8, [fromBase64(member.certificate.publicKey)])),
155
+ };
156
+ }
157
+ /** The members with a certificate of their own - the ones a key can be wrapped for. */
158
+ function wrappable(status) {
159
+ return status.members.filter((member) => !!member.certificate);
160
+ }
161
+ /** `pkcs8` wrapped to the certificate of the escrow scope `escrow` describes. */
162
+ async function escrowWrapFor(escrow, pkcs8) {
163
+ return { escrowScopeId: escrow.escrowScopeId, cms: toBase64(await encryptForRecipients(pkcs8, [fromBase64(escrow.publicKey.publicKey)])) };
164
+ }
165
+ /** A new keypair for a mailbox without an owner, enrolled together with a wrap of it for every member who can receive one, and for its
166
+ * escrow scope when it has one. */
167
+ async function enrollNewSharedKey(mailboxUid, mailboxAddress, status, client) {
168
+ const escrow = status.escrowScopeId ? await getEscrowInfo(mailboxUid, client) : undefined;
169
+ const { keyPair, csrPem } = await generateKeyPairWithCsr(mailboxAddress, "encrypt");
170
+ const pkcs8 = await exportPrivateKeyPkcs8(keyPair.privateKey);
171
+ try {
172
+ const wraps = await Promise.all(wrappable(status).map((member) => wrapFor(member, pkcs8)));
173
+ const escrowWrap = escrow ? await escrowWrapFor(escrow, pkcs8) : undefined;
174
+ return await enrollSharedZeroAccessKey(mailboxUid, { csr: csrPem, wraps, ...(escrowWrap ? { escrowWrap } : {}) }, client);
175
+ }
176
+ finally {
177
+ pkcs8.fill(0);
178
+ }
179
+ }
180
+ /**
181
+ * The background care of a shared mailbox, run by the client of its owner or of a member with full access (`status.manager`); nothing for
182
+ * anyone else, or while the keys it needs are locked:
183
+ *
184
+ * A mailbox without an owner and without a key gets one (unless the administrator disallows zero-access), wrapped for every member who has a
185
+ * certificate. Once a user who held the active key has left (`rotationPending`), a new key replaces it - enrolled by the owner with their
186
+ * master key (`provisionZeroAccessKey()`), or for a mailbox without an owner with the members' wraps - and then the departed users' wraps
187
+ * are removed. Members still waiting (`pending`) get wraps of every key this client can open, and the escrow scope of a mailbox without an
188
+ * owner gets copies of the keys it is missing (`escrowPending`).
189
+ */
190
+ export async function maintainSharedMailbox(mailboxUid, mailboxAddress, client) {
191
+ const result = { provisioned: false, rotated: false, wrapped: 0, removed: 0, escrowed: 0 };
192
+ let status = await getSharedKeyStatus(mailboxUid, client);
193
+ if (!status.manager || (status.members.length === 0 && status.departed.length === 0)) {
194
+ return result;
195
+ }
196
+ const ownerUnlocked = status.role === "owner" ? getUnlockedKeys(mailboxUid) : undefined;
197
+ if (status.role === "owner" && (!ownerUnlocked || ownerUnlocked.shared)) {
198
+ return result;
199
+ }
200
+ if (status.ownerless && !status.activeKeyFingerprint) {
201
+ const policy = await getEncryptionPolicy(client).catch(() => undefined);
202
+ if (policy?.allowZeroAccess === false || ownZeroAccessKeys().length === 0) {
203
+ return result;
204
+ }
205
+ status = await enrollNewSharedKey(mailboxUid, mailboxAddress, status, client);
206
+ result.provisioned = true;
207
+ }
208
+ else if (status.rotationPending) {
209
+ if (status.ownerless) {
210
+ status = await enrollNewSharedKey(mailboxUid, mailboxAddress, status, client);
211
+ }
212
+ else {
213
+ const vault = await getKeyVault(mailboxUid, client);
214
+ await provisionZeroAccessKey(mailboxUid, mailboxAddress, ownerUnlocked.masterKey, vault.masterKeyGeneration, client);
215
+ status = await getSharedKeyStatus(mailboxUid, client);
216
+ }
217
+ result.rotated = true;
218
+ }
219
+ // The departed hold no wrap of the active key any more (or never did): their old wraps go.
220
+ if (!status.rotationPending) {
221
+ for (const departed of status.departed) {
222
+ status = await removeMemberKeyWraps(mailboxUid, departed, client);
223
+ result.removed++;
224
+ }
225
+ }
226
+ const missing = wrappable(status).filter((member) => status.zeroAccessKeys.some((key) => !member.wrapped.includes(key.fingerprint)));
227
+ const escrowPending = status.ownerless && status.escrowScopeId ? (status.escrowPending ?? []) : [];
228
+ if (missing.length > 0 || escrowPending.length > 0) {
229
+ const keys = await openPrivateKeys(mailboxUid, status, ownerUnlocked, client);
230
+ try {
231
+ const wraps = [];
232
+ for (const member of missing) {
233
+ for (const [fingerprint, pkcs8] of keys) {
234
+ if (!member.wrapped.includes(fingerprint)) {
235
+ wraps.push({ ...(await wrapFor(member, pkcs8)), zeroAccessKeyFingerprint: fingerprint });
236
+ }
237
+ }
238
+ }
239
+ if (wraps.length > 0) {
240
+ await putMemberKeyWraps(mailboxUid, wraps, client);
241
+ result.wrapped = wraps.length;
242
+ }
243
+ const held = escrowPending.filter((fingerprint) => keys.has(fingerprint));
244
+ if (held.length > 0) {
245
+ const escrow = await getEscrowInfo(mailboxUid, client);
246
+ // Assigned to another scope since the status was read: the next pass sees it.
247
+ if (escrow.escrowScopeId === status.escrowScopeId) {
248
+ const escrowWraps = [];
249
+ for (const fingerprint of held) {
250
+ escrowWraps.push({ ...(await escrowWrapFor(escrow, keys.get(fingerprint))), zeroAccessKeyFingerprint: fingerprint });
251
+ }
252
+ await putZeroAccessEscrowWraps(mailboxUid, escrowWraps, client);
253
+ result.escrowed = escrowWraps.length;
254
+ }
255
+ }
256
+ }
257
+ finally {
258
+ for (const pkcs8 of keys.values()) {
259
+ pkcs8.fill(0);
260
+ }
261
+ }
262
+ }
263
+ return result;
264
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Zero-access keys (restapi `specs/search.md` §3): the per-mailbox keypair the server encrypts mail at rest to on arrival.
3
+ * Generated here like the encryption key - P-256, its certificate issued by restapi's internal CA from a CSR, its PKCS#8
4
+ * private key sealed under the mailbox master key - but never published: restapi keeps the certificate on
5
+ * `Mailbox.zeroAccessKeys` and returns it only with the vault.
6
+ */
7
+ import type { ApiClient } from "../util/api.js";
8
+ import { type KeyVault } from "./keyvaultApi.js";
9
+ import { type RetainedEncryptionKey } from "./keySession.js";
10
+ /** What `provisionZeroAccessKey()` resolves with. */
11
+ export interface ProvisionedZeroAccessKey {
12
+ /** The vault as restapi returned it after the enrollment. */
13
+ vault: KeyVault;
14
+ /** The new key, ready to decrypt with (its private key imported non-extractable for ECDH). */
15
+ key: RetainedEncryptionKey;
16
+ }
17
+ /**
18
+ * Generates a zero-access keypair for `mailboxUid`, seals its private key under `masterKey` and enrolls it. The key becomes
19
+ * the mailbox's active zero-access key (restapi supersedes any earlier one, whose private key stays in the vault). Pass the
20
+ * vault's `masterKeyGeneration` read alongside `masterKey` as `expectedMasterKeyGeneration`, so restapi refuses (`409`)
21
+ * the key if another device rotated the master key meanwhile.
22
+ */
23
+ export declare function provisionZeroAccessKey(mailboxUid: string, mailboxAddress: string, masterKey: Uint8Array, expectedMasterKeyGeneration?: number, client?: ApiClient): Promise<ProvisionedZeroAccessKey>;
24
+ /**
25
+ * Gives an unlocked mailbox a zero-access key when it has none yet - how a mailbox set up before zero-access encryption
26
+ * existed gets one, on its owner's next unlock. Resolves `true` when it enrolled a key (and added it to the session), `false`
27
+ * when there was nothing to do: the mailbox isn't unlocked, or its vault already holds an unrevoked zero-access key.
28
+ *
29
+ * Only the mailbox's owner may call this (restapi refuses anybody else's enrollment); a caller decides that, e.g. from
30
+ * `KeyEnrollmentGate`'s `canProvision`.
31
+ */
32
+ export declare function ensureZeroAccessKey(mailboxUid: string, mailboxAddress: string, client?: ApiClient): Promise<boolean>;
@@ -0,0 +1,66 @@
1
+ import { fromBase64 } from "./encoding.js";
2
+ import { enrollKey, getKeyVault } from "./keyvaultApi.js";
3
+ import { exportPrivateKeyPkcs8, generateKeyPairWithCsr, importPrivateKeyPkcs8 } from "./keys.js";
4
+ import { addZeroAccessKey, getUnlockedKeys, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE } from "./keySession.js";
5
+ import { buildAad, sealWithKey } from "./masterKey.js";
6
+ /**
7
+ * Generates a zero-access keypair for `mailboxUid`, seals its private key under `masterKey` and enrolls it. The key becomes
8
+ * the mailbox's active zero-access key (restapi supersedes any earlier one, whose private key stays in the vault). Pass the
9
+ * vault's `masterKeyGeneration` read alongside `masterKey` as `expectedMasterKeyGeneration`, so restapi refuses (`409`)
10
+ * the key if another device rotated the master key meanwhile.
11
+ */
12
+ export async function provisionZeroAccessKey(mailboxUid, mailboxAddress, masterKey, expectedMasterKeyGeneration, client) {
13
+ const { keyPair, csrPem } = await generateKeyPairWithCsr(mailboxAddress, "encrypt");
14
+ const raw = await exportPrivateKeyPkcs8(keyPair.privateKey);
15
+ try {
16
+ const sealed = await sealWithKey(masterKey, raw, buildAad(mailboxUid, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE));
17
+ const vault = await enrollKey(mailboxUid, {
18
+ useType: "zero-access",
19
+ csr: csrPem,
20
+ wrappedKey: { ciphertext: sealed.ciphertext, nonce: sealed.nonce, algorithm: "AES-256-GCM" },
21
+ ...(expectedMasterKeyGeneration !== undefined ? { expectedMasterKeyGeneration } : {}),
22
+ }, client);
23
+ // restapi appends the new wrapped key last.
24
+ const enrolled = [...vault.wrappedKeys].reverse().find((k) => k.useType === "zero-access");
25
+ const certificate = enrolled && vault.zeroAccessKeys?.find((k) => k.fingerprint === enrolled.fingerprint);
26
+ if (!enrolled || !certificate) {
27
+ throw new Error("The server did not return the enrolled zero-access key.");
28
+ }
29
+ const privateKey = await importPrivateKeyPkcs8(raw, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
30
+ return { vault, key: { fingerprint: enrolled.fingerprint, certDer: fromBase64(certificate.publicKey), privateKey } };
31
+ }
32
+ finally {
33
+ raw.fill(0);
34
+ }
35
+ }
36
+ /** Provisions already in flight, per mailbox - a second caller (another effect, a re-render) joins the first. */
37
+ const inFlight = new Map();
38
+ /**
39
+ * Gives an unlocked mailbox a zero-access key when it has none yet - how a mailbox set up before zero-access encryption
40
+ * existed gets one, on its owner's next unlock. Resolves `true` when it enrolled a key (and added it to the session), `false`
41
+ * when there was nothing to do: the mailbox isn't unlocked, or its vault already holds an unrevoked zero-access key.
42
+ *
43
+ * Only the mailbox's owner may call this (restapi refuses anybody else's enrollment); a caller decides that, e.g. from
44
+ * `KeyEnrollmentGate`'s `canProvision`.
45
+ */
46
+ export function ensureZeroAccessKey(mailboxUid, mailboxAddress, client) {
47
+ const pending = inFlight.get(mailboxUid);
48
+ if (pending) {
49
+ return pending;
50
+ }
51
+ const run = (async () => {
52
+ const unlocked = getUnlockedKeys(mailboxUid);
53
+ if (!unlocked || unlocked.zeroAccessKeys?.length) {
54
+ return false;
55
+ }
56
+ const vault = await getKeyVault(mailboxUid, client);
57
+ if ((vault.zeroAccessKeys ?? []).some((k) => !k.revokedAt) || vault.wrappedKeys.length === 0) {
58
+ return false;
59
+ }
60
+ const { key } = await provisionZeroAccessKey(mailboxUid, mailboxAddress, unlocked.masterKey, vault.masterKeyGeneration, client);
61
+ addZeroAccessKey(mailboxUid, unlocked, key);
62
+ return true;
63
+ })().finally(() => inFlight.delete(mailboxUid));
64
+ inFlight.set(mailboxUid, run);
65
+ return run;
66
+ }
@@ -31,6 +31,12 @@ export interface ConversationSummary {
31
31
  latestFrom: Recipient;
32
32
  /** The most recent message's `bodyPreview` — the snippet a collapsed row shows. */
33
33
  latestPreview: string;
34
+ /** Set when the latest message is sealed at rest (zero-access): its subject and preview are only in `sealedSummary`, which
35
+ * `messageSecurity.ts`'s `openZeroAccessSummary()` opens with the mailbox's zero-access keys. */
36
+ latestZeroAccess?: {
37
+ keyFingerprint: string;
38
+ sealedSummary: string;
39
+ };
34
40
  /** The most recent message's folder. */
35
41
  latestFolderUid: string;
36
42
  /** The iTIP `METHOD` of the calendar file the most recent message carries (`Message.meetingMethod`) - `"REQUEST"`, `"REPLY"`,