@rapidmx/web-client 0.35.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 (268) hide show
  1. package/apps/escrow/matters/[uid].tsx +4 -3
  2. package/apps/shared/calendar/calendarReminders.ts +35 -0
  3. package/apps/shared/calendar/sealedEvents.ts +113 -0
  4. package/apps/shared/calendar/useCalendarReminders.ts +28 -9
  5. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
  6. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +89 -3
  7. package/apps/shared/components/calendar/EventDetails.tsx +13 -2
  8. package/apps/shared/components/calendar/EventEditor.tsx +124 -17
  9. package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
  10. package/apps/shared/components/calendar/EventModal.tsx +19 -2
  11. package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
  12. package/apps/shared/components/calendar/eventForm.ts +2 -0
  13. package/apps/shared/components/contacts/ContactCard.tsx +13 -1
  14. package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
  15. package/apps/shared/components/contacts/ContactForm.tsx +56 -7
  16. package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
  17. package/apps/shared/components/contacts/contactCardData.ts +8 -4
  18. package/apps/shared/components/contacts/contactKeys.ts +3 -3
  19. package/apps/shared/components/layout/AppShell.tsx +34 -5
  20. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +30 -1
  21. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +122 -0
  22. package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
  23. package/apps/shared/components/mail/ConversationList.tsx +1 -1
  24. package/apps/shared/components/mail/InviteCard.tsx +11 -1
  25. package/apps/shared/components/mail/MessageDetailPane.tsx +59 -26
  26. package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
  27. package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
  28. package/apps/shared/components/mail/compose/draftResume.ts +28 -3
  29. package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
  30. package/apps/shared/components/mail/compose/quotedBody.ts +4 -3
  31. package/apps/shared/components/mail/invite/inviteStore.ts +17 -1
  32. package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
  33. package/apps/shared/components/mail/pinnedSigners.ts +14 -0
  34. package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
  35. package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
  36. package/apps/shared/components/settings/KeyExportSetting.tsx +173 -0
  37. package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
  38. package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
  39. package/apps/shared/components/settings/ZeroAccessSetting.tsx +128 -0
  40. package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
  41. package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
  42. package/apps/shared/mail/decryptedMessages.ts +11 -3
  43. package/apps/shared/mail/outbox/sendJob.ts +349 -50
  44. package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
  45. package/apps/shared/mail/reportNotices.ts +3 -1
  46. package/apps/shared/mail/zeroAccessSource.ts +27 -0
  47. package/apps/shared/search/LocalIndexLifecycle.tsx +84 -24
  48. package/apps/shared/search/localIndexBuilder.ts +57 -20
  49. package/apps/shared/search/localIndexRpcClient.ts +96 -12
  50. package/apps/shared/search/localIndexSchema.ts +20 -4
  51. package/apps/shared/search/localIndexTransport.ts +6 -2
  52. package/apps/shared/search/localIndexWorker.ts +37 -18
  53. package/apps/shared/search/searchTier2.ts +5 -0
  54. package/apps/shared/search/zeroAccessIndexSync.ts +679 -0
  55. package/apps/shared/search/zeroAccessSyncState.ts +70 -0
  56. package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
  57. package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
  58. package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
  59. package/apps/www/_shell.tsx +2 -0
  60. package/apps/www/calendar/index.tsx +225 -61
  61. package/apps/www/contacts/index.tsx +67 -11
  62. package/apps/www/index.tsx +19 -3
  63. package/apps/www/settings/encryption/index.tsx +21 -1
  64. package/apps/www/settings/privacy/index.tsx +89 -39
  65. package/apps/www/tasks/index.tsx +24 -6
  66. package/dist/apps/escrow/matters/[uid].js +2 -2
  67. package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
  68. package/dist/apps/shared/calendar/calendarReminders.js +26 -0
  69. package/dist/apps/shared/calendar/sealedEvents.d.ts +14 -0
  70. package/dist/apps/shared/calendar/sealedEvents.js +85 -0
  71. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
  72. package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
  73. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
  74. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +25 -2
  75. package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
  76. package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
  77. package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
  78. package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
  79. package/dist/apps/shared/components/calendar/EventModal.js +15 -2
  80. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
  81. package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
  82. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
  83. package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
  84. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
  85. package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
  86. package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
  87. package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
  88. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
  89. package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
  90. package/dist/apps/shared/components/contacts/contactCardData.js +8 -4
  91. package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
  92. package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
  93. package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
  94. package/dist/apps/shared/components/layout/AppShell.js +17 -6
  95. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +27 -2
  96. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +17 -0
  97. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +98 -0
  98. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
  99. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
  100. package/dist/apps/shared/components/mail/ConversationList.js +1 -1
  101. package/dist/apps/shared/components/mail/InviteCard.js +11 -1
  102. package/dist/apps/shared/components/mail/MessageDetailPane.js +46 -21
  103. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
  104. package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
  105. package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
  106. package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
  107. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
  108. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
  109. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +1 -1
  110. package/dist/apps/shared/components/mail/compose/quotedBody.js +4 -3
  111. package/dist/apps/shared/components/mail/invite/inviteStore.js +16 -1
  112. package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
  113. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
  114. package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
  115. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
  116. package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
  117. package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
  118. package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
  119. package/dist/apps/shared/components/settings/KeyExportSetting.js +90 -0
  120. package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
  121. package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
  122. package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
  123. package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
  124. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +16 -0
  125. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +80 -0
  126. package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
  127. package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
  128. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  129. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  130. package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
  131. package/dist/apps/shared/mail/decryptedMessages.js +11 -3
  132. package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
  133. package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
  134. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
  135. package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
  136. package/dist/apps/shared/mail/reportNotices.js +3 -1
  137. package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
  138. package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
  139. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
  140. package/dist/apps/shared/search/LocalIndexLifecycle.js +74 -24
  141. package/dist/apps/shared/search/localIndexBuilder.d.ts +9 -1
  142. package/dist/apps/shared/search/localIndexBuilder.js +50 -17
  143. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
  144. package/dist/apps/shared/search/localIndexRpcClient.js +129 -11
  145. package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
  146. package/dist/apps/shared/search/localIndexSchema.js +18 -4
  147. package/dist/apps/shared/search/localIndexTransport.d.ts +6 -2
  148. package/dist/apps/shared/search/localIndexWorker.d.ts +11 -0
  149. package/dist/apps/shared/search/localIndexWorker.js +22 -15
  150. package/dist/apps/shared/search/searchTier2.js +5 -0
  151. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +102 -0
  152. package/dist/apps/shared/search/zeroAccessIndexSync.js +544 -0
  153. package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
  154. package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
  155. package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
  156. package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
  157. package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
  158. package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
  159. package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
  160. package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
  161. package/dist/apps/www/_shell.d.ts +1 -1
  162. package/dist/apps/www/_shell.js +2 -2
  163. package/dist/apps/www/calendar/index.js +88 -20
  164. package/dist/apps/www/contacts/index.js +64 -11
  165. package/dist/apps/www/index.js +19 -3
  166. package/dist/apps/www/settings/encryption/index.js +16 -4
  167. package/dist/apps/www/settings/privacy/index.js +47 -6
  168. package/dist/apps/www/tasks/index.js +21 -7
  169. package/dist/lib/admin/domainsApi.d.ts +1 -1
  170. package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
  171. package/dist/lib/calendar/calendarApi.d.ts +52 -6
  172. package/dist/lib/calendar/calendarApi.js +110 -17
  173. package/dist/lib/calendar/calendarMutations.d.ts +20 -7
  174. package/dist/lib/calendar/calendarMutations.js +73 -14
  175. package/dist/lib/calendar/inviteApi.d.ts +17 -3
  176. package/dist/lib/calendar/inviteApi.js +86 -7
  177. package/dist/lib/calendar/protectedEvents.d.ts +31 -0
  178. package/dist/lib/calendar/protectedEvents.js +113 -0
  179. package/dist/lib/calendar/recurrence.d.ts +2 -0
  180. package/dist/lib/calendar/recurrence.js +16 -0
  181. package/dist/lib/calendar/sealedInvites.d.ts +9 -0
  182. package/dist/lib/calendar/sealedInvites.js +65 -0
  183. package/dist/lib/contacts/contactsApi.d.ts +29 -3
  184. package/dist/lib/contacts/contactsApi.js +23 -7
  185. package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
  186. package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
  187. package/dist/lib/crypto/composeSecurity.d.ts +11 -3
  188. package/dist/lib/crypto/composeSecurity.js +13 -5
  189. package/dist/lib/crypto/keyExport.d.ts +54 -0
  190. package/dist/lib/crypto/keyExport.js +161 -0
  191. package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
  192. package/dist/lib/crypto/keyPairMatch.js +65 -0
  193. package/dist/lib/crypto/keySession.d.ts +64 -3
  194. package/dist/lib/crypto/keySession.js +179 -12
  195. package/dist/lib/crypto/keyvaultApi.d.ts +129 -8
  196. package/dist/lib/crypto/keyvaultApi.js +45 -3
  197. package/dist/lib/crypto/meetingKeys.d.ts +25 -0
  198. package/dist/lib/crypto/meetingKeys.js +87 -0
  199. package/dist/lib/crypto/messageSecurity.d.ts +56 -3
  200. package/dist/lib/crypto/messageSecurity.js +201 -13
  201. package/dist/lib/crypto/mime.d.ts +5 -0
  202. package/dist/lib/crypto/mime.js +25 -0
  203. package/dist/lib/crypto/openPgpKey.d.ts +39 -0
  204. package/dist/lib/crypto/openPgpKey.js +89 -0
  205. package/dist/lib/crypto/openpgp.d.ts +47 -0
  206. package/dist/lib/crypto/openpgp.js +88 -0
  207. package/dist/lib/crypto/pgpMessage.d.ts +51 -0
  208. package/dist/lib/crypto/pgpMessage.js +271 -0
  209. package/dist/lib/crypto/sharedMailboxKeys.d.ts +62 -0
  210. package/dist/lib/crypto/sharedMailboxKeys.js +337 -0
  211. package/dist/lib/crypto/smimeMessage.d.ts +21 -0
  212. package/dist/lib/crypto/smimeMessage.js +11 -7
  213. package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
  214. package/dist/lib/crypto/zeroAccessKey.js +71 -0
  215. package/dist/lib/mail/conversationsApi.d.ts +6 -0
  216. package/dist/lib/mail/mailApi.d.ts +63 -2
  217. package/dist/lib/mail/mailApi.js +5 -1
  218. package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
  219. package/dist/lib/mail/mailboxImportApi.js +15 -0
  220. package/dist/lib/search/zeroAccessIndexApi.d.ts +85 -0
  221. package/dist/lib/search/zeroAccessIndexApi.js +35 -0
  222. package/dist/lib/tasks/tasksApi.d.ts +11 -1
  223. package/dist/lib/tasks/tasksApi.js +10 -4
  224. package/dist/lib/util/clientPlatform.d.ts +14 -0
  225. package/dist/lib/util/clientPlatform.js +26 -0
  226. package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
  227. package/dist/lib/videoconf/protectedMeetings.js +181 -0
  228. package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
  229. package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
  230. package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
  231. package/dist/lib/zeroAccess/sealedMoves.js +63 -0
  232. package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
  233. package/dist/lib/zeroAccess/sealedRecords.js +136 -0
  234. package/lib/admin/domainsApi.ts +1 -1
  235. package/lib/admin/escrowAccessRequestsApi.ts +6 -0
  236. package/lib/calendar/calendarApi.ts +143 -17
  237. package/lib/calendar/calendarMutations.ts +79 -12
  238. package/lib/calendar/inviteApi.ts +101 -8
  239. package/lib/calendar/protectedEvents.ts +143 -0
  240. package/lib/calendar/recurrence.ts +17 -0
  241. package/lib/calendar/sealedInvites.ts +73 -0
  242. package/lib/contacts/contactsApi.ts +43 -17
  243. package/lib/contacts/useContactPhotoSrc.ts +103 -13
  244. package/lib/crypto/composeSecurity.ts +20 -5
  245. package/lib/crypto/keyExport.ts +229 -0
  246. package/lib/crypto/keyPairMatch.ts +69 -0
  247. package/lib/crypto/keySession.ts +216 -12
  248. package/lib/crypto/keyvaultApi.ts +174 -10
  249. package/lib/crypto/meetingKeys.ts +118 -0
  250. package/lib/crypto/messageSecurity.ts +284 -16
  251. package/lib/crypto/mime.ts +26 -0
  252. package/lib/crypto/openPgpKey.ts +118 -0
  253. package/lib/crypto/openpgp.ts +115 -0
  254. package/lib/crypto/pgpMessage.ts +336 -0
  255. package/lib/crypto/sharedMailboxKeys.ts +402 -0
  256. package/lib/crypto/smimeMessage.ts +11 -7
  257. package/lib/crypto/zeroAccessKey.ts +102 -0
  258. package/lib/mail/conversationsApi.ts +3 -0
  259. package/lib/mail/mailApi.ts +60 -3
  260. package/lib/mail/mailboxImportApi.ts +24 -0
  261. package/lib/search/zeroAccessIndexApi.ts +123 -0
  262. package/lib/tasks/tasksApi.ts +19 -5
  263. package/lib/util/clientPlatform.ts +50 -0
  264. package/lib/videoconf/protectedMeetings.ts +221 -0
  265. package/lib/videoconf/videoMeetingsApi.ts +15 -4
  266. package/lib/zeroAccess/sealedMoves.ts +79 -0
  267. package/lib/zeroAccess/sealedRecords.ts +153 -0
  268. package/package.json +14 -5
@@ -0,0 +1,337 @@
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 { certificateHoldsKey } from "./keyPairMatch.js";
21
+ import { exportPrivateKeyPkcs8, generateKeyPairWithCsr, importPrivateKeyPkcs8 } from "./keys.js";
22
+ import { addZeroAccessKey, getUnlockedKeys, installSharedSession, personalSessions, sharedUnlockGeneration, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE, } from "./keySession.js";
23
+ import { buildAad, openWithKey } from "./masterKey.js";
24
+ import { decryptEnvelopedDataWithKeys, encryptForRecipients } from "./smime.js";
25
+ import { provisionZeroAccessKey } from "./zeroAccessKey.js";
26
+ /** Domain-separation prefix of the secret a shared session's `masterKey` stands for. */
27
+ const SESSION_SECRET_LABEL = new TextEncoder().encode("rapidmx-shared-mailbox-session-v1:");
28
+ async function sessionSecret(pkcs8) {
29
+ const input = new Uint8Array(SESSION_SECRET_LABEL.length + pkcs8.length);
30
+ input.set(SESSION_SECRET_LABEL);
31
+ input.set(pkcs8, SESSION_SECRET_LABEL.length);
32
+ try {
33
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", input));
34
+ }
35
+ finally {
36
+ input.fill(0);
37
+ }
38
+ }
39
+ /** The zero-access keys of the mailboxes this user has unlocked themselves - what their member wraps are addressed to. */
40
+ function ownZeroAccessKeys() {
41
+ return personalSessions().flatMap(({ mailboxUid, unlocked }) => (unlocked.zeroAccessKeys ?? []).map((key) => ({ ...key, mailboxUid })));
42
+ }
43
+ /** 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`
44
+ * when no own key opens it. */
45
+ async function openWrap(wrap, own) {
46
+ const recipient = own.find((key) => key.fingerprint === wrap.recipientFingerprint);
47
+ if (!recipient) {
48
+ return undefined;
49
+ }
50
+ try {
51
+ return { pkcs8: await decryptEnvelopedDataWithKeys(fromBase64(wrap.cms), [recipient]), openedWith: recipient.mailboxUid };
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ }
57
+ /** The active key first, then the others newest first - the order `UnlockedKeys.zeroAccessKeys` keeps. */
58
+ function keyOrder(status) {
59
+ const rank = new Map([...status.zeroAccessKeys]
60
+ .sort((a, b) => Number(a.fingerprint !== status.activeKeyFingerprint) - Number(b.fingerprint !== status.activeKeyFingerprint) || b.notBefore - a.notBefore)
61
+ .map((key, index) => [key.fingerprint, index]));
62
+ return (fingerprint) => rank.get(fingerprint) ?? Number.MAX_SAFE_INTEGER;
63
+ }
64
+ /**
65
+ * Opens shared mailbox `mailboxUid` for its member, with no password: their member wraps, opened with the zero-access keys of a mailbox they
66
+ * own and have unlocked, become this session's keys for it (`installSharedSession()`). See `SharedUnlockOutcome` for when it can't.
67
+ *
68
+ * `refresh`: a shared session already open is opened again when it lacks the mailbox's active key and the member now holds a wrap of it -
69
+ * after a rotation, new mail is sealed to a key the session doesn't have. Its `masterKey` secret is kept, so the local index stays readable.
70
+ */
71
+ export async function unlockSharedMailbox(mailboxUid, client, refresh = false) {
72
+ const existing = getUnlockedKeys(mailboxUid);
73
+ if (existing && !(refresh && existing.shared)) {
74
+ return "unlocked";
75
+ }
76
+ const generation = sharedUnlockGeneration(mailboxUid);
77
+ let status;
78
+ try {
79
+ status = await getSharedKeyStatus(mailboxUid, client);
80
+ }
81
+ catch (err) {
82
+ if (err instanceof ApiRequestError && (err.status === 403 || err.status === 404)) {
83
+ return existing ? "unlocked" : "not_shared";
84
+ }
85
+ throw err;
86
+ }
87
+ const holdsActive = (keys) => !!keys?.some((key) => key.fingerprint === status.activeKeyFingerprint);
88
+ if (existing && (!status.activeKeyFingerprint || holdsActive(existing.zeroAccessKeys))) {
89
+ return "unlocked";
90
+ }
91
+ if (status.role !== "member") {
92
+ return existing ? "unlocked" : "not_shared";
93
+ }
94
+ const wraps = await getMyMemberKeyWraps(mailboxUid, client);
95
+ // Nothing new to open yet: the active key's wrap is still on its way.
96
+ if (existing && !holdsActive(wraps.map((wrap) => ({ fingerprint: wrap.zeroAccessKeyFingerprint })))) {
97
+ return "unlocked";
98
+ }
99
+ if (wraps.length === 0) {
100
+ return "pending";
101
+ }
102
+ const own = ownZeroAccessKeys();
103
+ const rank = keyOrder(status);
104
+ const opened = [];
105
+ const openedWith = new Set();
106
+ let secret;
107
+ for (const wrap of [...wraps].sort((a, b) => rank(a.zeroAccessKeyFingerprint) - rank(b.zeroAccessKeyFingerprint))) {
108
+ const certificate = status.zeroAccessKeys.find((key) => key.fingerprint === wrap.zeroAccessKeyFingerprint);
109
+ const unwrapped = certificate ? await openWrap(wrap, own) : undefined;
110
+ if (!certificate || !unwrapped) {
111
+ continue;
112
+ }
113
+ const pkcs8 = unwrapped.pkcs8;
114
+ try {
115
+ // The certificate is the server's: one that isn't this key's must never be what this session seals to.
116
+ if (!(await certificateHoldsKey(fromBase64(certificate.publicKey), pkcs8))) {
117
+ continue;
118
+ }
119
+ secret ?? (secret = await sessionSecret(pkcs8));
120
+ const privateKey = await importPrivateKeyPkcs8(pkcs8, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"]);
121
+ opened.push({ fingerprint: wrap.zeroAccessKeyFingerprint, certDer: fromBase64(certificate.publicKey), privateKey });
122
+ openedWith.add(unwrapped.openedWith);
123
+ }
124
+ catch {
125
+ // A wrap that doesn't hold a usable key (or one its certificate doesn't match) is skipped; the others still open the mailbox.
126
+ }
127
+ finally {
128
+ pkcs8.fill(0);
129
+ }
130
+ }
131
+ if (!secret || opened.length === 0) {
132
+ return existing ? "unlocked" : "own_keys_locked";
133
+ }
134
+ if (existing) {
135
+ // Opened again: the secret the local index key is derived from stays the one the session started with.
136
+ secret.fill(0);
137
+ secret = new Uint8Array(existing.masterKey);
138
+ }
139
+ const unlocked = { masterKey: secret, zeroAccessKeys: opened, openedWith: [...openedWith] };
140
+ installSharedSession(mailboxUid, unlocked, generation);
141
+ return "unlocked";
142
+ }
143
+ /** 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
144
+ * key, or from the caller's own member wraps. Only keys whose certificate in `status` (the server's) is really theirs: a key is wrapped
145
+ * for members and escrow under that certificate's fingerprint, so a mismatched one is left out. The caller zeroes them. */
146
+ async function openPrivateKeys(mailboxUid, status, ownerUnlocked, client) {
147
+ const keys = new Map();
148
+ const keep = async (fingerprint, pkcs8) => {
149
+ const certificate = status.zeroAccessKeys.find((key) => key.fingerprint === fingerprint);
150
+ if (certificate && (await certificateHoldsKey(fromBase64(certificate.publicKey), pkcs8))) {
151
+ keys.set(fingerprint, pkcs8);
152
+ }
153
+ else {
154
+ pkcs8.fill(0);
155
+ }
156
+ };
157
+ if (ownerUnlocked) {
158
+ const vault = await getKeyVault(mailboxUid, client);
159
+ const aad = buildAad(mailboxUid, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE);
160
+ for (const key of status.zeroAccessKeys) {
161
+ const wrapped = vault.wrappedKeys.find((w) => w.fingerprint === key.fingerprint && w.useType === "zero-access");
162
+ if (wrapped) {
163
+ try {
164
+ await keep(key.fingerprint, await openWithKey(ownerUnlocked.masterKey, wrapped, aad));
165
+ }
166
+ catch {
167
+ // Sealed under a master key since replaced: not this device's to share.
168
+ }
169
+ }
170
+ }
171
+ return keys;
172
+ }
173
+ const own = ownZeroAccessKeys();
174
+ for (const wrap of await getMyMemberKeyWraps(mailboxUid, client)) {
175
+ const unwrapped = await openWrap(wrap, own);
176
+ if (unwrapped) {
177
+ await keep(wrap.zeroAccessKeyFingerprint, unwrapped.pkcs8);
178
+ }
179
+ }
180
+ return keys;
181
+ }
182
+ async function wrapFor(member, pkcs8) {
183
+ return {
184
+ memberUserUid: member.userUid,
185
+ recipientFingerprint: member.certificate.fingerprint,
186
+ cms: toBase64(await encryptForRecipients(pkcs8, [fromBase64(member.certificate.publicKey)])),
187
+ };
188
+ }
189
+ /** The members with a certificate of their own - the ones a key can be wrapped for. A certificate listed under the fingerprint of one of
190
+ * this user's own opened zero-access keys must be that key's certificate (checked against its private key at unlock): one the server
191
+ * swapped is left out, so a wrap meant for this user never goes to a key that isn't theirs. */
192
+ function wrappable(status) {
193
+ const own = ownZeroAccessKeys();
194
+ return status.members.filter((member) => {
195
+ if (!member.certificate) {
196
+ return false;
197
+ }
198
+ const mine = own.find((key) => key.fingerprint === member.certificate.fingerprint);
199
+ return !mine || sameBytes(mine.certDer, fromBase64(member.certificate.publicKey));
200
+ });
201
+ }
202
+ function sameBytes(a, b) {
203
+ return a.length === b.length && a.every((byte, index) => byte === b[index]);
204
+ }
205
+ /** `pkcs8` wrapped to the certificate of the escrow scope `escrow` describes. */
206
+ async function escrowWrapFor(escrow, pkcs8) {
207
+ return { escrowScopeId: escrow.escrowScopeId, cms: toBase64(await encryptForRecipients(pkcs8, [fromBase64(escrow.publicKey.publicKey)])) };
208
+ }
209
+ /** 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
210
+ * escrow scope when it has one. */
211
+ async function enrollNewSharedKey(mailboxUid, mailboxAddress, status, client) {
212
+ const escrow = status.escrowScopeId ? await getEscrowInfo(mailboxUid, client) : undefined;
213
+ const { keyPair, csrPem } = await generateKeyPairWithCsr(mailboxAddress, "encrypt");
214
+ const pkcs8 = await exportPrivateKeyPkcs8(keyPair.privateKey);
215
+ try {
216
+ const wraps = await Promise.all(wrappable(status).map((member) => wrapFor(member, pkcs8)));
217
+ const escrowWrap = escrow ? await escrowWrapFor(escrow, pkcs8) : undefined;
218
+ return await enrollSharedZeroAccessKey(mailboxUid, { csr: csrPem, wraps, ...(escrowWrap ? { escrowWrap } : {}) }, client);
219
+ }
220
+ finally {
221
+ pkcs8.fill(0);
222
+ }
223
+ }
224
+ /**
225
+ * 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
226
+ * anyone else, or while the keys it needs are locked:
227
+ *
228
+ * A mailbox without an owner and without a key gets one (unless the administrator disallows zero-access), wrapped for every member who has a
229
+ * certificate. Once a user who held the active key has left (`rotationPending`), a new key replaces it - enrolled by the owner with their
230
+ * master key (`provisionZeroAccessKey()`), or for a mailbox without an owner with the members' wraps - and then the departed users' wraps
231
+ * are removed. Members still waiting (`pending`) get wraps of every key this client can open, one write per key with the active key's
232
+ * first (a refused older key doesn't stop the rest), and the escrow scope of a mailbox without an owner gets copies of the keys it is
233
+ * missing (`escrowPending`). A rotation the server refuses (400) is reported in `rotationRefused`, not thrown: the current key stays active
234
+ * and the rest of the pass goes ahead.
235
+ */
236
+ export async function maintainSharedMailbox(mailboxUid, mailboxAddress, client) {
237
+ const result = { provisioned: false, rotated: false, wrapped: 0, refused: 0, removed: 0, escrowed: 0 };
238
+ let status = await getSharedKeyStatus(mailboxUid, client);
239
+ if (!status.manager || (status.members.length === 0 && status.departed.length === 0)) {
240
+ return result;
241
+ }
242
+ const ownerUnlocked = status.role === "owner" ? getUnlockedKeys(mailboxUid) : undefined;
243
+ if (status.role === "owner" && (!ownerUnlocked || ownerUnlocked.shared)) {
244
+ return result;
245
+ }
246
+ if (status.ownerless && !status.activeKeyFingerprint) {
247
+ const policy = await getEncryptionPolicy(client).catch(() => undefined);
248
+ if (policy?.allowZeroAccess === false || ownZeroAccessKeys().length === 0) {
249
+ return result;
250
+ }
251
+ status = await enrollNewSharedKey(mailboxUid, mailboxAddress, status, client);
252
+ result.provisioned = true;
253
+ }
254
+ else if (status.rotationPending) {
255
+ try {
256
+ if (status.ownerless) {
257
+ status = await enrollNewSharedKey(mailboxUid, mailboxAddress, status, client);
258
+ }
259
+ else {
260
+ const vault = await getKeyVault(mailboxUid, client);
261
+ const { key } = await provisionZeroAccessKey(mailboxUid, mailboxAddress, ownerUnlocked.masterKey, vault.masterKeyGeneration, client);
262
+ // The owner's open session gets the new key as its active one, as `ensureZeroAccessKey()` does: new mail is sealed to it.
263
+ addZeroAccessKey(mailboxUid, ownerUnlocked, key);
264
+ status = await getSharedKeyStatus(mailboxUid, client);
265
+ }
266
+ result.rotated = true;
267
+ }
268
+ catch (err) {
269
+ // Refused before any key was issued (restapi: the vault can't hold the new key's member wraps next to those it holds): the
270
+ // current key stays active, so the rest of the pass - waiting members' wraps of it - still goes ahead. Anything else throws.
271
+ if (!(err instanceof ApiRequestError) || err.status !== 400) {
272
+ throw err;
273
+ }
274
+ result.rotationRefused = err.message;
275
+ }
276
+ }
277
+ // The departed hold no wrap of the active key any more (or never did): their old wraps go.
278
+ if (!status.rotationPending) {
279
+ for (const departed of status.departed) {
280
+ status = await removeMemberKeyWraps(mailboxUid, departed, client);
281
+ result.removed++;
282
+ }
283
+ }
284
+ const missing = wrappable(status).filter((member) => status.zeroAccessKeys.some((key) => !member.wrapped.includes(key.fingerprint)));
285
+ const escrowPending = status.ownerless && status.escrowScopeId ? (status.escrowPending ?? []) : [];
286
+ if (missing.length > 0 || escrowPending.length > 0) {
287
+ const keys = await openPrivateKeys(mailboxUid, status, ownerUnlocked, client);
288
+ try {
289
+ // One write per key, the active key's first: it alone opens new mail, so a refused backfill of an older key (the server bounds
290
+ // each key's wraps and the vault's total) never keeps a member from it. A failed older key is left for the next pass.
291
+ const order = keyOrder(status);
292
+ for (const [fingerprint, pkcs8] of [...keys].sort(([a], [b]) => order(a) - order(b))) {
293
+ const wraps = [];
294
+ for (const member of missing) {
295
+ if (!member.wrapped.includes(fingerprint)) {
296
+ wraps.push({ ...(await wrapFor(member, pkcs8)), zeroAccessKeyFingerprint: fingerprint });
297
+ }
298
+ }
299
+ if (wraps.length === 0) {
300
+ continue;
301
+ }
302
+ if (fingerprint === status.activeKeyFingerprint) {
303
+ await putMemberKeyWraps(mailboxUid, wraps, client);
304
+ result.wrapped += wraps.length;
305
+ }
306
+ else {
307
+ try {
308
+ await putMemberKeyWraps(mailboxUid, wraps, client);
309
+ result.wrapped += wraps.length;
310
+ }
311
+ catch {
312
+ result.refused += wraps.length;
313
+ }
314
+ }
315
+ }
316
+ const held = escrowPending.filter((fingerprint) => keys.has(fingerprint));
317
+ if (held.length > 0) {
318
+ const escrow = await getEscrowInfo(mailboxUid, client);
319
+ // Assigned to another scope since the status was read: the next pass sees it.
320
+ if (escrow.escrowScopeId === status.escrowScopeId) {
321
+ const escrowWraps = [];
322
+ for (const fingerprint of held) {
323
+ escrowWraps.push({ ...(await escrowWrapFor(escrow, keys.get(fingerprint))), zeroAccessKeyFingerprint: fingerprint });
324
+ }
325
+ await putZeroAccessEscrowWraps(mailboxUid, escrowWraps, client);
326
+ result.escrowed = escrowWraps.length;
327
+ }
328
+ }
329
+ }
330
+ finally {
331
+ for (const pkcs8 of keys.values()) {
332
+ pkcs8.fill(0);
333
+ }
334
+ }
335
+ }
336
+ return result;
337
+ }
@@ -13,6 +13,14 @@ export interface ProtectedHeaders {
13
13
  * (Section 3.3). Applied to produce the OUTER, unprotected envelope's headers from the real
14
14
  * (protected) ones; `From`/`To`/`Cc`/`Date` pass through unchanged, `Subject` is obscured. */
15
15
  export declare function applyBaselineOuterHeaders(headers: ProtectedHeaders): ProtectedHeaders;
16
+ /** @internal Shared with `pgpMessage.ts`. */
17
+ export declare function base64Wrapped(bytes: Uint8Array): string;
18
+ /** @internal Shared with `pgpMessage.ts`. */
19
+ export declare function generateBoundary(): string;
20
+ /** Strips CR/LF/NUL from a header value that has no further encoding (Date, Message-ID, Content-Type). */
21
+ export declare function flattenHeaderValue(value: string): string;
22
+ /** @internal Shared with `pgpMessage.ts`: the protected header block of an inner entity (with `HP-Outer:` copies of `hpOuter`). */
23
+ export declare function protectedHeaderLines(headers: ProtectedHeaders, hpOuter?: ProtectedHeaders): string;
16
24
  /** One MIME entity's worth of headers-beyond-Content-Type plus body, ready for a caller (the actual
17
25
  * outbound-message compose step, built on top of this module) to combine with the outer envelope's
18
26
  * own `From`/`To`/`Subject`/`Date`/`Message-ID`. */
@@ -106,6 +114,17 @@ export interface ParsedEncryptedMessage {
106
114
  * (`messageId` has no `HP-Outer` counterpart; only the fields the outer envelope itself carries do).
107
115
  * Exported so `messageSecurity.ts` can build one from a received message's real outer envelope. */
108
116
  export type ComparableOuterHeaders = Partial<Pick<ProtectedHeaders, "from" | "to" | "cc" | "date" | "subject">>;
117
+ /** Extracts the outer-envelope header values RFC 9788's `HP-Outer: <Field>: <value>` mechanism embeds
118
+ * inside an encrypted message's protected content - one line per outer header, all sharing the literal
119
+ * field name `HP-Outer`, which `splitHeadersAndBody()`'s own flat header map can hold only the last of
120
+ * (a real bug if reused for this - JSON/`Record` keys aren't multi-valued). Scans the raw header block
121
+ * text directly instead. Returns `undefined` when no `HP-Outer:` lines are present at all, so a caller
122
+ * can distinguish "nothing to compare" from "compared and everything matched". */
123
+ export declare function extractHpOuterHeaders(fields: MimeHeaderField[]): ComparableOuterHeaders | undefined;
124
+ /** `true` when every field `hpOuter` actually carries matches `actualOuterHeaders`' value for that same
125
+ * field - a field `hpOuter` doesn't carry (e.g. no `Cc` on this message) is not compared, since RFC
126
+ * 9788 never claims coverage for it either way. */
127
+ export declare function outerHeadersMatch(hpOuter: ComparableOuterHeaders, actualOuterHeaders: ComparableOuterHeaders): boolean;
109
128
  /** Decrypts (and, if present, verifies the inner signature of) a message built by
110
129
  * `buildEncryptedMessage()`. Returns `{ decrypted: false }` (never throws) for anything malformed or
111
130
  * undecryptable with the given key — the caller's own concern is distinguishing "wrong key" from
@@ -130,3 +149,5 @@ export declare function splitHeadersAndBody(entity: string): {
130
149
  body: string;
131
150
  rawHeaderBlock: string;
132
151
  };
152
+ /** @internal Shared with `pgpMessage.ts`. */
153
+ export declare function headersToProtectedHeaders(headers: Record<string, string>): ProtectedHeaders;
@@ -38,7 +38,8 @@ const BASE64_LINE_WIDTH = 76;
38
38
  export function applyBaselineOuterHeaders(headers) {
39
39
  return { ...headers, subject: "[...]" };
40
40
  }
41
- function base64Wrapped(bytes) {
41
+ /** @internal Shared with `pgpMessage.ts`. */
42
+ export function base64Wrapped(bytes) {
42
43
  const encoded = toBase64(bytes);
43
44
  const lines = [];
44
45
  for (let i = 0; i < encoded.length; i += BASE64_LINE_WIDTH) {
@@ -46,14 +47,15 @@ function base64Wrapped(bytes) {
46
47
  }
47
48
  return lines.join(CRLF);
48
49
  }
49
- function generateBoundary() {
50
+ /** @internal Shared with `pgpMessage.ts`. */
51
+ export function generateBoundary() {
50
52
  const bytes = crypto.getRandomValues(new Uint8Array(16));
51
53
  return `----=_RapidMX_${Array.from(bytes)
52
54
  .map((b) => b.toString(16).padStart(2, "0"))
53
55
  .join("")}`;
54
56
  }
55
57
  /** Strips CR/LF/NUL from a header value that has no further encoding (Date, Message-ID, Content-Type). */
56
- function flattenHeaderValue(value) {
58
+ export function flattenHeaderValue(value) {
57
59
  return value.replace(/[\r\n\0]+/g, " ");
58
60
  }
59
61
  /**
@@ -73,7 +75,8 @@ function headerLines(headers, prefix = "") {
73
75
  ...(prefix ? [] : [`Message-ID: ${flattenHeaderValue(headers.messageId)}`]),
74
76
  ];
75
77
  }
76
- function protectedHeaderLines(headers, hpOuter) {
78
+ /** @internal Shared with `pgpMessage.ts`: the protected header block of an inner entity (with `HP-Outer:` copies of `hpOuter`). */
79
+ export function protectedHeaderLines(headers, hpOuter) {
77
80
  const lines = headerLines(headers);
78
81
  if (hpOuter) {
79
82
  // RFC 9788 Section 2.2.1's `hp-outer` field: the literal string "HP-Outer:" followed by the
@@ -237,7 +240,7 @@ export function assembleOutboundMime(outerHeaders, part) {
237
240
  * (a real bug if reused for this - JSON/`Record` keys aren't multi-valued). Scans the raw header block
238
241
  * text directly instead. Returns `undefined` when no `HP-Outer:` lines are present at all, so a caller
239
242
  * can distinguish "nothing to compare" from "compared and everything matched". */
240
- function extractHpOuterHeaders(fields) {
243
+ export function extractHpOuterHeaders(fields) {
241
244
  const result = {};
242
245
  let found = false;
243
246
  for (const { name, value } of fields) {
@@ -253,7 +256,7 @@ function extractHpOuterHeaders(fields) {
253
256
  /** `true` when every field `hpOuter` actually carries matches `actualOuterHeaders`' value for that same
254
257
  * field - a field `hpOuter` doesn't carry (e.g. no `Cc` on this message) is not compared, since RFC
255
258
  * 9788 never claims coverage for it either way. */
256
- function outerHeadersMatch(hpOuter, actualOuterHeaders) {
259
+ export function outerHeadersMatch(hpOuter, actualOuterHeaders) {
257
260
  return Object.keys(hpOuter).every((field) => hpOuter[field] === actualOuterHeaders[field]);
258
261
  }
259
262
  /** Decrypts (and, if present, verifies the inner signature of) a message built by
@@ -327,7 +330,8 @@ export function splitHeadersAndBody(entity) {
327
330
  const { headers, body, rawHeaderBlock } = parseMimeEntity(entity);
328
331
  return { headers, contentType: headers["content-type"], body, rawHeaderBlock };
329
332
  }
330
- function headersToProtectedHeaders(headers) {
333
+ /** @internal Shared with `pgpMessage.ts`. */
334
+ export function headersToProtectedHeaders(headers) {
331
335
  return {
332
336
  from: headers["from"] ?? "",
333
337
  to: headers["to"] ?? "",
@@ -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,71 @@
1
+ import { fromBase64 } from "./encoding.js";
2
+ import { enrollKey, getKeyVault } from "./keyvaultApi.js";
3
+ import { certificateHoldsKey } from "./keyPairMatch.js";
4
+ import { exportPrivateKeyPkcs8, generateKeyPairWithCsr, importPrivateKeyPkcs8 } from "./keys.js";
5
+ import { addZeroAccessKey, getUnlockedKeys, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE } from "./keySession.js";
6
+ import { buildAad, sealWithKey } from "./masterKey.js";
7
+ /**
8
+ * Generates a zero-access keypair for `mailboxUid`, seals its private key under `masterKey` and enrolls it. The key becomes
9
+ * the mailbox's active zero-access key (restapi supersedes any earlier one, whose private key stays in the vault). Pass the
10
+ * vault's `masterKeyGeneration` read alongside `masterKey` as `expectedMasterKeyGeneration`, so restapi refuses (`409`)
11
+ * the key if another device rotated the master key meanwhile.
12
+ */
13
+ export async function provisionZeroAccessKey(mailboxUid, mailboxAddress, masterKey, expectedMasterKeyGeneration, client) {
14
+ const { keyPair, csrPem } = await generateKeyPairWithCsr(mailboxAddress, "encrypt");
15
+ const raw = await exportPrivateKeyPkcs8(keyPair.privateKey);
16
+ try {
17
+ const sealed = await sealWithKey(masterKey, raw, buildAad(mailboxUid, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE));
18
+ const vault = await enrollKey(mailboxUid, {
19
+ useType: "zero-access",
20
+ csr: csrPem,
21
+ wrappedKey: { ciphertext: sealed.ciphertext, nonce: sealed.nonce, algorithm: "AES-256-GCM" },
22
+ ...(expectedMasterKeyGeneration !== undefined ? { expectedMasterKeyGeneration } : {}),
23
+ }, client);
24
+ // restapi appends the new wrapped key last.
25
+ const enrolled = [...vault.wrappedKeys].reverse().find((k) => k.useType === "zero-access");
26
+ const certificate = enrolled && vault.zeroAccessKeys?.find((k) => k.fingerprint === enrolled.fingerprint);
27
+ if (!enrolled || !certificate) {
28
+ throw new Error("The server did not return the enrolled zero-access key.");
29
+ }
30
+ // The certificate is the server's: this device seals to it (index segments, protected meetings' keys), so it must be the new key's.
31
+ if (!(await certificateHoldsKey(fromBase64(certificate.publicKey), raw))) {
32
+ throw new Error("The server returned a zero-access certificate that isn't the enrolled key's.");
33
+ }
34
+ const privateKey = await importPrivateKeyPkcs8(raw, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
35
+ return { vault, key: { fingerprint: enrolled.fingerprint, certDer: fromBase64(certificate.publicKey), privateKey } };
36
+ }
37
+ finally {
38
+ raw.fill(0);
39
+ }
40
+ }
41
+ /** Provisions already in flight, per mailbox - a second caller (another effect, a re-render) joins the first. */
42
+ const inFlight = new Map();
43
+ /**
44
+ * Gives an unlocked mailbox a zero-access key when it has none yet - how a mailbox set up before zero-access encryption
45
+ * existed gets one, on its owner's next unlock. Resolves `true` when it enrolled a key (and added it to the session), `false`
46
+ * when there was nothing to do: the mailbox isn't unlocked, or its vault already holds an unrevoked zero-access key.
47
+ *
48
+ * Only the mailbox's owner may call this (restapi refuses anybody else's enrollment); a caller decides that, e.g. from
49
+ * `KeyEnrollmentGate`'s `canProvision`.
50
+ */
51
+ export function ensureZeroAccessKey(mailboxUid, mailboxAddress, client) {
52
+ const pending = inFlight.get(mailboxUid);
53
+ if (pending) {
54
+ return pending;
55
+ }
56
+ const run = (async () => {
57
+ const unlocked = getUnlockedKeys(mailboxUid);
58
+ if (!unlocked || unlocked.zeroAccessKeys?.length) {
59
+ return false;
60
+ }
61
+ const vault = await getKeyVault(mailboxUid, client);
62
+ if ((vault.zeroAccessKeys ?? []).some((k) => !k.revokedAt) || vault.wrappedKeys.length === 0) {
63
+ return false;
64
+ }
65
+ const { key } = await provisionZeroAccessKey(mailboxUid, mailboxAddress, unlocked.masterKey, vault.masterKeyGeneration, client);
66
+ addZeroAccessKey(mailboxUid, unlocked, key);
67
+ return true;
68
+ })().finally(() => inFlight.delete(mailboxUid));
69
+ inFlight.set(mailboxUid, run);
70
+ return run;
71
+ }
@@ -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"`,