@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,229 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * "Export keys for Outlook/phone" (restapi `specs/search.md` §3, *Clients that don't run RapidMX*): a passphrase-protected
7
+ * PKCS#12 file holding the private keys a mail client needs to read this mailbox's mail on its own - every zero-access key
8
+ * (mail at rest is S/MIME `enveloped-data` to these) and every end-to-end encryption key (current and retained), plus, only
9
+ * when asked, the active signing key. Built entirely here from a fresh master-password unlock: the session's own private
10
+ * keys are non-extractable, and the server never sees the file or the passphrase. restapi only records that an export
11
+ * happened (`key_vault.export`), with the fingerprints exported and nothing else.
12
+ */
13
+ import "reflect-metadata";
14
+ import * as asn1js from "asn1js";
15
+ import * as pkijs from "pkijs";
16
+ import type { ApiClient } from "../util/api.js";
17
+ import { fromBase64 } from "./encoding.js";
18
+ import { type KeyVault, type PublicKey, findActivePublicKey, isOpenPgpKey, recordKeyExport, type WrappedPrivateKey } from "./keyvaultApi.js";
19
+ import {
20
+ ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE,
21
+ MAX_RETAINED_ENCRYPTION_KEYS,
22
+ openMasterKeyWithPassword,
23
+ SIGNING_PRIVATE_KEY_AAD_PURPOSE,
24
+ ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE,
25
+ } from "./keySession.js";
26
+ import { buildAad, openWithKey } from "./masterKey.js";
27
+
28
+ /** PBKDF2 iterations for the shrouded key bags and the PKCS#12 MAC. Windows, macOS and iOS all accept this. */
29
+ export const KEY_EXPORT_ITERATIONS = 100_000;
30
+
31
+ /** The shortest export passphrase accepted. The file grants read access to all of this mailbox's mail. */
32
+ export const MIN_EXPORT_PASSPHRASE_LENGTH = 12;
33
+
34
+ const PKCS8_SHROUDED_KEY_BAG = "1.2.840.113549.1.12.10.1.2";
35
+ const CERT_BAG = "1.2.840.113549.1.12.10.1.3";
36
+ const X509_CERTIFICATE = "1.2.840.113549.1.9.22.1";
37
+ const FRIENDLY_NAME = "1.2.840.113549.1.9.20";
38
+ const LOCAL_KEY_ID = "1.2.840.113549.1.9.21";
39
+
40
+ /** The shrouded key bags' cipher. pkijs's type wants an `iv`, but it generates a fresh one for every bag and ignores this one. */
41
+ const AES_256_CBC = { name: "AES-CBC", length: 256, iv: new Uint8Array(16) } as pkijs.ContentEncryptionAlgorithm;
42
+
43
+ /** Which key a bag holds, for its friendly name and the export's audit record. */
44
+ export type ExportedKeyKind = "zero-access" | "encrypt" | "sign";
45
+
46
+ /** What `exportKeysAsPkcs12()` takes. */
47
+ export interface KeyExportInput {
48
+ mailboxUid: string;
49
+ /** The mailbox's address, used in each key's friendly name. */
50
+ mailboxAddress: string;
51
+ /** The mailbox's published keys (`Mailbox.keys`): the encryption and signing certificates. */
52
+ mailboxKeys: PublicKey[];
53
+ /** The master password, for the fresh unlock every export needs. */
54
+ masterPassword: string;
55
+ /** Protects the PKCS#12 file. At least `MIN_EXPORT_PASSPHRASE_LENGTH` characters. */
56
+ exportPassphrase: string;
57
+ /** Whether to include the active signing key, for clients that will sign. Off unless the user opts in. */
58
+ includeSigningKey?: boolean;
59
+ }
60
+
61
+ /** One key in the export. */
62
+ export interface ExportedKey {
63
+ kind: ExportedKeyKind;
64
+ fingerprint: string;
65
+ }
66
+
67
+ /** What `exportKeysAsPkcs12()` resolves with. */
68
+ export interface KeyExport {
69
+ /** The DER-encoded PKCS#12 (`.p12`) file. */
70
+ pkcs12: Uint8Array;
71
+ /** The keys it holds, zero-access keys first. */
72
+ keys: ExportedKey[];
73
+ }
74
+
75
+ /** One private key opened from the vault, before it goes into a bag. Its `pkcs8` is zeroed once the file is built. */
76
+ interface OpenedKey extends ExportedKey {
77
+ certDer: Uint8Array;
78
+ pkcs8: Uint8Array;
79
+ }
80
+
81
+ /** The export has nothing to hold - the vault has no zero-access or encryption private key this password opens. */
82
+ export class NothingToExportError extends Error {
83
+ constructor() {
84
+ super("This mailbox has no keys to export yet.");
85
+ this.name = "NothingToExportError";
86
+ }
87
+ }
88
+
89
+ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
90
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
91
+ }
92
+
93
+ function hexBytes(hex: string): Uint8Array {
94
+ const out = new Uint8Array(hex.length >> 1);
95
+ for (let i = 0; i < out.length; i++) {
96
+ out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
97
+ }
98
+ return out;
99
+ }
100
+
101
+ function bagAttributes(friendlyName: string, localKeyId: Uint8Array): pkijs.Attribute[] {
102
+ return [
103
+ new pkijs.Attribute({ type: FRIENDLY_NAME, values: [new asn1js.BmpString({ value: friendlyName })] }),
104
+ new pkijs.Attribute({ type: LOCAL_KEY_ID, values: [new asn1js.OctetString({ valueHex: toArrayBuffer(localKeyId) })] }),
105
+ ];
106
+ }
107
+
108
+ function friendlyName(kind: ExportedKeyKind, address: string, index: number): string {
109
+ const label = kind === "zero-access" ? "zero-access key" : kind === "encrypt" ? "encryption key" : "signing key";
110
+ return `RapidMX ${label} (${address})${index > 0 ? ` ${index + 1}` : ""}`;
111
+ }
112
+
113
+ /** Opens one wrapped private key to its raw PKCS#8, or `undefined` when it won't open under this master key - an export
114
+ * carries what it can rather than failing on one stale wrap. */
115
+ async function openPkcs8(masterKey: Uint8Array, wrapped: WrappedPrivateKey, aad: Uint8Array): Promise<Uint8Array | undefined> {
116
+ try {
117
+ return await openWithKey(masterKey, wrapped, aad);
118
+ } catch {
119
+ return undefined;
120
+ }
121
+ }
122
+
123
+ /** Opens every key the export holds, in order: zero-access keys (unrevoked newest first), the encryption keys (active
124
+ * first, then retained newest first), then the active signing key if asked. Each list is capped like the session's. */
125
+ async function openExportedKeys(input: KeyExportInput, vault: KeyVault, masterKey: Uint8Array): Promise<OpenedKey[]> {
126
+ const opened: OpenedKey[] = [];
127
+ const wrappedFor = (fingerprint: string) => vault.wrappedKeys.find((k) => k.fingerprint === fingerprint);
128
+ const take = async (kind: ExportedKeyKind, keys: { fingerprint: string; publicKey: string }[], purpose: string, max: number) => {
129
+ const aad = buildAad(input.mailboxUid, purpose);
130
+ const seen = new Set<string>();
131
+ for (const key of keys) {
132
+ const wrapped = wrappedFor(key.fingerprint);
133
+ if (!wrapped || seen.has(key.fingerprint) || seen.size === max) {
134
+ continue;
135
+ }
136
+ const pkcs8 = await openPkcs8(masterKey, wrapped, aad);
137
+ if (pkcs8) {
138
+ seen.add(key.fingerprint);
139
+ opened.push({ kind, fingerprint: key.fingerprint, certDer: fromBase64(key.publicKey), pkcs8 });
140
+ }
141
+ }
142
+ };
143
+
144
+ const zeroAccess = [...(vault.zeroAccessKeys ?? [])].sort((a, b) => Number(!!a.revokedAt) - Number(!!b.revokedAt) || b.notBefore - a.notBefore);
145
+ await take("zero-access", zeroAccess, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE, MAX_RETAINED_ENCRYPTION_KEYS + 1);
146
+
147
+ const active = findActivePublicKey(input.mailboxKeys, "encrypt");
148
+ const encryption = [
149
+ ...(active ? [active] : []),
150
+ ...input.mailboxKeys.filter((k) => k.useType === "encrypt" && !isOpenPgpKey(k) && k !== active).sort((a, b) => b.notBefore - a.notBefore),
151
+ ];
152
+ await take("encrypt", encryption, ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, MAX_RETAINED_ENCRYPTION_KEYS + 1);
153
+
154
+ if (input.includeSigningKey) {
155
+ const signing = findActivePublicKey(input.mailboxKeys, "sign");
156
+ await take("sign", signing ? [signing] : [], SIGNING_PRIVATE_KEY_AAD_PURPOSE, 1);
157
+ }
158
+ return opened;
159
+ }
160
+
161
+ /** Builds the PKCS#12 file: one PBES2 (PBKDF2-SHA256, AES-256-CBC) shrouded key bag and one certificate bag per key,
162
+ * paired by `localKeyID`, in an unencrypted SafeContents, under a SHA-256 MAC. */
163
+ async function buildPkcs12(keys: OpenedKey[], address: string, passphrase: string): Promise<Uint8Array> {
164
+ const crypto = pkijs.getCrypto(true);
165
+ // UTF-8 for both: pkijs re-encodes it as the BMPString the PKCS#12 MAC derivation (RFC 7292 Appendix B) takes.
166
+ const utf8 = toArrayBuffer(new TextEncoder().encode(passphrase));
167
+ const bags: pkijs.SafeBag[] = [];
168
+ const shrouded: pkijs.PKCS8ShroudedKeyBag[] = [];
169
+ const counts: Record<ExportedKeyKind, number> = { "zero-access": 0, encrypt: 0, sign: 0 };
170
+
171
+ for (const key of keys) {
172
+ const name = friendlyName(key.kind, address, counts[key.kind]++);
173
+ const localKeyId = hexBytes(key.fingerprint).slice(0, 20);
174
+ const keyBag = new pkijs.PKCS8ShroudedKeyBag({ parsedValue: pkijs.PrivateKeyInfo.fromBER(toArrayBuffer(key.pkcs8)) });
175
+ shrouded.push(keyBag);
176
+ bags.push(new pkijs.SafeBag({ bagId: PKCS8_SHROUDED_KEY_BAG, bagValue: keyBag, bagAttributes: bagAttributes(name, localKeyId) }));
177
+ const certBag = new pkijs.CertBag({ certId: X509_CERTIFICATE, parsedValue: pkijs.Certificate.fromBER(toArrayBuffer(key.certDer)) });
178
+ bags.push(new pkijs.SafeBag({ bagId: CERT_BAG, bagValue: certBag, bagAttributes: bagAttributes(name, localKeyId) }));
179
+ }
180
+
181
+ for (const keyBag of shrouded) {
182
+ await keyBag.makeInternalValues(
183
+ { password: utf8, contentEncryptionAlgorithm: AES_256_CBC, hmacHashAlgorithm: "SHA-256", iterationCount: KEY_EXPORT_ITERATIONS },
184
+ crypto,
185
+ );
186
+ }
187
+
188
+ const pfx = new pkijs.PFX({
189
+ parsedValue: {
190
+ integrityMode: 0,
191
+ authenticatedSafe: new pkijs.AuthenticatedSafe({ parsedValue: { safeContents: [{ privacyMode: 0, value: new pkijs.SafeContents({ safeBags: bags }) }] } }),
192
+ },
193
+ });
194
+ await pfx.parsedValue!.authenticatedSafe!.makeInternalValues({ safeContents: [{}] }, crypto);
195
+ await pfx.makeInternalValues(
196
+ { password: utf8, iterations: KEY_EXPORT_ITERATIONS, pbkdf2HashAlgorithm: { name: "SHA-256" }, hmacHashAlgorithm: "SHA-256" },
197
+ crypto,
198
+ );
199
+ return new Uint8Array(pfx.toSchema().toBER(false));
200
+ }
201
+
202
+ /**
203
+ * Builds the "Export keys for Outlook/phone" PKCS#12 for `input.mailboxUid` (see this module's comment) and records the
204
+ * export with restapi. Re-opens the vault with `masterPassword` - rejecting as `unlockWithPassword()` does on a wrong
205
+ * one - and never touches the in-memory session. Every opened private key and the master key are zeroed before this
206
+ * returns, whether or not it succeeds. Throws `NothingToExportError` when no key opens.
207
+ */
208
+ export async function exportKeysAsPkcs12(input: KeyExportInput, client?: ApiClient): Promise<KeyExport> {
209
+ if (input.exportPassphrase.length < MIN_EXPORT_PASSPHRASE_LENGTH) {
210
+ throw new Error(`The export passphrase must be at least ${MIN_EXPORT_PASSPHRASE_LENGTH} characters.`);
211
+ }
212
+ const { vault, masterKey } = await openMasterKeyWithPassword(input.mailboxUid, input.masterPassword, client);
213
+ let opened: OpenedKey[] = [];
214
+ try {
215
+ opened = await openExportedKeys(input, vault, masterKey);
216
+ if (!opened.some((k) => k.kind !== "sign")) {
217
+ throw new NothingToExportError();
218
+ }
219
+ const pkcs12 = await buildPkcs12(opened, input.mailboxAddress, input.exportPassphrase);
220
+ const keys = opened.map(({ kind, fingerprint }) => ({ kind, fingerprint }));
221
+ await recordKeyExport(input.mailboxUid, keys, client);
222
+ return { pkcs12, keys };
223
+ } finally {
224
+ masterKey.fill(0);
225
+ for (const key of opened) {
226
+ key.pkcs8.fill(0);
227
+ }
228
+ }
229
+ }
@@ -0,0 +1,69 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Whether a certificate the server lists is really the one of a private key this device opened. A zero-access key's private half comes
7
+ * from an authenticated seal (or a member's wrap), but its certificate comes from the server as it is: what this device then encrypts
8
+ * to that certificate - the search index segments it builds from end-to-end mail, a protected meeting's key - must not go to a key the
9
+ * server swapped in. Plain WebCrypto and a few lines of DER, so the unlock path loads no certificate library.
10
+ */
11
+
12
+ /** One DER element of `der` at `offset`: where its header starts, where its content starts, where it ends. */
13
+ function element(der: Uint8Array, offset: number): { tag: number; header: number; content: number; end: number } {
14
+ const tag = der[offset];
15
+ let length = der[offset + 1];
16
+ let content = offset + 2;
17
+ if (length & 0x80) {
18
+ const bytes = length & 0x7f;
19
+ if (bytes === 0 || bytes > 4) {
20
+ throw new Error("Unsupported DER length.");
21
+ }
22
+ length = 0;
23
+ for (let i = 0; i < bytes; i++) {
24
+ length = length * 256 + der[content + i];
25
+ }
26
+ content += bytes;
27
+ }
28
+ const end = content + length;
29
+ if (tag === undefined || end > der.length) {
30
+ throw new Error("Truncated DER.");
31
+ }
32
+ return { tag, header: offset, content, end };
33
+ }
34
+
35
+ /** The children of the constructed element `parent`. */
36
+ function children(der: Uint8Array, parent: { content: number; end: number }): ReturnType<typeof element>[] {
37
+ const found: ReturnType<typeof element>[] = [];
38
+ for (let offset = parent.content; offset < parent.end; offset = found[found.length - 1].end) {
39
+ found.push(element(der, offset));
40
+ }
41
+ return found;
42
+ }
43
+
44
+ /** The SubjectPublicKeyInfo of X.509 certificate `certDer` (RFC 5280: the seventh field of `tbsCertificate`, the sixth when it has no
45
+ * explicit version). */
46
+ function subjectPublicKeyInfo(certDer: Uint8Array): Uint8Array {
47
+ const [tbs] = children(certDer, element(certDer, 0));
48
+ const fields = children(certDer, tbs);
49
+ const spki = fields[fields[0].tag === 0xa0 ? 6 : 5];
50
+ if (!spki || spki.tag !== 0x30) {
51
+ throw new Error("No subject public key.");
52
+ }
53
+ return certDer.subarray(spki.header, spki.end);
54
+ }
55
+
56
+ const P256_ECDH: EcKeyImportParams = { name: "ECDH", namedCurve: "P-256" };
57
+
58
+ /** Whether `certDer` (a P-256 certificate) carries the public half of `pkcs8` (a P-256 private key). Never rejects: anything that won't
59
+ * parse or import is `false`. */
60
+ export async function certificateHoldsKey(certDer: Uint8Array, pkcs8: Uint8Array): Promise<boolean> {
61
+ try {
62
+ const publicKey = await crypto.subtle.importKey("spki", subjectPublicKeyInfo(certDer) as BufferSource, P256_ECDH, true, []);
63
+ const privateKey = await crypto.subtle.importKey("pkcs8", pkcs8 as BufferSource, P256_ECDH, true, ["deriveBits"]);
64
+ const [certified, held] = await Promise.all([crypto.subtle.exportKey("jwk", publicKey), crypto.subtle.exportKey("jwk", privateKey)]);
65
+ return !!certified.x && certified.x === held.x && certified.y === held.y;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
@@ -21,12 +21,15 @@
21
21
  * follow-up, not a silent gap: a mailbox enrolled *only* with a passkey has no way to unlock through this
22
22
  * module today.
23
23
  */
24
- import { type KeyVault, type PublicKey, type WrappedPrivateKey, findActivePublicKey, getKeyVault } from "./keyvaultApi.js";
24
+ import { type KeyVault, type PublicKey, type WrappedPrivateKey, findActivePublicKey, getKeyVault, isOpenPgpKey } from "./keyvaultApi.js";
25
+ import type { ApiClient } from "../util/api.js";
25
26
  import { fromBase64 } from "./encoding.js";
27
+ import { certificateHoldsKey } from "./keyPairMatch.js";
26
28
  import { importPrivateKeyPkcs8 } from "./keys.js";
27
29
  import { KeysLockedError, buildAad, openWithKey } from "./masterKey.js";
28
30
  import { deriveFromPassword, parseArgon2idKdfLabel } from "./passwordUnlock.js";
29
31
  import { RECOVERY_KDF_LABEL, deriveFromRecoveryCode } from "./recoveryCode.js";
32
+ import type { OpenPgpPrivateKey } from "./openpgp.js";
30
33
 
31
34
  /** AAD purpose labels — MUST exactly match what `KeyEnrollmentGate.tsx` used when it originally
32
35
  * sealed each of these values, or `openWithKey()` fails (GCM authenticates the AAD, not just the
@@ -34,6 +37,8 @@ import { RECOVERY_KDF_LABEL, deriveFromRecoveryCode } from "./recoveryCode.js";
34
37
  export const MASTER_KEY_AAD_PURPOSE = "master-key";
35
38
  export const SIGNING_PRIVATE_KEY_AAD_PURPOSE = "sign-private-key";
36
39
  export const ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE = "encrypt-private-key";
40
+ export const ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE = "zero-access-private-key";
41
+ export const OPENPGP_PRIVATE_KEY_AAD_PURPOSE = "openpgp-private-key";
37
42
 
38
43
  export { KeysLockedError };
39
44
 
@@ -73,6 +78,21 @@ export interface UnlockedKeys {
73
78
  * that). A compromised key is kept because reading one's own mail encrypted to it is still needed. Absent when there
74
79
  * are none. */
75
80
  retainedEncryptionKeys?: RetainedEncryptionKey[];
81
+ /** The mailbox's zero-access private keys (restapi `specs/search.md` §3) - the keys the server encrypts mail at rest,
82
+ * index entries and sealed summaries to - the active one first, then retained ones newest first, at most
83
+ * `MAX_RETAINED_ENCRYPTION_KEYS + 1`. Decryption only. Absent when the mailbox has none (or none opened). */
84
+ zeroAccessKeys?: RetainedEncryptionKey[];
85
+ /** The mailbox's OpenPGP secret keys (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*) - the active one
86
+ * first, then retained ones newest first, at most `MAX_RETAINED_ENCRYPTION_KEYS + 1`: what opens mail OpenPGP users encrypt to
87
+ * the mailbox, and signs what it sends them. Absent when the mailbox has none (or none opened). */
88
+ openPgpKeys?: { fingerprint: string; privateKey: OpenPgpPrivateKey }[];
89
+ /** A shared mailbox opened through this user's member key wraps (`sharedMailboxKeys.ts`), not with its own master key: it holds
90
+ * only `zeroAccessKeys`, and `masterKey` is a per-key secret derived from its zero-access private key (what the local search
91
+ * index's key is derived from), never a key that opens the mailbox's vault. */
92
+ shared?: boolean;
93
+ /** A shared session only: the user's own mailboxes whose zero-access keys opened its member key wraps. Locking any of them locks it too -
94
+ * it was opened with their keys, so it doesn't outlive them. */
95
+ openedWith?: string[];
76
96
  }
77
97
 
78
98
  const sessions = new Map<string, UnlockedKeys>();
@@ -146,6 +166,63 @@ export function subscribeKeySession(listener: KeySessionListener): () => void {
146
166
  };
147
167
  }
148
168
 
169
+ /**
170
+ * Makes a zero-access key this device has just enrolled (`zeroAccessKey.ts`'s `provisionZeroAccessKey()`) the active one
171
+ * of `unlocked` - the session object the key's private half was sealed under - so zero-access mail encrypted to it reads
172
+ * without another unlock. Does nothing when `unlocked` is no longer this mailbox's session (it was locked or replaced
173
+ * meanwhile; the next unlock opens the key from the vault anyway).
174
+ */
175
+ export function addZeroAccessKey(mailboxUid: string, unlocked: UnlockedKeys, key: RetainedEncryptionKey): void {
176
+ if (unlocked.destroyed || sessions.get(mailboxUid) !== unlocked) {
177
+ return;
178
+ }
179
+ unlocked.zeroAccessKeys = [key, ...(unlocked.zeroAccessKeys ?? []).filter((k) => k.fingerprint !== key.fingerprint)];
180
+ }
181
+
182
+ /**
183
+ * Makes an OpenPGP key this device has just enrolled (`openPgpKey.ts`'s `ensureOpenPgpKey()`) the active one of `unlocked`, so
184
+ * OpenPGP mail to it reads without another unlock - the same rules as `addZeroAccessKey()`.
185
+ */
186
+ export function addOpenPgpKey(mailboxUid: string, unlocked: UnlockedKeys, key: { fingerprint: string; privateKey: OpenPgpPrivateKey }): void {
187
+ if (unlocked.destroyed || sessions.get(mailboxUid) !== unlocked) {
188
+ return;
189
+ }
190
+ unlocked.openPgpKeys = [key, ...(unlocked.openPgpKeys ?? []).filter((k) => k.fingerprint !== key.fingerprint)];
191
+ }
192
+
193
+ /**
194
+ * Stores the keys of a shared mailbox opened through member key wraps (`sharedMailboxKeys.ts`'s `unlockSharedMailbox()`) as that mailbox's
195
+ * session, exactly as an unlock would - tracked for `destroyUnlockedKeys()`, and notified - unless the mailbox was locked since `generation`
196
+ * was taken (`sharedUnlockGeneration()`), when they are destroyed instead and `KeysLockedError` is thrown.
197
+ */
198
+ export function installSharedSession(mailboxUid: string, unlocked: UnlockedKeys, generation: string): void {
199
+ if (lockGeneration(mailboxUid) !== generation) {
200
+ destroyObject(unlocked);
201
+ throw new KeysLockedError();
202
+ }
203
+ unlocked.shared = true;
204
+ sessions.set(mailboxUid, unlocked);
205
+ const objects = issued.get(mailboxUid) ?? new Set<WeakHandle<UnlockedKeys>>();
206
+ objects.add(weakHandle(unlocked));
207
+ issued.set(mailboxUid, objects);
208
+ notify({ mailboxUid, state: "unlocked" });
209
+ }
210
+
211
+ /** The lock generation to pass `installSharedSession()`, taken before a shared unlock starts its requests. */
212
+ export function sharedUnlockGeneration(mailboxUid: string): string {
213
+ return lockGeneration(mailboxUid);
214
+ }
215
+
216
+ /** Every mailbox unlocked this session with its own keys (not a shared one opened through member key wraps). */
217
+ export function personallyUnlockedKeys(): UnlockedKeys[] {
218
+ return personalSessions().map((session) => session.unlocked);
219
+ }
220
+
221
+ /** `personallyUnlockedKeys()` with each mailbox's uid. */
222
+ export function personalSessions(): { mailboxUid: string; unlocked: UnlockedKeys }[] {
223
+ return [...sessions].filter(([, unlocked]) => !unlocked.shared).map(([mailboxUid, unlocked]) => ({ mailboxUid, unlocked }));
224
+ }
225
+
149
226
  /** Reads back a mailbox's already-unlocked keys this session, or `undefined` if it hasn't been
150
227
  * unlocked yet (or was destroyed). Never triggers an unlock itself. */
151
228
  export function getUnlockedKeys(mailboxUid: string): UnlockedKeys | undefined {
@@ -189,6 +266,14 @@ export function destroyUnlockedKeys(mailboxUid?: string): void {
189
266
  sessions.delete(uid);
190
267
  notify({ mailboxUid: uid, state: "locked" });
191
268
  }
269
+ // The shared mailboxes opened with this mailbox's keys go with it.
270
+ if (mailboxUid) {
271
+ for (const [uid, unlocked] of [...sessions]) {
272
+ if (unlocked.shared && unlocked.openedWith?.includes(mailboxUid)) {
273
+ destroyUnlockedKeys(uid);
274
+ }
275
+ }
276
+ }
192
277
  }
193
278
 
194
279
  function destroyObject(unlocked: UnlockedKeys): void {
@@ -201,6 +286,19 @@ function destroyObject(unlocked: UnlockedKeys): void {
201
286
  unlocked.retainedEncryptionKeys.length = 0;
202
287
  delete unlocked.retainedEncryptionKeys;
203
288
  }
289
+ if (unlocked.zeroAccessKeys) {
290
+ unlocked.zeroAccessKeys.length = 0;
291
+ delete unlocked.zeroAccessKeys;
292
+ }
293
+ if (unlocked.openPgpKeys) {
294
+ // OpenPGP.js keeps a decrypted secret key's material in its own objects: wipe it there too.
295
+ for (const { privateKey } of unlocked.openPgpKeys) {
296
+ // Present at runtime on every `PrivateKey`, missing from OpenPGP.js's type declarations.
297
+ (privateKey as unknown as { clearPrivateParams(): void }).clearPrivateParams();
298
+ }
299
+ unlocked.openPgpKeys.length = 0;
300
+ delete unlocked.openPgpKeys;
301
+ }
204
302
  }
205
303
 
206
304
  /** Finds the wrapped private key whose fingerprint matches a given published public key. */
@@ -238,7 +336,9 @@ export class UnopenableEncryptionKeyError extends Error {
238
336
  }
239
337
 
240
338
  /** Opens one wrapped PKCS#8 private key under `masterKey` and imports it (non-extractable by default, like
241
- * `importPrivateKeyPkcs8()` itself), zeroing the transient plaintext whether or not the import succeeds. */
339
+ * `importPrivateKeyPkcs8()` itself), zeroing the transient plaintext whether or not the import succeeds. With `certDer` - the server's
340
+ * certificate for it, unauthenticated - it rejects a key that certificate isn't the one of (`certificateHoldsKey()`), so a swapped
341
+ * certificate never pairs with the opened key. */
242
342
  async function openPrivateKey(
243
343
  masterKey: Uint8Array,
244
344
  wrapped: WrappedPrivateKey,
@@ -246,9 +346,13 @@ async function openPrivateKey(
246
346
  algorithm: EcKeyImportParams,
247
347
  usages: KeyUsage[],
248
348
  extractable = false,
349
+ certDer?: Uint8Array,
249
350
  ): Promise<CryptoKey> {
250
351
  const raw = await openWithKey(masterKey, wrapped, aad);
251
352
  try {
353
+ if (certDer && !(await certificateHoldsKey(certDer, raw))) {
354
+ throw new Error("The certificate isn't the key's.");
355
+ }
252
356
  return await importPrivateKeyPkcs8(raw, algorithm, usages, extractable);
253
357
  } finally {
254
358
  raw.fill(0);
@@ -259,7 +363,7 @@ async function openPrivateKey(
259
363
  * Opens the retained encryption keys onto `unlocked.retainedEncryptionKeys`: every published `encrypt` key in
260
364
  * `mailboxKeys` other than the active one (`activeFingerprint`), whatever its revocation or expiry, that has a wrapped
261
365
  * private key in the vault - de-duplicated by fingerprint, newest `notBefore` first, at most
262
- * `MAX_RETAINED_ENCRYPTION_KEYS`. A key that won't open or import, or whose certificate won't decode, is skipped and
366
+ * `MAX_RETAINED_ENCRYPTION_KEYS`. A key that won't open or import, or whose certificate won't decode or isn't the key's, is skipped and
263
367
  * added to `unopenableKeys`. Retained keys are imported non-extractable. Each key is attached as it opens, so a failed
264
368
  * unlock's `destroyObject()` drops those too.
265
369
  */
@@ -274,7 +378,8 @@ async function openRetainedEncryptionKeys(
274
378
  const seen = new Set<string>(activeFingerprint !== undefined ? [activeFingerprint] : []);
275
379
  const candidates: { publicKey: PublicKey; wrapped: WrappedPrivateKey }[] = [];
276
380
  for (const publicKey of [...mailboxKeys].sort((a, b) => b.notBefore - a.notBefore)) {
277
- const wrapped = publicKey.useType === "encrypt" && !seen.has(publicKey.fingerprint) ? findWrappedPrivateKey(vault, publicKey.fingerprint) : undefined;
381
+ const wrapped =
382
+ publicKey.useType === "encrypt" && !isOpenPgpKey(publicKey) && !seen.has(publicKey.fingerprint) ? findWrappedPrivateKey(vault, publicKey.fingerprint) : undefined;
278
383
  if (!wrapped) {
279
384
  continue;
280
385
  }
@@ -289,7 +394,7 @@ async function openRetainedEncryptionKeys(
289
394
  for (const { publicKey, wrapped } of candidates) {
290
395
  try {
291
396
  const certDer = fromBase64(publicKey.publicKey);
292
- const privateKey = await openPrivateKey(unlocked.masterKey, wrapped, aad, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
397
+ const privateKey = await openPrivateKey(unlocked.masterKey, wrapped, aad, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false, certDer);
293
398
  (unlocked.retainedEncryptionKeys ??= []).push({ fingerprint: publicKey.fingerprint, certDer, privateKey });
294
399
  } catch {
295
400
  unopenableKeys.push(publicKey.fingerprint);
@@ -297,6 +402,78 @@ async function openRetainedEncryptionKeys(
297
402
  }
298
403
  }
299
404
 
405
+ /**
406
+ * Opens the mailbox's zero-access private keys into `unlocked.zeroAccessKeys`: the vault's `zeroAccessKeys` that have a
407
+ * wrapped private key, the unrevoked one with the latest `notBefore` first, then the rest newest first, at most
408
+ * `MAX_RETAINED_ENCRYPTION_KEYS + 1`. A key that won't open is listed in `unopenableKeys` and skipped - zero-access mail
409
+ * encrypted to it doesn't decrypt on this device, but nothing else about the unlock depends on it. So is one whose certificate (the
410
+ * server's, unauthenticated) isn't the private key's: this device seals to the active key's certificate - search index segments of
411
+ * end-to-end mail, protected meetings' keys - so a certificate swapped by the server must never become it.
412
+ */
413
+ async function openZeroAccessKeys(mailboxUid: string, vault: KeyVault, unlocked: UnlockedKeys, unopenableKeys: string[]): Promise<void> {
414
+ const ordered = [...(vault.zeroAccessKeys ?? [])].sort(
415
+ (a, b) => Number(!!a.revokedAt) - Number(!!b.revokedAt) || b.notBefore - a.notBefore,
416
+ );
417
+ const aad = buildAad(mailboxUid, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE);
418
+ let opened = 0;
419
+ for (const publicKey of ordered) {
420
+ const wrapped = findWrappedPrivateKey(vault, publicKey.fingerprint);
421
+ if (!wrapped || wrapped.useType !== "zero-access") {
422
+ continue;
423
+ }
424
+ try {
425
+ const certDer = fromBase64(publicKey.publicKey);
426
+ const raw = await openWithKey(unlocked.masterKey, wrapped, aad);
427
+ try {
428
+ if (!(await certificateHoldsKey(certDer, raw))) {
429
+ throw new Error("The zero-access certificate isn't the key's.");
430
+ }
431
+ const privateKey = await importPrivateKeyPkcs8(raw, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
432
+ (unlocked.zeroAccessKeys ??= []).push({ fingerprint: publicKey.fingerprint, certDer, privateKey });
433
+ } finally {
434
+ raw.fill(0);
435
+ }
436
+ } catch {
437
+ unopenableKeys.push(publicKey.fingerprint);
438
+ }
439
+ if (++opened === MAX_RETAINED_ENCRYPTION_KEYS + 1) {
440
+ break;
441
+ }
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Opens the mailbox's OpenPGP secret keys into `unlocked.openPgpKeys`: its published OpenPGP certificates (`type: "openpgp"`) that
447
+ * have a wrapped secret key, the active one first (unrevoked, unexpired, latest `notBefore`), then the rest newest first, at most
448
+ * `MAX_RETAINED_ENCRYPTION_KEYS + 1`. OpenPGP.js is loaded only for a mailbox that has one. A key that won't open is listed in
449
+ * `unopenableKeys` and skipped.
450
+ */
451
+ async function openOpenPgpKeys(mailboxUid: string, mailboxKeys: PublicKey[], vault: KeyVault, unlocked: UnlockedKeys, unopenableKeys: string[]): Promise<void> {
452
+ const now = Date.now();
453
+ const active = (key: PublicKey): boolean => !key.revokedAt && key.notAfter > now;
454
+ const ordered = mailboxKeys
455
+ .filter((key) => isOpenPgpKey(key) && findWrappedPrivateKey(vault, key.fingerprint))
456
+ .sort((a, b) => Number(active(b)) - Number(active(a)) || b.notBefore - a.notBefore)
457
+ .slice(0, MAX_RETAINED_ENCRYPTION_KEYS + 1);
458
+ if (ordered.length === 0) {
459
+ return;
460
+ }
461
+ const { readSecretKey } = await import("./openpgp.js");
462
+ const aad = buildAad(mailboxUid, OPENPGP_PRIVATE_KEY_AAD_PURPOSE);
463
+ for (const publicKey of ordered) {
464
+ try {
465
+ const raw = await openWithKey(unlocked.masterKey, findWrappedPrivateKey(vault, publicKey.fingerprint)!, aad);
466
+ try {
467
+ (unlocked.openPgpKeys ??= []).push({ fingerprint: publicKey.fingerprint, privateKey: await readSecretKey(raw) });
468
+ } finally {
469
+ raw.fill(0);
470
+ }
471
+ } catch {
472
+ unopenableKeys.push(publicKey.fingerprint);
473
+ }
474
+ }
475
+ }
476
+
300
477
  /**
301
478
  * Unlocks a mailbox's key vault with its password unlock method: fetches the vault, derives the
302
479
  * wrapping key from `password` using the *exact* KDF parameters that wrap was created with, unwraps
@@ -309,13 +486,27 @@ async function openRetainedEncryptionKeys(
309
486
  * "incorrect password," not a generic error, but this module doesn't presume a specific UI's error copy.
310
487
  * Once the master key has opened, the password is known to be right: an active signing key that then
311
488
  * won't open is skipped and listed in `UnlockResult.unopenableKeys` (so is a retained encryption key); an active
312
- * encryption key that won't
313
- * open still fails the unlock, with `UnopenableEncryptionKeyError`. Throws `KeysLockedError` when
489
+ * encryption key that won't open, or whose certificate isn't its own,
490
+ * still fails the unlock, with `UnopenableEncryptionKeyError`. Throws `KeysLockedError` when
314
491
  * `destroyUnlockedKeys()` locked this mailbox (or all mailboxes) while the unlock was in flight.
315
492
  */
316
493
  export async function unlockWithPassword(mailboxUid: string, mailboxKeys: PublicKey[], password: string): Promise<UnlockResult> {
317
494
  const generation = lockGeneration(mailboxUid);
318
- const vault = await getKeyVault(mailboxUid);
495
+ const { vault, masterKey } = await openMasterKeyWithPassword(mailboxUid, password);
496
+ return openSession(mailboxUid, mailboxKeys, vault, masterKey, generation);
497
+ }
498
+
499
+ /**
500
+ * Fetches `mailboxUid`'s vault and opens its master key with the password wrap, touching no session - for an operation that
501
+ * must re-prove the password even while the mailbox is unlocked (`keyExport.ts`). Rejects exactly as `unlockWithPassword()`
502
+ * does on a missing wrap, an unknown KDF or a wrong password. The caller owns the returned master key and must zero it.
503
+ */
504
+ export async function openMasterKeyWithPassword(
505
+ mailboxUid: string,
506
+ password: string,
507
+ client?: ApiClient,
508
+ ): Promise<{ vault: KeyVault; masterKey: Uint8Array }> {
509
+ const vault = await getKeyVault(mailboxUid, client);
319
510
  const passwordWrap = vault.masterKeyWraps.find((w) => w.method === "password");
320
511
  if (!passwordWrap) {
321
512
  throw new Error("This mailbox has no password unlock method enrolled.");
@@ -332,8 +523,7 @@ export async function unlockWithPassword(mailboxUid: string, mailboxKeys: Public
332
523
  { ciphertext: passwordWrap.ciphertext, nonce: passwordWrap.nonce },
333
524
  buildAad(mailboxUid, MASTER_KEY_AAD_PURPOSE),
334
525
  );
335
-
336
- return openSession(mailboxUid, mailboxKeys, vault, masterKey, generation);
526
+ return { vault, masterKey };
337
527
  }
338
528
 
339
529
  /** What `unlockWithRecoveryCode()` resolves with. */
@@ -442,17 +632,31 @@ async function openSession(
442
632
  const encryptionPublicKey = findActivePublicKey(mailboxKeys, "encrypt");
443
633
  const wrappedEncryptionKey = encryptionPublicKey && findWrappedPrivateKey(vault, encryptionPublicKey.fingerprint);
444
634
  if (encryptionPublicKey && wrappedEncryptionKey) {
635
+ let encryptionCertDer: Uint8Array;
445
636
  try {
637
+ // The certificate is the server's: one that isn't this key's - what this device encrypts its own copies to, and what it
638
+ // hands out as its S/MIME certificate - makes the key unopenable, exactly as a wrap that won't open.
639
+ encryptionCertDer = fromBase64(encryptionPublicKey.publicKey);
446
640
  const aad = buildAad(mailboxUid, ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE);
447
- unlocked.encryptionPrivateKey = await openPrivateKey(masterKey, wrappedEncryptionKey, aad, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"]);
641
+ unlocked.encryptionPrivateKey = await openPrivateKey(
642
+ masterKey,
643
+ wrappedEncryptionKey,
644
+ aad,
645
+ { name: "ECDH", namedCurve: "P-256" },
646
+ ["deriveBits"],
647
+ false,
648
+ encryptionCertDer,
649
+ );
448
650
  } catch (err) {
449
651
  throw new UnopenableEncryptionKeyError(encryptionPublicKey.fingerprint, err);
450
652
  }
451
- unlocked.encryptionCertDer = fromBase64(encryptionPublicKey.publicKey);
653
+ unlocked.encryptionCertDer = encryptionCertDer;
452
654
  unlocked.encryptionFingerprint = encryptionPublicKey.fingerprint;
453
655
  }
454
656
 
455
657
  await openRetainedEncryptionKeys(mailboxUid, mailboxKeys, vault, unlocked, encryptionPublicKey?.fingerprint, unopenableKeys);
658
+ await openZeroAccessKeys(mailboxUid, vault, unlocked, unopenableKeys);
659
+ await openOpenPgpKeys(mailboxUid, mailboxKeys, vault, unlocked, unopenableKeys);
456
660
  if (lockGeneration(mailboxUid) !== generation) {
457
661
  // Locked (e.g. logout) while this unlock was awaiting - never restore keys after that.
458
662
  throw new KeysLockedError();