@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
@@ -37,10 +37,36 @@
37
37
  * `untrusted_signer`. Previously pinned keys count as pins (see `signingKeyFingerprints()`), so mail signed before an
38
38
  * accepted or automatic rotation still verifies.
39
39
  */
40
- import { DisplayBody, MimeAttachment, MimeHeaderField, decodeHeaderText, extractAddresses, parseMimeEntity, parseParameterizedHeader, plainTextToHtml } from "./mime.js";
41
- import { toBase64 } from "./encoding.js";
42
- import { computeCertFingerprint, extractCertificateEmails } from "./smime.js";
43
- import { ComparableOuterHeaders, ProtectedHeaders, parseEncryptedMessageWithKeys, parseSignedOnlyMessage } from "./smimeMessage.js";
40
+ import {
41
+ DisplayBody,
42
+ MimeAttachment,
43
+ MimeHeaderField,
44
+ bytesToBinaryString,
45
+ decodeBodyText,
46
+ type MimeEntity,
47
+ decodeBase64Text,
48
+ decodeHeaderText,
49
+ extractAddresses,
50
+ extractAttachments,
51
+ extractCalendarText,
52
+ extractDisplayBody,
53
+ parseMimeEntity,
54
+ parseParameterizedHeader,
55
+ plainTextToHtml,
56
+ } from "./mime.js";
57
+ import { fromBase64, toBase64 } from "./encoding.js";
58
+ import { type DecryptionKey, computeCertFingerprint, decryptEnvelopedDataWithKeys, extractCertificateEmails } from "./smime.js";
59
+ import {
60
+ ComparableOuterHeaders,
61
+ ProtectedHeaders,
62
+ extractHpOuterHeaders,
63
+ headersToProtectedHeaders,
64
+ outerHeadersMatch,
65
+ parseEncryptedMessageWithKeys,
66
+ parseSignedOnlyMessage,
67
+ } from "./smimeMessage.js";
68
+ import type { OpenPgpPrivateKey } from "./openpgp.js";
69
+ import type { ParsedPgpMessage, PgpSignerCandidate } from "./pgpMessage.js";
44
70
  import { type PublicKey, isTrustedForVerification } from "./keyvaultApi.js";
45
71
  import { KeysLockedError } from "./masterKey.js";
46
72
  import { type SealKeyMaterial, type SealedVerificationState, buildVerificationSeal, openVerificationSeal, rawMimeSha256 } from "./verificationSeal.js";
@@ -67,6 +93,7 @@ export type MessageSecurityState =
67
93
  | "encrypted_unverified_signer"
68
94
  | "signature_failed"
69
95
  | "verified_at_first_open"
96
+ | "protected_at_rest"
70
97
  | "unprotected";
71
98
 
72
99
  /** Why a message is `"signature_failed"`. `invalid_signature`: the CMS signature itself is malformed or
@@ -113,6 +140,13 @@ export interface MessageSecurityResult {
113
140
  * since-rotated key, non-AEAD content encryption, or corrupt/foreign data) - the caller should keep
114
141
  * showing whatever `GET /:id/content` already produced alongside this explanation. */
115
142
  decryptError?: string;
143
+ /** Set when the stored message was sealed at rest by the server (restapi `specs/search.md` §3) and opened here with the mailbox's
144
+ * zero-access key. `state` then describes the original message inside: `protected_at_rest` when it arrived unprotected, or its own
145
+ * signed/encrypted state otherwise. */
146
+ zeroAccess?: boolean;
147
+ /** With `zeroAccess`: the original's calendar file (`extractCalendarText()`), which the server can't read from the sealed copy - the
148
+ * client sends it with the invitation endpoints. Absent when the message carries none. */
149
+ calendar?: string;
116
150
  /** See `ParsedEncryptedMessage.headerTamperDetected`'s own doc comment - a deliberately separate,
117
151
  * orthogonal signal from `state`, since RFC 9788's `HP-Outer` is written on every encrypted message
118
152
  * regardless of whether it's also signed. `undefined` for anything that isn't an encrypted message
@@ -139,6 +173,14 @@ export interface MessageSecurityResult {
139
173
  signerFingerprint?: string;
140
174
  /** The email addresses that signer certificate asserts (lowercased), alongside `signerFingerprint`. */
141
175
  signerEmails?: string[];
176
+ /** `"openpgp"` when the signer is an OpenPGP key: `signerCertificate` is then its certificate (base64 binary) and
177
+ * `signerFingerprint` its primary key's fingerprint - pin it with `trustSigner()`'s `type: "openpgp"`. */
178
+ signerKeyType?: "openpgp";
179
+ /** `true` for an OpenPGP message whose signature was made by a key this reader doesn't have (neither pinned for the sender,
180
+ * nor the reader's own, nor attached to the message), so it couldn't be checked at all. A signed-only one is then reported
181
+ * `"unprotected"` (its content is readable anyway), an encrypted one `"encrypted"`; a UI can say the sender's key isn't known
182
+ * yet. */
183
+ signerKeyUnknown?: boolean;
142
184
  /** Base64 DER of the certificate that verified the signature - what `keyvaultApi.ts`'s `trustSigner()` pins for
143
185
  * a "trust this signer" action. Present alongside `signerFingerprint` for `"signed_verified"`,
144
186
  * `"encrypted_verified"`, the `*_unverified_signer` states, and `"signature_failed"` with reason
@@ -258,8 +300,13 @@ function sameInstant(signed: string | undefined, outer: string | undefined): boo
258
300
  return Number.isNaN(left) || left === Date.parse(String(outer));
259
301
  }
260
302
 
261
- function normalizePins(pins: string | string[] | undefined): string[] {
262
- return (Array.isArray(pins) ? pins : pins === undefined ? [] : [pins]).map((pin) => pin.toLowerCase());
303
+ function normalizePins(pins: SignerPinsInput): string[] {
304
+ const list: string[] = Array.isArray(pins) ? pins : pins === undefined ? [] : typeof pins === "string" ? [pins] : pins.fingerprints;
305
+ return list.map((pin) => pin.toLowerCase());
306
+ }
307
+
308
+ function pinnedOpenPgpKeys(pins: SignerPinsInput): string[] {
309
+ return pins !== undefined && typeof pins === "object" && !Array.isArray(pins) ? pins.openPgpKeys : [];
263
310
  }
264
311
 
265
312
  /** Checks 2-4 from this module's doc comment for an already cryptographically verified signature.
@@ -277,13 +324,14 @@ export async function checkSignerBinding(input: SignerBindingInput): Promise<Sig
277
324
  if (pins.length > 0 && !pins.includes(await computeCertFingerprint(certDer))) {
278
325
  // A certificate that otherwise binds cleanly to the sender is a key change for the user to judge; anything else
279
326
  // (no certificate, wrong identity, header tampering) stays a plain untrusted signer.
280
- return input.signerCertificateDer && checkIdentityAndHeaders(input, certDer) === undefined ? "signer_key_changed" : "untrusted_signer";
327
+ return input.signerCertificateDer && checkIdentityAndHeaders(input, extractCertificateEmails(certDer)) === undefined ? "signer_key_changed" : "untrusted_signer";
281
328
  }
282
- return checkIdentityAndHeaders(input, certDer);
329
+ return checkIdentityAndHeaders(input, extractCertificateEmails(certDer));
283
330
  }
284
331
 
285
- /** Checks 3-4 from this module's doc comment. */
286
- function checkIdentityAndHeaders(input: SignerBindingInput, certDer: Uint8Array): SignatureFailureReason | undefined {
332
+ /** Checks 3-4 from this module's doc comment, for a signer whose key names `signerEmails` (an X.509 certificate's, or an
333
+ * OpenPGP key's user IDs). */
334
+ function checkIdentityAndHeaders(input: Omit<SignerBindingInput, "signerCertificateDer">, signerEmails: string[]): SignatureFailureReason | undefined {
287
335
  const { protectedHeaders, outerHeaders } = input;
288
336
  if (hasRepeatedAddressField(input.outerFields) || hasRepeatedAddressField(input.protectedFields)) {
289
337
  return "header_mismatch";
@@ -291,7 +339,7 @@ function checkIdentityAndHeaders(input: SignerBindingInput, certDer: Uint8Array)
291
339
  const outerFrom = extractAddresses(outerHeaders["from"]);
292
340
  const protectedFrom = extractAddresses(protectedHeaders?.from);
293
341
  const senderAddresses = protectedFrom.length > 0 ? protectedFrom : outerFrom;
294
- if (senderAddresses.length !== 1 || !extractCertificateEmails(certDer).includes(senderAddresses[0])) {
342
+ if (senderAddresses.length !== 1 || !signerEmails.includes(senderAddresses[0])) {
295
343
  return "signer_identity_mismatch";
296
344
  }
297
345
  const protectedTo = extractAddresses(protectedHeaders?.to);
@@ -335,7 +383,7 @@ function checkIdentityAndHeaders(input: SignerBindingInput, certDer: Uint8Array)
335
383
  export async function evaluateMessageSecurity(
336
384
  rawMime: string,
337
385
  unlocked: MessageSecurityKeys | undefined,
338
- pinnedSignerFingerprints?: string | string[],
386
+ pinnedSignerFingerprints?: SignerPinsInput,
339
387
  readerAddress?: string,
340
388
  ): Promise<MessageSecurityResult> {
341
389
  return (await evaluateLive(rawMime, unlocked, pinnedSignerFingerprints, readerAddress)).result;
@@ -347,6 +395,75 @@ export interface MessageSecurityKeys {
347
395
  encryptionCertDer?: Uint8Array;
348
396
  retainedEncryptionKeys?: { certDer: Uint8Array; privateKey: CryptoKey }[];
349
397
  signingFingerprint?: string;
398
+ /** The mailbox's zero-access keys (`keySession.ts`'s `UnlockedKeys.zeroAccessKeys`) - what opens a message the server sealed at
399
+ * rest on arrival. */
400
+ zeroAccessKeys?: { certDer: Uint8Array; privateKey: CryptoKey }[];
401
+ /** The mailbox's OpenPGP secret keys (`keySession.ts`'s `UnlockedKeys.openPgpKeys`), the active one first - what opens mail an
402
+ * OpenPGP user encrypted to the mailbox, and verifies mail it signed itself. */
403
+ openPgpKeys?: { fingerprint: string; privateKey: OpenPgpPrivateKey }[];
404
+ }
405
+
406
+ /** The sender's trusted keys, when they include OpenPGP certificates: `fingerprints` as `pinnedSignerFingerprints`, and
407
+ * `openPgpKeys` the pinned OpenPGP certificates themselves (base64, `PublicKey.publicKey`) - an OpenPGP signature carries only
408
+ * a key ID, so its key has to be supplied to be checked at all (`pinnedSigners.ts`'s `getPinnedSigners()`). */
409
+ export interface SignerPins {
410
+ fingerprints: string[];
411
+ openPgpKeys: string[];
412
+ }
413
+
414
+ /** What `pinnedSignerFingerprints` accepts: one fingerprint, several, or `SignerPins`. */
415
+ export type SignerPinsInput = string | string[] | SignerPins | undefined;
416
+
417
+ /**
418
+ * The subject and preview of a zero-access message (`Message.zeroAccess.sealedSummary`, base64 CMS of JSON `{ subject, preview }`),
419
+ * decrypted with the mailbox's zero-access keys - a few hundred bytes, so a message list shows real subjects without downloading
420
+ * each message. `undefined` when no key opens it or it isn't a summary.
421
+ */
422
+ export async function openZeroAccessSummary(
423
+ sealedSummary: string,
424
+ keys: DecryptionKey[] | undefined,
425
+ ): Promise<{ subject: string; preview: string } | undefined> {
426
+ if (!keys?.length) {
427
+ return undefined;
428
+ }
429
+ try {
430
+ const json: unknown = JSON.parse(new TextDecoder().decode(await decryptEnvelopedDataWithKeys(fromBase64(sealedSummary), keys)));
431
+ const { subject, preview } = (json ?? {}) as Record<string, unknown>;
432
+ return typeof subject === "string" && typeof preview === "string" ? { subject, preview } : undefined;
433
+ } catch {
434
+ return undefined;
435
+ }
436
+ }
437
+
438
+ /**
439
+ * The original MIME (as a binary string) of a stored message the server sealed at rest, opened with `unlocked.zeroAccessKeys` -
440
+ * `undefined` when it isn't a sealed message or no key opens it. For what needs the original itself, e.g. reporting it to the spam
441
+ * filter (`reportMessage()`'s `rawMessage`).
442
+ */
443
+ export async function openZeroAccessMessage(rawMime: string, unlocked: MessageSecurityKeys | undefined): Promise<string | undefined> {
444
+ const { headers, body } = parseMimeEntity(rawMime);
445
+ const rawContentType = headers["content-type"];
446
+ if (!unlocked?.zeroAccessKeys?.length || !rawContentType || !isEncryptedContentType(parseParameterizedHeader(rawContentType))) {
447
+ return undefined;
448
+ }
449
+ return openZeroAccess(body, unlocked.zeroAccessKeys);
450
+ }
451
+
452
+ /**
453
+ * The original MIME of a message the server sealed at rest (restapi `specs/search.md` §3) - the stored S/MIME `enveloped-data`
454
+ * message's content, decrypted with one of `keys` - or `undefined` when none of them opens it. Only the server ever encrypts to a
455
+ * zero-access key (it is never published), so a message that opens with one was sealed at rest, never sent end-to-end.
456
+ */
457
+ async function openZeroAccess(body: string, keys: DecryptionKey[]): Promise<string | undefined> {
458
+ const der = decodeBase64Text(body);
459
+ if (!der) {
460
+ return undefined;
461
+ }
462
+ try {
463
+ return bytesToBinaryString(await decryptEnvelopedDataWithKeys(der, keys));
464
+ } catch {
465
+ return undefined;
466
+ }
350
467
  }
351
468
 
352
469
  /** `evaluateMessageSecurity()`'s result plus what the seal-aware wrapper needs: which kind of protected message it was,
@@ -360,15 +477,21 @@ interface LiveEvaluation {
360
477
  async function evaluateLive(
361
478
  rawMime: string,
362
479
  unlocked: MessageSecurityKeys | undefined,
363
- pinnedSignerFingerprints: string | string[] | undefined,
480
+ pinnedSignerFingerprints: SignerPinsInput,
364
481
  readerAddress: string | undefined,
482
+ sealedAtRest = false,
365
483
  ): Promise<LiveEvaluation> {
366
- const { headers, body, fields } = parseMimeEntity(rawMime);
484
+ const outerEntity = parseMimeEntity(rawMime);
485
+ const { headers, body, fields } = outerEntity;
367
486
  const rawContentType = headers["content-type"];
487
+ const contentType = parseParameterizedHeader(rawContentType ?? "text/plain");
488
+ const pgp = pgpKind(contentType, outerEntity);
489
+ if (pgp) {
490
+ return await evaluatePgp(pgp, rawContentType ?? "text/plain", outerEntity, unlocked, pinnedSignerFingerprints, readerAddress);
491
+ }
368
492
  if (!rawContentType) {
369
493
  return { result: { state: "unprotected" }, kind: "other" };
370
494
  }
371
- const contentType = parseParameterizedHeader(rawContentType);
372
495
  const callerPins = normalizePins(pinnedSignerFingerprints);
373
496
 
374
497
  // The received message's own *real* outer envelope - what `HP-Outer`'s field copies (written at
@@ -452,6 +575,35 @@ async function evaluateLive(
452
575
  };
453
576
  }
454
577
 
578
+ // A message the server sealed at rest: evaluate the original it holds - which may itself be signed or end-to-end encrypted -
579
+ // and report one that was unprotected when it arrived as `protected_at_rest`, with its content.
580
+ if (isEncryptedContentType(contentType) && !sealedAtRest && unlocked?.zeroAccessKeys?.length) {
581
+ const original = await openZeroAccess(body, unlocked.zeroAccessKeys);
582
+ if (original !== undefined) {
583
+ const inner = await evaluateLive(original, unlocked, pinnedSignerFingerprints, readerAddress, true);
584
+ const entity = parseMimeEntity(original);
585
+ const subject = entity.headers["subject"];
586
+ const calendarText = extractCalendarText(entity);
587
+ const calendar = calendarText !== undefined ? { calendar: calendarText } : {};
588
+ if (inner.result.state !== "unprotected") {
589
+ // The stored message's own Subject is the placeholder: the original's stands in when the inner result has none.
590
+ const fallbackSubject = inner.result.subject === undefined && subject !== undefined ? { subject: decodeHeaderText(subject) } : {};
591
+ return { ...inner, result: { ...inner.result, ...fallbackSubject, ...calendar, zeroAccess: true } };
592
+ }
593
+ return {
594
+ result: {
595
+ state: "protected_at_rest",
596
+ zeroAccess: true,
597
+ ...calendar,
598
+ ...renderDisplayBody(extractDisplayBody(entity)),
599
+ ...(subject !== undefined ? { subject: decodeHeaderText(subject) } : {}),
600
+ attachments: extractAttachments(entity),
601
+ },
602
+ kind: "other",
603
+ };
604
+ }
605
+ }
606
+
455
607
  if (isEncryptedContentType(contentType)) {
456
608
  // The active key first, then the retained older ones (see `keySession.ts`'s `retainedEncryptionKeys`), so mail
457
609
  // encrypted to a key since replaced still opens. Only ever used to decrypt here - never to encrypt or sign.
@@ -496,6 +648,122 @@ async function evaluateLive(
496
648
  return { result: { state: "unprotected" }, kind: "other" };
497
649
  }
498
650
 
651
+ /** Which OpenPGP form a received message is, if any: RFC 3156 PGP/MIME (`multipart/signed` or `multipart/encrypted` with an OpenPGP
652
+ * `protocol`), or an inline message - a `text/plain` body that is one armored `PGP MESSAGE`, or a cleartext signature. */
653
+ type PgpKind = "signed" | "encrypted" | "inline-encrypted" | "inline-signed";
654
+
655
+ const INLINE_PGP_MESSAGE = /^-----BEGIN PGP MESSAGE-----\r?\n[\s\S]*-----END PGP MESSAGE-----$/;
656
+ const INLINE_PGP_SIGNED = /^-----BEGIN PGP SIGNED MESSAGE-----\r?\n[\s\S]*-----END PGP SIGNATURE-----$/;
657
+
658
+ function pgpKind(contentType: { value: string; params: Record<string, string> }, entity: MimeEntity): PgpKind | undefined {
659
+ const protocol = contentType.params["protocol"]?.toLowerCase();
660
+ if (contentType.value === "multipart/signed" && protocol === "application/pgp-signature") {
661
+ return "signed";
662
+ }
663
+ if (contentType.value === "multipart/encrypted" && protocol === "application/pgp-encrypted") {
664
+ return "encrypted";
665
+ }
666
+ if (contentType.value !== "text/plain") {
667
+ return undefined;
668
+ }
669
+ const text = decodeBodyText(entity).trim();
670
+ return INLINE_PGP_MESSAGE.test(text) ? "inline-encrypted" : INLINE_PGP_SIGNED.test(text) ? "inline-signed" : undefined;
671
+ }
672
+
673
+ /**
674
+ * `evaluateLive()` for an OpenPGP message (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*), with the same
675
+ * trust rules as S/MIME: a signature by a key pinned for the sender (`SignerPins.openPgpKeys`) or the reader's own is verified;
676
+ * one by a key only attached to the message is `*_unverified_signer` - or `signer_key_changed` when the sender has a different key
677
+ * pinned; and the key must name the sender, with the protected headers matching the outer ones (`checkIdentityAndHeaders()`).
678
+ * A signature nobody's key can check is reported through `signerKeyUnknown`, never as failed. The crypto is `pgpMessage.ts`'s,
679
+ * loaded only here.
680
+ */
681
+ async function evaluatePgp(
682
+ kind: PgpKind,
683
+ rawContentType: string,
684
+ outer: MimeEntity,
685
+ unlocked: MessageSecurityKeys | undefined,
686
+ pins: SignerPinsInput,
687
+ readerAddress: string | undefined,
688
+ ): Promise<LiveEvaluation> {
689
+ const pgp = await import("./pgpMessage.js");
690
+ const pinned = (await pgp.readKeysBase64(pinnedOpenPgpKeys(pins))).map((key) => ({ key, trusted: true }));
691
+ const ownKeys = unlocked?.openPgpKeys ?? [];
692
+ const candidates: PgpSignerCandidate[] = [...pinned, ...ownKeys.map(({ privateKey }) => ({ key: privateKey.toPublic(), trusted: true }))];
693
+ const decryptionKeys = ownKeys.map(({ privateKey }) => privateKey);
694
+ const encrypted = kind === "encrypted" || kind === "inline-encrypted";
695
+ const text = decodeBodyText(outer).trim();
696
+ let parsed: ParsedPgpMessage;
697
+ if (kind === "signed") {
698
+ parsed = await pgp.parsePgpSignedMessage(rawContentType, outer.body, candidates);
699
+ } else if (kind === "encrypted") {
700
+ parsed = await pgp.parsePgpEncryptedMessage(rawContentType, outer.body, decryptionKeys, candidates);
701
+ } else if (kind === "inline-encrypted") {
702
+ parsed = await pgp.parsePgpInlineEncrypted(text, decryptionKeys, candidates);
703
+ } else {
704
+ parsed = await pgp.parsePgpCleartextSigned(text, candidates);
705
+ }
706
+ const resultKind: LiveEvaluation["kind"] = encrypted ? "encrypted" : "signed";
707
+ if (encrypted && !parsed.decrypted) {
708
+ return { result: { state: "encrypted", decryptError: NO_KEY_ERROR }, kind: resultKind };
709
+ }
710
+ if (parsed.entityText === undefined) {
711
+ return { result: { state: "signature_failed", signatureFailureReason: "invalid_signature" }, kind: resultKind };
712
+ }
713
+ const entity = parseMimeEntity(parsed.entityText);
714
+ const inline = kind === "inline-encrypted" || kind === "inline-signed";
715
+ const protectedHeaders: ProtectedHeaders | undefined = inline ? undefined : headersToProtectedHeaders(entity.headers);
716
+ const hasProtected = !!protectedHeaders && extractAddresses(protectedHeaders.from).length > 0;
717
+ const hpOuter = encrypted && !inline ? extractHpOuterHeaders(entity.fields) : undefined;
718
+ const outerComparable: ComparableOuterHeaders = { from: outer.headers["from"], to: outer.headers["to"], cc: outer.headers["cc"], date: outer.headers["date"], subject: outer.headers["subject"] };
719
+ const recipients = [...extractAddresses(protectedHeaders?.to), ...extractAddresses(protectedHeaders?.cc)];
720
+ const content: Partial<MessageSecurityResult> = {
721
+ ...renderDisplayBody(extractDisplayBody(entity)),
722
+ subject: (hasProtected ? protectedHeaders?.subject : undefined) || undefined,
723
+ attachments: extractAttachments(entity),
724
+ ...(hpOuter ? { headerTamperDetected: !outerHeadersMatch(hpOuter, outerComparable) } : {}),
725
+ ...(readerAddress && recipients.length > 0 ? { notAddressedToReader: !recipients.includes(readerAddress.trim().toLowerCase()) } : {}),
726
+ };
727
+ const signature = parsed.signature;
728
+ if (!signature) {
729
+ // Encrypted without a signature (an unsigned signed-only message can't happen: it is the signature).
730
+ return { result: { state: "encrypted", ...content }, kind: resultKind };
731
+ }
732
+ if (signature.status === "unknown_key") {
733
+ return { result: encrypted ? { state: "encrypted", ...content, signerKeyUnknown: true } : { state: "unprotected", signerKeyUnknown: true }, kind: resultKind };
734
+ }
735
+ if (signature.status === "invalid") {
736
+ return { result: { state: "signature_failed", signatureFailureReason: "invalid_signature", ...(encrypted ? content : {}) }, kind: resultKind };
737
+ }
738
+ const signer = { signerFingerprint: signature.fingerprint, signerEmails: signature.emails, signerKeyType: "openpgp" as const };
739
+ const acceptedSigner = { ...signer, signerCertificate: toBase64(signature.candidate.key.write()) };
740
+ const signedHeaders: Partial<MessageSecurityResult> = hasProtected
741
+ ? { protectedHeaders: { from: protectedHeaders.from, to: protectedHeaders.to, ...(protectedHeaders.cc !== undefined ? { cc: protectedHeaders.cc } : {}), subject: protectedHeaders.subject } }
742
+ : {};
743
+ const failure = checkIdentityAndHeaders(
744
+ {
745
+ protectedHeaders: hasProtected ? protectedHeaders : undefined,
746
+ outerHeaders: outer.headers,
747
+ outerFields: outer.fields,
748
+ protectedFields: hasProtected ? entity.fields : undefined,
749
+ compareSubject: !encrypted,
750
+ },
751
+ signature.emails,
752
+ );
753
+ // Signed by a key only attached to the message while the sender has OpenPGP keys pinned: they appear to use a new key.
754
+ const keyChanged = !failure && !signature.candidate.trusted && pinned.length > 0;
755
+ if (failure || keyChanged) {
756
+ const reason: SignatureFailureReason = failure ?? "signer_key_changed";
757
+ return {
758
+ result: { state: "signature_failed", signatureFailureReason: reason, ...(encrypted ? content : {}), ...(reason === "signer_key_changed" ? acceptedSigner : signer) },
759
+ kind: resultKind,
760
+ ...(reason === "signer_key_changed" ? { signedContent: { ...content, ...signedHeaders } } : {}),
761
+ };
762
+ }
763
+ const state: MessageSecurityState = signature.candidate.trusted ? (encrypted ? "encrypted_verified" : "signed_verified") : encrypted ? "encrypted_unverified_signer" : "signed_unverified_signer";
764
+ return { result: { state, ...content, ...signedHeaders, ...acceptedSigner }, kind: resultKind };
765
+ }
766
+
499
767
  /** Options for `evaluateMessageSecurityWithSeal()`. */
500
768
  export interface VerificationSealOptions {
501
769
  mailboxUid: string;
@@ -562,7 +830,7 @@ function failedOnlyOnKeyStatus(result: MessageSecurityResult): boolean {
562
830
  export async function evaluateMessageSecurityWithSeal(
563
831
  rawMime: string,
564
832
  unlocked: (MessageSecurityKeys & SealKeyMaterial) | undefined,
565
- pinnedSignerFingerprints: string | string[] | undefined,
833
+ pinnedSignerFingerprints: SignerPinsInput,
566
834
  readerAddress: string | undefined,
567
835
  options: VerificationSealOptions,
568
836
  ): Promise<MessageSecurityResult> {
@@ -541,6 +541,32 @@ export function extractAttachments(entity: MimeEntity, depth = 0): MimeAttachmen
541
541
  ];
542
542
  }
543
543
 
544
+ /**
545
+ * The text of the first calendar file in `entity` (descending into multipart children, bounded depth): a `text/calendar` or
546
+ * `application/ics` leaf, whether a body alternative (as Outlook sends an invitation) or an attached `.ics`. `undefined` when there is none.
547
+ */
548
+ export function extractCalendarText(entity: MimeEntity, depth = 0): string | undefined {
549
+ const contentType = parseParameterizedHeader(entity.headers["content-type"] ?? "text/plain");
550
+ if (contentType.value.startsWith("multipart/")) {
551
+ const boundary = contentType.params["boundary"];
552
+ if (!boundary || depth >= MAX_MULTIPART_DEPTH) {
553
+ return undefined;
554
+ }
555
+ for (const part of splitMultipart(entity.body, boundary)) {
556
+ const found = extractCalendarText(parseMimeEntity(part), depth + 1);
557
+ if (found !== undefined) {
558
+ return found;
559
+ }
560
+ }
561
+ return undefined;
562
+ }
563
+ if (contentType.value !== "text/calendar" && contentType.value !== "application/ics") {
564
+ return undefined;
565
+ }
566
+ const bytes = decodeBodyBytes(entity);
567
+ return bytes ? new TextDecoder().decode(bytes) : undefined;
568
+ }
569
+
544
570
  /** HTML-escapes plain text and wraps it in a whitespace-preserving `<pre>`, so a `text/plain` body can be
545
571
  * rendered through the same HTML path without ever being interpreted as markup. */
546
572
  export function plainTextToHtml(text: string): string {
@@ -0,0 +1,118 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The mailbox's own OpenPGP key (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*): generated here, its secret
7
+ * key sealed under the master key like every other private key, its certificate enrolled with restapi - which publishes it on the
8
+ * RapidMX discovery endpoint, the Web Key Directory and in an `Autocrypt` header, so OpenPGP users (Proton Mail and others) can
9
+ * encrypt to the mailbox. S/MIME stays the mailbox's own format; this key is for correspondents who can't receive S/MIME.
10
+ */
11
+ import type { ApiClient } from "../util/api.js";
12
+ import { type KeyVault, OPENPGP_KEY_TYPE, enrollKey, getEncryptionPolicy, getKeyVault, isOpenPgpKey, recordKeyExport } from "./keyvaultApi.js";
13
+ import { addOpenPgpKey, getUnlockedKeys, OPENPGP_PRIVATE_KEY_AAD_PURPOSE, openMasterKeyWithPassword } from "./keySession.js";
14
+ import type { OpenPgpPrivateKey } from "./openpgp.js";
15
+ import { buildAad, openWithKey, sealWithKey } from "./masterKey.js";
16
+
17
+ /**
18
+ * Generates an OpenPGP key for `mailboxAddress` (`openpgp.ts`'s `generateOpenPgpKey()`), seals its secret key under `masterKey`
19
+ * and enrolls it; restapi makes it the mailbox's active OpenPGP key, superseding any earlier one. Pass the vault's
20
+ * `masterKeyGeneration` read with `masterKey` as `expectedMasterKeyGeneration` (`409` when another device rotated it meanwhile).
21
+ * Resolves with the vault and the new key, ready to decrypt and sign with.
22
+ */
23
+ export async function provisionOpenPgpKey(
24
+ mailboxUid: string,
25
+ mailboxAddress: string,
26
+ masterKey: Uint8Array,
27
+ expectedMasterKeyGeneration?: number,
28
+ displayName?: string,
29
+ client?: ApiClient,
30
+ ): Promise<{ vault: KeyVault; key: { fingerprint: string; privateKey: OpenPgpPrivateKey } }> {
31
+ const { generateOpenPgpKey, readSecretKey } = await import("./openpgp.js");
32
+ const generated = await generateOpenPgpKey(mailboxAddress, displayName);
33
+ try {
34
+ const sealed = await sealWithKey(masterKey, generated.secretKey, buildAad(mailboxUid, OPENPGP_PRIVATE_KEY_AAD_PURPOSE));
35
+ const vault = await enrollKey(
36
+ mailboxUid,
37
+ {
38
+ useType: "encrypt",
39
+ type: OPENPGP_KEY_TYPE,
40
+ publicKey: generated.armoredPublicKey,
41
+ wrappedKey: { ciphertext: sealed.ciphertext, nonce: sealed.nonce, algorithm: "AES-256-GCM" },
42
+ ...(expectedMasterKeyGeneration !== undefined ? { expectedMasterKeyGeneration } : {}),
43
+ },
44
+ client,
45
+ );
46
+ return { vault, key: { fingerprint: generated.fingerprint, privateKey: await readSecretKey(generated.secretKey) } };
47
+ } finally {
48
+ generated.secretKey.fill(0);
49
+ }
50
+ }
51
+
52
+ /** Provisions already in flight, per mailbox - a second caller joins the first. */
53
+ const inFlight = new Map<string, Promise<boolean>>();
54
+
55
+ /**
56
+ * Gives an unlocked mailbox an OpenPGP key when it has none - how every mailbox, including one set up before OpenPGP was
57
+ * supported, gets one on its owner's next unlock. Resolves `true` when it enrolled one, `false` when there was nothing to do:
58
+ * the mailbox isn't unlocked, the server doesn't allow OpenPGP (`EncryptionPolicy.allowOpenPgp`), the vault isn't set up yet, or it
59
+ * already holds an OpenPGP key. Only the mailbox's owner may call this (restapi refuses anybody else's enrollment).
60
+ */
61
+ export function ensureOpenPgpKey(mailboxUid: string, mailboxAddress: string, displayName?: string, client?: ApiClient): Promise<boolean> {
62
+ const pending = inFlight.get(mailboxUid);
63
+ if (pending) {
64
+ return pending;
65
+ }
66
+ const run = (async (): Promise<boolean> => {
67
+ const unlocked = getUnlockedKeys(mailboxUid);
68
+ if (!unlocked || unlocked.shared || unlocked.openPgpKeys?.length) {
69
+ return false;
70
+ }
71
+ const policy = await getEncryptionPolicy(client);
72
+ if (policy.allowOpenPgp !== true) {
73
+ return false;
74
+ }
75
+ const vault = await getKeyVault(mailboxUid, client);
76
+ if (vault.wrappedKeys.length === 0 || vault.wrappedKeys.some((k) => isOpenPgpKey(k))) {
77
+ return false;
78
+ }
79
+ const { key } = await provisionOpenPgpKey(mailboxUid, mailboxAddress, unlocked.masterKey, vault.masterKeyGeneration, displayName, client);
80
+ addOpenPgpKey(mailboxUid, unlocked, key);
81
+ return true;
82
+ })().finally(() => inFlight.delete(mailboxUid));
83
+ inFlight.set(mailboxUid, run);
84
+ return run;
85
+ }
86
+
87
+ /**
88
+ * The mailbox's active OpenPGP secret key, armored and protected with `passphrase` - for a user who reads this mailbox's OpenPGP
89
+ * mail in another OpenPGP app too (Thunderbird, GnuPG...). Like the PKCS#12 export (`keyExport.ts`) it re-opens the vault with
90
+ * the master password, whatever the session, never touches the session's keys, and is recorded with restapi (kind `encrypt`).
91
+ * Throws when the mailbox has no OpenPGP key.
92
+ */
93
+ export async function exportOpenPgpSecretKey(
94
+ mailboxUid: string,
95
+ masterPassword: string,
96
+ passphrase: string,
97
+ client?: ApiClient,
98
+ ): Promise<{ armored: string; fingerprint: string }> {
99
+ const { readSecretKey, exportProtectedSecretKey } = await import("./openpgp.js");
100
+ const { vault, masterKey } = await openMasterKeyWithPassword(mailboxUid, masterPassword, client);
101
+ try {
102
+ // The newest OpenPGP key enrolled is the active one (restapi appends, and supersedes the earlier ones).
103
+ const wrapped = [...vault.wrappedKeys].reverse().find((k) => isOpenPgpKey(k));
104
+ if (!wrapped) {
105
+ throw new Error("This mailbox has no OpenPGP key to export.");
106
+ }
107
+ const raw = await openWithKey(masterKey, wrapped, buildAad(mailboxUid, OPENPGP_PRIVATE_KEY_AAD_PURPOSE));
108
+ try {
109
+ const armored = await exportProtectedSecretKey(await readSecretKey(raw), passphrase);
110
+ await recordKeyExport(mailboxUid, [{ kind: "encrypt", fingerprint: wrapped.fingerprint }], client);
111
+ return { armored, fingerprint: wrapped.fingerprint };
112
+ } finally {
113
+ raw.fill(0);
114
+ }
115
+ } finally {
116
+ masterKey.fill(0);
117
+ }
118
+ }