@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
@@ -37,9 +37,23 @@
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 { DisplayBody, MimeAttachment, MimeHeaderField, decodeHeaderText, extractAddresses, parseMimeEntity, parseParameterizedHeader, plainTextToHtml } from "./mime.js";
41
- import { toBase64 } from "./encoding.js";
42
- import { computeCertFingerprint, extractCertificateEmails } from "./smime.js";
40
+ import {
41
+ DisplayBody,
42
+ MimeAttachment,
43
+ MimeHeaderField,
44
+ bytesToBinaryString,
45
+ decodeBase64Text,
46
+ decodeHeaderText,
47
+ extractAddresses,
48
+ extractAttachments,
49
+ extractCalendarText,
50
+ extractDisplayBody,
51
+ parseMimeEntity,
52
+ parseParameterizedHeader,
53
+ plainTextToHtml,
54
+ } from "./mime.js";
55
+ import { fromBase64, toBase64 } from "./encoding.js";
56
+ import { type DecryptionKey, computeCertFingerprint, decryptEnvelopedDataWithKeys, extractCertificateEmails } from "./smime.js";
43
57
  import { ComparableOuterHeaders, ProtectedHeaders, parseEncryptedMessageWithKeys, parseSignedOnlyMessage } from "./smimeMessage.js";
44
58
  import { type PublicKey, isTrustedForVerification } from "./keyvaultApi.js";
45
59
  import { KeysLockedError } from "./masterKey.js";
@@ -67,6 +81,7 @@ export type MessageSecurityState =
67
81
  | "encrypted_unverified_signer"
68
82
  | "signature_failed"
69
83
  | "verified_at_first_open"
84
+ | "protected_at_rest"
70
85
  | "unprotected";
71
86
 
72
87
  /** Why a message is `"signature_failed"`. `invalid_signature`: the CMS signature itself is malformed or
@@ -113,6 +128,13 @@ export interface MessageSecurityResult {
113
128
  * since-rotated key, non-AEAD content encryption, or corrupt/foreign data) - the caller should keep
114
129
  * showing whatever `GET /:id/content` already produced alongside this explanation. */
115
130
  decryptError?: string;
131
+ /** Set when the stored message was sealed at rest by the server (restapi `specs/search.md` §3) and opened here with the mailbox's
132
+ * zero-access key. `state` then describes the original message inside: `protected_at_rest` when it arrived unprotected, or its own
133
+ * signed/encrypted state otherwise. */
134
+ zeroAccess?: boolean;
135
+ /** With `zeroAccess`: the original's calendar file (`extractCalendarText()`), which the server can't read from the sealed copy - the
136
+ * client sends it with the invitation endpoints. Absent when the message carries none. */
137
+ calendar?: string;
116
138
  /** See `ParsedEncryptedMessage.headerTamperDetected`'s own doc comment - a deliberately separate,
117
139
  * orthogonal signal from `state`, since RFC 9788's `HP-Outer` is written on every encrypted message
118
140
  * regardless of whether it's also signed. `undefined` for anything that isn't an encrypted message
@@ -347,6 +369,61 @@ export interface MessageSecurityKeys {
347
369
  encryptionCertDer?: Uint8Array;
348
370
  retainedEncryptionKeys?: { certDer: Uint8Array; privateKey: CryptoKey }[];
349
371
  signingFingerprint?: string;
372
+ /** The mailbox's zero-access keys (`keySession.ts`'s `UnlockedKeys.zeroAccessKeys`) - what opens a message the server sealed at
373
+ * rest on arrival. */
374
+ zeroAccessKeys?: { certDer: Uint8Array; privateKey: CryptoKey }[];
375
+ }
376
+
377
+ /**
378
+ * The subject and preview of a zero-access message (`Message.zeroAccess.sealedSummary`, base64 CMS of JSON `{ subject, preview }`),
379
+ * decrypted with the mailbox's zero-access keys - a few hundred bytes, so a message list shows real subjects without downloading
380
+ * each message. `undefined` when no key opens it or it isn't a summary.
381
+ */
382
+ export async function openZeroAccessSummary(
383
+ sealedSummary: string,
384
+ keys: DecryptionKey[] | undefined,
385
+ ): Promise<{ subject: string; preview: string } | undefined> {
386
+ if (!keys?.length) {
387
+ return undefined;
388
+ }
389
+ try {
390
+ const json: unknown = JSON.parse(new TextDecoder().decode(await decryptEnvelopedDataWithKeys(fromBase64(sealedSummary), keys)));
391
+ const { subject, preview } = (json ?? {}) as Record<string, unknown>;
392
+ return typeof subject === "string" && typeof preview === "string" ? { subject, preview } : undefined;
393
+ } catch {
394
+ return undefined;
395
+ }
396
+ }
397
+
398
+ /**
399
+ * The original MIME (as a binary string) of a stored message the server sealed at rest, opened with `unlocked.zeroAccessKeys` -
400
+ * `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
401
+ * filter (`reportMessage()`'s `rawMessage`).
402
+ */
403
+ export async function openZeroAccessMessage(rawMime: string, unlocked: MessageSecurityKeys | undefined): Promise<string | undefined> {
404
+ const { headers, body } = parseMimeEntity(rawMime);
405
+ const rawContentType = headers["content-type"];
406
+ if (!unlocked?.zeroAccessKeys?.length || !rawContentType || !isEncryptedContentType(parseParameterizedHeader(rawContentType))) {
407
+ return undefined;
408
+ }
409
+ return openZeroAccess(body, unlocked.zeroAccessKeys);
410
+ }
411
+
412
+ /**
413
+ * The original MIME of a message the server sealed at rest (restapi `specs/search.md` §3) - the stored S/MIME `enveloped-data`
414
+ * message's content, decrypted with one of `keys` - or `undefined` when none of them opens it. Only the server ever encrypts to a
415
+ * zero-access key (it is never published), so a message that opens with one was sealed at rest, never sent end-to-end.
416
+ */
417
+ async function openZeroAccess(body: string, keys: DecryptionKey[]): Promise<string | undefined> {
418
+ const der = decodeBase64Text(body);
419
+ if (!der) {
420
+ return undefined;
421
+ }
422
+ try {
423
+ return bytesToBinaryString(await decryptEnvelopedDataWithKeys(der, keys));
424
+ } catch {
425
+ return undefined;
426
+ }
350
427
  }
351
428
 
352
429
  /** `evaluateMessageSecurity()`'s result plus what the seal-aware wrapper needs: which kind of protected message it was,
@@ -362,6 +439,7 @@ async function evaluateLive(
362
439
  unlocked: MessageSecurityKeys | undefined,
363
440
  pinnedSignerFingerprints: string | string[] | undefined,
364
441
  readerAddress: string | undefined,
442
+ sealedAtRest = false,
365
443
  ): Promise<LiveEvaluation> {
366
444
  const { headers, body, fields } = parseMimeEntity(rawMime);
367
445
  const rawContentType = headers["content-type"];
@@ -452,6 +530,35 @@ async function evaluateLive(
452
530
  };
453
531
  }
454
532
 
533
+ // A message the server sealed at rest: evaluate the original it holds - which may itself be signed or end-to-end encrypted -
534
+ // and report one that was unprotected when it arrived as `protected_at_rest`, with its content.
535
+ if (isEncryptedContentType(contentType) && !sealedAtRest && unlocked?.zeroAccessKeys?.length) {
536
+ const original = await openZeroAccess(body, unlocked.zeroAccessKeys);
537
+ if (original !== undefined) {
538
+ const inner = await evaluateLive(original, unlocked, pinnedSignerFingerprints, readerAddress, true);
539
+ const entity = parseMimeEntity(original);
540
+ const subject = entity.headers["subject"];
541
+ const calendarText = extractCalendarText(entity);
542
+ const calendar = calendarText !== undefined ? { calendar: calendarText } : {};
543
+ if (inner.result.state !== "unprotected") {
544
+ // The stored message's own Subject is the placeholder: the original's stands in when the inner result has none.
545
+ const fallbackSubject = inner.result.subject === undefined && subject !== undefined ? { subject: decodeHeaderText(subject) } : {};
546
+ return { ...inner, result: { ...inner.result, ...fallbackSubject, ...calendar, zeroAccess: true } };
547
+ }
548
+ return {
549
+ result: {
550
+ state: "protected_at_rest",
551
+ zeroAccess: true,
552
+ ...calendar,
553
+ ...renderDisplayBody(extractDisplayBody(entity)),
554
+ ...(subject !== undefined ? { subject: decodeHeaderText(subject) } : {}),
555
+ attachments: extractAttachments(entity),
556
+ },
557
+ kind: "other",
558
+ };
559
+ }
560
+ }
561
+
455
562
  if (isEncryptedContentType(contentType)) {
456
563
  // The active key first, then the retained older ones (see `keySession.ts`'s `retainedEncryptionKeys`), so mail
457
564
  // encrypted to a key since replaced still opens. Only ever used to decrypt here - never to encrypt or sign.
@@ -541,6 +541,32 @@ export function extractAttachments(entity: MimeEntity, depth = 0): MimeAttachmen
541
541
  ];
542
542
  }
543
543
 
544
+ /**
545
+ * The text of the first calendar file in `entity` (descending into multipart children, bounded depth): a `text/calendar` or
546
+ * `application/ics` leaf, whether a body alternative (as Outlook sends an invitation) or an attached `.ics`. `undefined` when there is none.
547
+ */
548
+ export function extractCalendarText(entity: MimeEntity, depth = 0): string | undefined {
549
+ const contentType = parseParameterizedHeader(entity.headers["content-type"] ?? "text/plain");
550
+ if (contentType.value.startsWith("multipart/")) {
551
+ const boundary = contentType.params["boundary"];
552
+ if (!boundary || depth >= MAX_MULTIPART_DEPTH) {
553
+ return undefined;
554
+ }
555
+ for (const part of splitMultipart(entity.body, boundary)) {
556
+ const found = extractCalendarText(parseMimeEntity(part), depth + 1);
557
+ if (found !== undefined) {
558
+ return found;
559
+ }
560
+ }
561
+ return undefined;
562
+ }
563
+ if (contentType.value !== "text/calendar" && contentType.value !== "application/ics") {
564
+ return undefined;
565
+ }
566
+ const bytes = decodeBodyBytes(entity);
567
+ return bytes ? new TextDecoder().decode(bytes) : undefined;
568
+ }
569
+
544
570
  /** HTML-escapes plain text and wraps it in a whitespace-preserving `<pre>`, so a `text/plain` body can be
545
571
  * rendered through the same HTML path without ever being interpreted as markup. */
546
572
  export function plainTextToHtml(text: string): string {
@@ -0,0 +1,324 @@
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 { type ApiClient, ApiRequestError } from "../util/api.js";
18
+ import { fromBase64, toBase64 } from "./encoding.js";
19
+ import {
20
+ enrollSharedZeroAccessKey,
21
+ type EscrowInfo,
22
+ getEncryptionPolicy,
23
+ getEscrowInfo,
24
+ getKeyVault,
25
+ getMyMemberKeyWraps,
26
+ getSharedKeyStatus,
27
+ type MemberKeyWrap,
28
+ putMemberKeyWraps,
29
+ putZeroAccessEscrowWraps,
30
+ removeMemberKeyWraps,
31
+ type SharedKeyStatus,
32
+ type ZeroAccessEscrowWrapInput,
33
+ type ZeroAccessKey,
34
+ } from "./keyvaultApi.js";
35
+ import { exportPrivateKeyPkcs8, generateKeyPairWithCsr, importPrivateKeyPkcs8 } from "./keys.js";
36
+ import {
37
+ getUnlockedKeys,
38
+ installSharedSession,
39
+ personalSessions,
40
+ type RetainedEncryptionKey,
41
+ sharedUnlockGeneration,
42
+ type UnlockedKeys,
43
+ ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE,
44
+ } from "./keySession.js";
45
+ import { buildAad, openWithKey } from "./masterKey.js";
46
+ import { decryptEnvelopedDataWithKeys, encryptForRecipients } from "./smime.js";
47
+ import { provisionZeroAccessKey } from "./zeroAccessKey.js";
48
+
49
+ /** What `unlockSharedMailbox()` found. */
50
+ export type SharedUnlockOutcome =
51
+ /** Its keys are open in this session. */
52
+ | "unlocked"
53
+ /** The caller is a member but holds no wrap yet: another member's client shares the key with them shortly. */
54
+ | "pending"
55
+ /** The caller is a member, but none of their own mailboxes is unlocked to open their wraps with. */
56
+ | "own_keys_locked"
57
+ /** Not a shared mailbox of the caller's (they own it, or have no member access): its own unlock applies. */
58
+ | "not_shared";
59
+
60
+ /** Domain-separation prefix of the secret a shared session's `masterKey` stands for. */
61
+ const SESSION_SECRET_LABEL = new TextEncoder().encode("rapidmx-shared-mailbox-session-v1:");
62
+
63
+ async function sessionSecret(pkcs8: Uint8Array): Promise<Uint8Array> {
64
+ const input = new Uint8Array(SESSION_SECRET_LABEL.length + pkcs8.length);
65
+ input.set(SESSION_SECRET_LABEL);
66
+ input.set(pkcs8, SESSION_SECRET_LABEL.length);
67
+ try {
68
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", input));
69
+ } finally {
70
+ input.fill(0);
71
+ }
72
+ }
73
+
74
+ /** One of the user's own zero-access keys, with the mailbox it belongs to. */
75
+ type OwnKey = RetainedEncryptionKey & { mailboxUid: string };
76
+
77
+ /** The zero-access keys of the mailboxes this user has unlocked themselves - what their member wraps are addressed to. */
78
+ function ownZeroAccessKeys(): OwnKey[] {
79
+ return personalSessions().flatMap(({ mailboxUid, unlocked }) => (unlocked.zeroAccessKeys ?? []).map((key) => ({ ...key, mailboxUid })));
80
+ }
81
+
82
+ /** 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`
83
+ * when no own key opens it. */
84
+ async function openWrap(wrap: MemberKeyWrap, own: OwnKey[]): Promise<{ pkcs8: Uint8Array; openedWith: string } | undefined> {
85
+ const recipient = own.find((key) => key.fingerprint === wrap.recipientFingerprint);
86
+ if (!recipient) {
87
+ return undefined;
88
+ }
89
+ try {
90
+ return { pkcs8: await decryptEnvelopedDataWithKeys(fromBase64(wrap.cms), [recipient]), openedWith: recipient.mailboxUid };
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ }
95
+
96
+ /** The active key first, then the others newest first - the order `UnlockedKeys.zeroAccessKeys` keeps. */
97
+ function keyOrder(status: Pick<SharedKeyStatus, "activeKeyFingerprint" | "zeroAccessKeys">): (fingerprint: string) => number {
98
+ const rank = new Map<string, number>(
99
+ [...status.zeroAccessKeys]
100
+ .sort((a, b) => Number(a.fingerprint !== status.activeKeyFingerprint) - Number(b.fingerprint !== status.activeKeyFingerprint) || b.notBefore - a.notBefore)
101
+ .map((key, index) => [key.fingerprint, index]),
102
+ );
103
+ return (fingerprint) => rank.get(fingerprint) ?? Number.MAX_SAFE_INTEGER;
104
+ }
105
+
106
+ /**
107
+ * Opens shared mailbox `mailboxUid` for its member, with no password: their member wraps, opened with the zero-access keys of a mailbox they
108
+ * own and have unlocked, become this session's keys for it (`installSharedSession()`). See `SharedUnlockOutcome` for when it can't.
109
+ */
110
+ export async function unlockSharedMailbox(mailboxUid: string, client?: ApiClient): Promise<SharedUnlockOutcome> {
111
+ if (getUnlockedKeys(mailboxUid)) {
112
+ return "unlocked";
113
+ }
114
+ const generation: string = sharedUnlockGeneration(mailboxUid);
115
+ let status: SharedKeyStatus;
116
+ try {
117
+ status = await getSharedKeyStatus(mailboxUid, client);
118
+ } catch (err) {
119
+ if (err instanceof ApiRequestError && (err.status === 403 || err.status === 404)) {
120
+ return "not_shared";
121
+ }
122
+ throw err;
123
+ }
124
+ if (status.role !== "member") {
125
+ return "not_shared";
126
+ }
127
+ const wraps: MemberKeyWrap[] = await getMyMemberKeyWraps(mailboxUid, client);
128
+ if (wraps.length === 0) {
129
+ return "pending";
130
+ }
131
+ const own = ownZeroAccessKeys();
132
+ const rank = keyOrder(status);
133
+ const opened: RetainedEncryptionKey[] = [];
134
+ const openedWith: Set<string> = new Set();
135
+ let secret: Uint8Array | undefined;
136
+ for (const wrap of [...wraps].sort((a, b) => rank(a.zeroAccessKeyFingerprint) - rank(b.zeroAccessKeyFingerprint))) {
137
+ const certificate: ZeroAccessKey | undefined = status.zeroAccessKeys.find((key) => key.fingerprint === wrap.zeroAccessKeyFingerprint);
138
+ const unwrapped = certificate ? await openWrap(wrap, own) : undefined;
139
+ if (!certificate || !unwrapped) {
140
+ continue;
141
+ }
142
+ const pkcs8 = unwrapped.pkcs8;
143
+ try {
144
+ secret ??= await sessionSecret(pkcs8);
145
+ const privateKey = await importPrivateKeyPkcs8(pkcs8, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"]);
146
+ opened.push({ fingerprint: wrap.zeroAccessKeyFingerprint, certDer: fromBase64(certificate.publicKey), privateKey });
147
+ openedWith.add(unwrapped.openedWith);
148
+ } catch {
149
+ // A wrap that doesn't hold a usable key is skipped; the others still open the mailbox.
150
+ } finally {
151
+ pkcs8.fill(0);
152
+ }
153
+ }
154
+ if (!secret || opened.length === 0) {
155
+ return "own_keys_locked";
156
+ }
157
+ const unlocked: UnlockedKeys = { masterKey: secret, zeroAccessKeys: opened, openedWith: [...openedWith] };
158
+ installSharedSession(mailboxUid, unlocked, generation);
159
+ return "unlocked";
160
+ }
161
+
162
+ /** 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
163
+ * key, or from the caller's own member wraps. The caller zeroes them. */
164
+ async function openPrivateKeys(
165
+ mailboxUid: string,
166
+ status: SharedKeyStatus,
167
+ ownerUnlocked: UnlockedKeys | undefined,
168
+ client?: ApiClient,
169
+ ): Promise<Map<string, Uint8Array>> {
170
+ const keys = new Map<string, Uint8Array>();
171
+ if (ownerUnlocked) {
172
+ const vault = await getKeyVault(mailboxUid, client);
173
+ const aad = buildAad(mailboxUid, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE);
174
+ for (const key of status.zeroAccessKeys) {
175
+ const wrapped = vault.wrappedKeys.find((w) => w.fingerprint === key.fingerprint && w.useType === "zero-access");
176
+ if (wrapped) {
177
+ try {
178
+ keys.set(key.fingerprint, await openWithKey(ownerUnlocked.masterKey, wrapped, aad));
179
+ } catch {
180
+ // Sealed under a master key since replaced: not this device's to share.
181
+ }
182
+ }
183
+ }
184
+ return keys;
185
+ }
186
+ const own = ownZeroAccessKeys();
187
+ for (const wrap of await getMyMemberKeyWraps(mailboxUid, client)) {
188
+ const unwrapped = await openWrap(wrap, own);
189
+ if (unwrapped) {
190
+ keys.set(wrap.zeroAccessKeyFingerprint, unwrapped.pkcs8);
191
+ }
192
+ }
193
+ return keys;
194
+ }
195
+
196
+ async function wrapFor(member: { userUid: string; certificate: ZeroAccessKey }, pkcs8: Uint8Array): Promise<Omit<MemberKeyWrap, "zeroAccessKeyFingerprint">> {
197
+ return {
198
+ memberUserUid: member.userUid,
199
+ recipientFingerprint: member.certificate.fingerprint,
200
+ cms: toBase64(await encryptForRecipients(pkcs8, [fromBase64(member.certificate.publicKey)])),
201
+ };
202
+ }
203
+
204
+ /** The members with a certificate of their own - the ones a key can be wrapped for. */
205
+ function wrappable(status: SharedKeyStatus): { userUid: string; certificate: ZeroAccessKey; wrapped: string[] }[] {
206
+ return status.members.filter((member): member is typeof member & { certificate: ZeroAccessKey } => !!member.certificate);
207
+ }
208
+
209
+ /** `pkcs8` wrapped to the certificate of the escrow scope `escrow` describes. */
210
+ async function escrowWrapFor(escrow: EscrowInfo, pkcs8: Uint8Array): Promise<Omit<ZeroAccessEscrowWrapInput, "zeroAccessKeyFingerprint">> {
211
+ return { escrowScopeId: escrow.escrowScopeId, cms: toBase64(await encryptForRecipients(pkcs8, [fromBase64(escrow.publicKey.publicKey)])) };
212
+ }
213
+
214
+ /** 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
215
+ * escrow scope when it has one. */
216
+ async function enrollNewSharedKey(mailboxUid: string, mailboxAddress: string, status: SharedKeyStatus, client?: ApiClient): Promise<SharedKeyStatus> {
217
+ const escrow: EscrowInfo | undefined = status.escrowScopeId ? await getEscrowInfo(mailboxUid, client) : undefined;
218
+ const { keyPair, csrPem } = await generateKeyPairWithCsr(mailboxAddress, "encrypt");
219
+ const pkcs8 = await exportPrivateKeyPkcs8(keyPair.privateKey);
220
+ try {
221
+ const wraps = await Promise.all(wrappable(status).map((member) => wrapFor(member, pkcs8)));
222
+ const escrowWrap = escrow ? await escrowWrapFor(escrow, pkcs8) : undefined;
223
+ return await enrollSharedZeroAccessKey(mailboxUid, { csr: csrPem, wraps, ...(escrowWrap ? { escrowWrap } : {}) }, client);
224
+ } finally {
225
+ pkcs8.fill(0);
226
+ }
227
+ }
228
+
229
+ /** What `maintainSharedMailbox()` did. */
230
+ export interface SharedMaintenance {
231
+ provisioned: boolean;
232
+ rotated: boolean;
233
+ /** Wraps added for waiting members. */
234
+ wrapped: number;
235
+ /** Departed users whose wraps were removed. */
236
+ removed: number;
237
+ /** Keys wrapped for the mailbox's escrow scope. */
238
+ escrowed: number;
239
+ }
240
+
241
+ /**
242
+ * 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
243
+ * anyone else, or while the keys it needs are locked:
244
+ *
245
+ * A mailbox without an owner and without a key gets one (unless the administrator disallows zero-access), wrapped for every member who has a
246
+ * certificate. Once a user who held the active key has left (`rotationPending`), a new key replaces it - enrolled by the owner with their
247
+ * master key (`provisionZeroAccessKey()`), or for a mailbox without an owner with the members' wraps - and then the departed users' wraps
248
+ * are removed. Members still waiting (`pending`) get wraps of every key this client can open, and the escrow scope of a mailbox without an
249
+ * owner gets copies of the keys it is missing (`escrowPending`).
250
+ */
251
+ export async function maintainSharedMailbox(mailboxUid: string, mailboxAddress: string, client?: ApiClient): Promise<SharedMaintenance> {
252
+ const result: SharedMaintenance = { provisioned: false, rotated: false, wrapped: 0, removed: 0, escrowed: 0 };
253
+ let status: SharedKeyStatus = await getSharedKeyStatus(mailboxUid, client);
254
+ if (!status.manager || (status.members.length === 0 && status.departed.length === 0)) {
255
+ return result;
256
+ }
257
+ const ownerUnlocked = status.role === "owner" ? getUnlockedKeys(mailboxUid) : undefined;
258
+ if (status.role === "owner" && (!ownerUnlocked || ownerUnlocked.shared)) {
259
+ return result;
260
+ }
261
+
262
+ if (status.ownerless && !status.activeKeyFingerprint) {
263
+ const policy = await getEncryptionPolicy(client).catch(() => undefined);
264
+ if (policy?.allowZeroAccess === false || ownZeroAccessKeys().length === 0) {
265
+ return result;
266
+ }
267
+ status = await enrollNewSharedKey(mailboxUid, mailboxAddress, status, client);
268
+ result.provisioned = true;
269
+ } else if (status.rotationPending) {
270
+ if (status.ownerless) {
271
+ status = await enrollNewSharedKey(mailboxUid, mailboxAddress, status, client);
272
+ } else {
273
+ const vault = await getKeyVault(mailboxUid, client);
274
+ await provisionZeroAccessKey(mailboxUid, mailboxAddress, ownerUnlocked!.masterKey, vault.masterKeyGeneration, client);
275
+ status = await getSharedKeyStatus(mailboxUid, client);
276
+ }
277
+ result.rotated = true;
278
+ }
279
+ // The departed hold no wrap of the active key any more (or never did): their old wraps go.
280
+ if (!status.rotationPending) {
281
+ for (const departed of status.departed) {
282
+ status = await removeMemberKeyWraps(mailboxUid, departed, client);
283
+ result.removed++;
284
+ }
285
+ }
286
+
287
+ const missing = wrappable(status).filter((member) => status.zeroAccessKeys.some((key) => !member.wrapped.includes(key.fingerprint)));
288
+ const escrowPending: string[] = status.ownerless && status.escrowScopeId ? (status.escrowPending ?? []) : [];
289
+ if (missing.length > 0 || escrowPending.length > 0) {
290
+ const keys = await openPrivateKeys(mailboxUid, status, ownerUnlocked, client);
291
+ try {
292
+ const wraps: MemberKeyWrap[] = [];
293
+ for (const member of missing) {
294
+ for (const [fingerprint, pkcs8] of keys) {
295
+ if (!member.wrapped.includes(fingerprint)) {
296
+ wraps.push({ ...(await wrapFor(member, pkcs8)), zeroAccessKeyFingerprint: fingerprint });
297
+ }
298
+ }
299
+ }
300
+ if (wraps.length > 0) {
301
+ await putMemberKeyWraps(mailboxUid, wraps, client);
302
+ result.wrapped = wraps.length;
303
+ }
304
+ const held: string[] = escrowPending.filter((fingerprint) => keys.has(fingerprint));
305
+ if (held.length > 0) {
306
+ const escrow: EscrowInfo = await getEscrowInfo(mailboxUid, client);
307
+ // Assigned to another scope since the status was read: the next pass sees it.
308
+ if (escrow.escrowScopeId === status.escrowScopeId) {
309
+ const escrowWraps: ZeroAccessEscrowWrapInput[] = [];
310
+ for (const fingerprint of held) {
311
+ escrowWraps.push({ ...(await escrowWrapFor(escrow, keys.get(fingerprint)!)), zeroAccessKeyFingerprint: fingerprint });
312
+ }
313
+ await putZeroAccessEscrowWraps(mailboxUid, escrowWraps, client);
314
+ result.escrowed = escrowWraps.length;
315
+ }
316
+ }
317
+ } finally {
318
+ for (const pkcs8 of keys.values()) {
319
+ pkcs8.fill(0);
320
+ }
321
+ }
322
+ }
323
+ return result;
324
+ }
@@ -0,0 +1,97 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Zero-access keys (restapi `specs/search.md` §3): the per-mailbox keypair the server encrypts mail at rest to on arrival.
7
+ * Generated here like the encryption key - P-256, its certificate issued by restapi's internal CA from a CSR, its PKCS#8
8
+ * private key sealed under the mailbox master key - but never published: restapi keeps the certificate on
9
+ * `Mailbox.zeroAccessKeys` and returns it only with the vault.
10
+ */
11
+ import type { ApiClient } from "../util/api.js";
12
+ import { fromBase64 } from "./encoding.js";
13
+ import { type KeyVault, enrollKey, getKeyVault } from "./keyvaultApi.js";
14
+ import { exportPrivateKeyPkcs8, generateKeyPairWithCsr, importPrivateKeyPkcs8 } from "./keys.js";
15
+ import { addZeroAccessKey, getUnlockedKeys, type RetainedEncryptionKey, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE } from "./keySession.js";
16
+ import { buildAad, sealWithKey } from "./masterKey.js";
17
+
18
+ /** What `provisionZeroAccessKey()` resolves with. */
19
+ export interface ProvisionedZeroAccessKey {
20
+ /** The vault as restapi returned it after the enrollment. */
21
+ vault: KeyVault;
22
+ /** The new key, ready to decrypt with (its private key imported non-extractable for ECDH). */
23
+ key: RetainedEncryptionKey;
24
+ }
25
+
26
+ /**
27
+ * Generates a zero-access keypair for `mailboxUid`, seals its private key under `masterKey` and enrolls it. The key becomes
28
+ * the mailbox's active zero-access key (restapi supersedes any earlier one, whose private key stays in the vault). Pass the
29
+ * vault's `masterKeyGeneration` read alongside `masterKey` as `expectedMasterKeyGeneration`, so restapi refuses (`409`)
30
+ * the key if another device rotated the master key meanwhile.
31
+ */
32
+ export async function provisionZeroAccessKey(
33
+ mailboxUid: string,
34
+ mailboxAddress: string,
35
+ masterKey: Uint8Array,
36
+ expectedMasterKeyGeneration?: number,
37
+ client?: ApiClient,
38
+ ): Promise<ProvisionedZeroAccessKey> {
39
+ const { keyPair, csrPem } = await generateKeyPairWithCsr(mailboxAddress, "encrypt");
40
+ const raw = await exportPrivateKeyPkcs8(keyPair.privateKey);
41
+ try {
42
+ const sealed = await sealWithKey(masterKey, raw, buildAad(mailboxUid, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE));
43
+ const vault = await enrollKey(
44
+ mailboxUid,
45
+ {
46
+ useType: "zero-access",
47
+ csr: csrPem,
48
+ wrappedKey: { ciphertext: sealed.ciphertext, nonce: sealed.nonce, algorithm: "AES-256-GCM" },
49
+ ...(expectedMasterKeyGeneration !== undefined ? { expectedMasterKeyGeneration } : {}),
50
+ },
51
+ client,
52
+ );
53
+ // restapi appends the new wrapped key last.
54
+ const enrolled = [...vault.wrappedKeys].reverse().find((k) => k.useType === "zero-access");
55
+ const certificate = enrolled && vault.zeroAccessKeys?.find((k) => k.fingerprint === enrolled.fingerprint);
56
+ if (!enrolled || !certificate) {
57
+ throw new Error("The server did not return the enrolled zero-access key.");
58
+ }
59
+ const privateKey = await importPrivateKeyPkcs8(raw, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
60
+ return { vault, key: { fingerprint: enrolled.fingerprint, certDer: fromBase64(certificate.publicKey), privateKey } };
61
+ } finally {
62
+ raw.fill(0);
63
+ }
64
+ }
65
+
66
+ /** Provisions already in flight, per mailbox - a second caller (another effect, a re-render) joins the first. */
67
+ const inFlight = new Map<string, Promise<boolean>>();
68
+
69
+ /**
70
+ * Gives an unlocked mailbox a zero-access key when it has none yet - how a mailbox set up before zero-access encryption
71
+ * existed gets one, on its owner's next unlock. Resolves `true` when it enrolled a key (and added it to the session), `false`
72
+ * when there was nothing to do: the mailbox isn't unlocked, or its vault already holds an unrevoked zero-access key.
73
+ *
74
+ * Only the mailbox's owner may call this (restapi refuses anybody else's enrollment); a caller decides that, e.g. from
75
+ * `KeyEnrollmentGate`'s `canProvision`.
76
+ */
77
+ export function ensureZeroAccessKey(mailboxUid: string, mailboxAddress: string, client?: ApiClient): Promise<boolean> {
78
+ const pending = inFlight.get(mailboxUid);
79
+ if (pending) {
80
+ return pending;
81
+ }
82
+ const run = (async (): Promise<boolean> => {
83
+ const unlocked = getUnlockedKeys(mailboxUid);
84
+ if (!unlocked || unlocked.zeroAccessKeys?.length) {
85
+ return false;
86
+ }
87
+ const vault = await getKeyVault(mailboxUid, client);
88
+ if ((vault.zeroAccessKeys ?? []).some((k) => !k.revokedAt) || vault.wrappedKeys.length === 0) {
89
+ return false;
90
+ }
91
+ const { key } = await provisionZeroAccessKey(mailboxUid, mailboxAddress, unlocked.masterKey, vault.masterKeyGeneration, client);
92
+ addZeroAccessKey(mailboxUid, unlocked, key);
93
+ return true;
94
+ })().finally(() => inFlight.delete(mailboxUid));
95
+ inFlight.set(mailboxUid, run);
96
+ return run;
97
+ }
@@ -37,6 +37,9 @@ export interface ConversationSummary {
37
37
  latestFrom: Recipient;
38
38
  /** The most recent message's `bodyPreview` — the snippet a collapsed row shows. */
39
39
  latestPreview: string;
40
+ /** Set when the latest message is sealed at rest (zero-access): its subject and preview are only in `sealedSummary`, which
41
+ * `messageSecurity.ts`'s `openZeroAccessSummary()` opens with the mailbox's zero-access keys. */
42
+ latestZeroAccess?: { keyFingerprint: string; sealedSummary: string };
40
43
  /** The most recent message's folder. */
41
44
  latestFolderUid: string;
42
45
  /** The iTIP `METHOD` of the calendar file the most recent message carries (`Message.meetingMethod`) - `"REQUEST"`, `"REPLY"`,