@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,87 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The temporary per-meeting key of a meeting with external guests (meet-plugin `specs/meeting_privacy.md` §3-4): a P-256 keypair made
7
+ * in the browser of whoever creates the meeting, with a self-signed certificate so the CMS code in `smime.ts` seals to it and opens with
8
+ * it unchanged. Every participant receives the private key in their link's `#k=` part; the organizer also keeps it sealed to their own
9
+ * zero-access certificate. A live AES-256-GCM key derived from it encrypts the meeting's signaling and relayed media. Shared by the
10
+ * organizer's calendar editor, meet-plugin and booking-plugin.
11
+ *
12
+ * Nothing here talks to the server: the private key never leaves the browser except inside a link or a CMS wrap.
13
+ */
14
+ import { fromBase64Url, toBase64Url } from "./encoding.js";
15
+ /** The `#` parameter of a link that carries a meeting key. */
16
+ export const MEETING_KEY_FRAGMENT_PARAM = "k";
17
+ /** HKDF `info` of the live key (`specs/meeting_privacy.md` §3). */
18
+ const LIVE_KEY_INFO = "rapidmx-meeting-live-v1";
19
+ const EC_PARAMS = { name: "ECDH", namedCurve: "P-256" };
20
+ async function loadX509() {
21
+ await import("reflect-metadata");
22
+ const x509 = await import("@peculiar/x509");
23
+ x509.cryptoProvider.set(crypto);
24
+ return x509;
25
+ }
26
+ async function sha256Hex(bytes) {
27
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
28
+ return Array.from(digest, (b) => b.toString(16).padStart(2, "0")).join("");
29
+ }
30
+ /** A new meeting key valid until `expiresAt` (its certificate's `notAfter`). */
31
+ export async function generateMeetingKey(expiresAt) {
32
+ const x509 = await loadX509();
33
+ // ECDSA to self-sign the certificate; the same P-256 key is re-imported as ECDH to open what is sealed to it (`importMeetingKey()`).
34
+ const keys = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]));
35
+ const cert = await x509.X509CertificateGenerator.createSelfSigned({
36
+ serialNumber: toHex(crypto.getRandomValues(new Uint8Array(16))),
37
+ name: "CN=meeting",
38
+ notBefore: new Date(Date.now() - 60000),
39
+ notAfter: expiresAt,
40
+ signingAlgorithm: { name: "ECDSA", hash: "SHA-256" },
41
+ keys,
42
+ });
43
+ const certificateDer = new Uint8Array(cert.rawData);
44
+ const pkcs8 = new Uint8Array(await crypto.subtle.exportKey("pkcs8", keys.privateKey));
45
+ return { pkcs8, certificateDer, fingerprint: await sha256Hex(certificateDer) };
46
+ }
47
+ function toHex(bytes) {
48
+ // A positive serial: the top bit clear.
49
+ const copy = Uint8Array.from(bytes);
50
+ copy[0] &= 0x7f;
51
+ return Array.from(copy, (b) => b.toString(16).padStart(2, "0")).join("");
52
+ }
53
+ /** The `#` part a guest's link carries for `pkcs8`: `k=<base64url>`. */
54
+ export function meetingKeyFragment(pkcs8) {
55
+ return `${MEETING_KEY_FRAGMENT_PARAM}=${toBase64Url(pkcs8)}`;
56
+ }
57
+ /** The meeting key in `hash` (a `location.hash`, with or without its `#`), or `undefined` when it carries none or it is malformed. */
58
+ export function readMeetingKeyFragment(hash) {
59
+ const params = new URLSearchParams(hash.replace(/^#/, ""));
60
+ const value = params.get(MEETING_KEY_FRAGMENT_PARAM);
61
+ if (!value || !/^[A-Za-z0-9_-]{16,1024}$/.test(value)) {
62
+ return undefined;
63
+ }
64
+ try {
65
+ return fromBase64Url(value);
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ }
71
+ /** The meeting key `pkcs8` as the CMS code opens with it, and its live key for meeting `meetingUid`. Rejects a key that isn't P-256. */
72
+ export async function importMeetingKey(pkcs8, certificateDer, meetingUid) {
73
+ // Extractable once, only to read its scalar for the live key; the key kept for CMS isn't.
74
+ const readable = await crypto.subtle.importKey("pkcs8", pkcs8, EC_PARAMS, true, ["deriveBits"]);
75
+ const jwk = await crypto.subtle.exportKey("jwk", readable);
76
+ /* v8 ignore next 3 -- an imported PKCS#8 private key always exports its scalar */
77
+ if (!jwk.d) {
78
+ throw new Error("This is not a meeting key.");
79
+ }
80
+ const privateKey = await crypto.subtle.importKey("pkcs8", pkcs8, EC_PARAMS, false, ["deriveBits"]);
81
+ return { decryptionKey: { certDer: certificateDer, privateKey }, liveKey: await deriveLiveKey(fromBase64Url(jwk.d), meetingUid) };
82
+ }
83
+ /** The AES-256-GCM live key of meeting `meetingUid` from `ikm`, the meeting key's private scalar. Non-extractable. */
84
+ export async function deriveLiveKey(ikm, meetingUid) {
85
+ const base = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveKey"]);
86
+ return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode(meetingUid), info: new TextEncoder().encode(LIVE_KEY_INFO) }, base, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
87
+ }
@@ -34,7 +34,9 @@
34
34
  * accepted or automatic rotation still verifies.
35
35
  */
36
36
  import { MimeAttachment, MimeHeaderField } from "./mime.js";
37
+ import { type DecryptionKey } from "./smime.js";
37
38
  import { ProtectedHeaders } from "./smimeMessage.js";
39
+ import type { OpenPgpPrivateKey } from "./openpgp.js";
38
40
  import { type PublicKey } from "./keyvaultApi.js";
39
41
  import { type SealKeyMaterial, type SealedVerificationState } from "./verificationSeal.js";
40
42
  export { signingKeyFingerprints } from "./keyvaultApi.js";
@@ -49,7 +51,7 @@ export { signingKeyFingerprints } from "./keyvaultApi.js";
49
51
  * `sealedState`, `signerFingerprint`, the recovered content, `liveState`/`liveSignatureFailureReason`, and
50
52
  * `laterCompromised` when the signer key has since been revoked as compromised. A UI should show it as "verified when
51
53
  * first opened" - distinct from the live verified states, and with a warning when `laterCompromised` is set. */
52
- export type MessageSecurityState = "encrypted" | "signed_verified" | "encrypted_verified" | "signed_unverified_signer" | "encrypted_unverified_signer" | "signature_failed" | "verified_at_first_open" | "unprotected";
54
+ export type MessageSecurityState = "encrypted" | "signed_verified" | "encrypted_verified" | "signed_unverified_signer" | "encrypted_unverified_signer" | "signature_failed" | "verified_at_first_open" | "protected_at_rest" | "unprotected";
53
55
  /** Why a message is `"signature_failed"`. `invalid_signature`: the CMS signature itself is malformed or
54
56
  * doesn't verify over the content (or a `multipart/signed` body doesn't hold exactly two parts).
55
57
  * `signer_key_changed`: pinned fingerprints were supplied and the signer certificate matches none of them, but it
@@ -92,6 +94,13 @@ export interface MessageSecurityResult {
92
94
  * since-rotated key, non-AEAD content encryption, or corrupt/foreign data) - the caller should keep
93
95
  * showing whatever `GET /:id/content` already produced alongside this explanation. */
94
96
  decryptError?: string;
97
+ /** Set when the stored message was sealed at rest by the server (restapi `specs/search.md` §3) and opened here with the mailbox's
98
+ * zero-access key. `state` then describes the original message inside: `protected_at_rest` when it arrived unprotected, or its own
99
+ * signed/encrypted state otherwise. */
100
+ zeroAccess?: boolean;
101
+ /** With `zeroAccess`: the original's calendar file (`extractCalendarText()`), which the server can't read from the sealed copy - the
102
+ * client sends it with the invitation endpoints. Absent when the message carries none. */
103
+ calendar?: string;
95
104
  /** See `ParsedEncryptedMessage.headerTamperDetected`'s own doc comment - a deliberately separate,
96
105
  * orthogonal signal from `state`, since RFC 9788's `HP-Outer` is written on every encrypted message
97
106
  * regardless of whether it's also signed. `undefined` for anything that isn't an encrypted message
@@ -118,6 +127,14 @@ export interface MessageSecurityResult {
118
127
  signerFingerprint?: string;
119
128
  /** The email addresses that signer certificate asserts (lowercased), alongside `signerFingerprint`. */
120
129
  signerEmails?: string[];
130
+ /** `"openpgp"` when the signer is an OpenPGP key: `signerCertificate` is then its certificate (base64 binary) and
131
+ * `signerFingerprint` its primary key's fingerprint - pin it with `trustSigner()`'s `type: "openpgp"`. */
132
+ signerKeyType?: "openpgp";
133
+ /** `true` for an OpenPGP message whose signature was made by a key this reader doesn't have (neither pinned for the sender,
134
+ * 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
135
+ * `"unprotected"` (its content is readable anyway), an encrypted one `"encrypted"`; a UI can say the sender's key isn't known
136
+ * yet. */
137
+ signerKeyUnknown?: boolean;
121
138
  /** Base64 DER of the certificate that verified the signature - what `keyvaultApi.ts`'s `trustSigner()` pins for
122
139
  * a "trust this signer" action. Present alongside `signerFingerprint` for `"signed_verified"`,
123
140
  * `"encrypted_verified"`, the `*_unverified_signer` states, and `"signature_failed"` with reason
@@ -190,7 +207,7 @@ export declare function checkSignerBinding(input: SignerBindingInput): Promise<S
190
207
  * An encrypted message is decrypted with `unlocked`'s active encryption key or any of its `retainedEncryptionKeys` (the
191
208
  * older keys a rotation replaced - see `keySession.ts`), matched by recipient identifier first.
192
209
  */
193
- export declare function evaluateMessageSecurity(rawMime: string, unlocked: MessageSecurityKeys | undefined, pinnedSignerFingerprints?: string | string[], readerAddress?: string): Promise<MessageSecurityResult>;
210
+ export declare function evaluateMessageSecurity(rawMime: string, unlocked: MessageSecurityKeys | undefined, pinnedSignerFingerprints?: SignerPinsInput, readerAddress?: string): Promise<MessageSecurityResult>;
194
211
  /** The keys `evaluateMessageSecurity()` reads from an unlocked session (`keySession.ts`'s `UnlockedKeys`). */
195
212
  export interface MessageSecurityKeys {
196
213
  encryptionPrivateKey?: CryptoKey;
@@ -200,7 +217,43 @@ export interface MessageSecurityKeys {
200
217
  privateKey: CryptoKey;
201
218
  }[];
202
219
  signingFingerprint?: string;
220
+ /** The mailbox's zero-access keys (`keySession.ts`'s `UnlockedKeys.zeroAccessKeys`) - what opens a message the server sealed at
221
+ * rest on arrival. */
222
+ zeroAccessKeys?: {
223
+ certDer: Uint8Array;
224
+ privateKey: CryptoKey;
225
+ }[];
226
+ /** The mailbox's OpenPGP secret keys (`keySession.ts`'s `UnlockedKeys.openPgpKeys`), the active one first - what opens mail an
227
+ * OpenPGP user encrypted to the mailbox, and verifies mail it signed itself. */
228
+ openPgpKeys?: {
229
+ fingerprint: string;
230
+ privateKey: OpenPgpPrivateKey;
231
+ }[];
232
+ }
233
+ /** The sender's trusted keys, when they include OpenPGP certificates: `fingerprints` as `pinnedSignerFingerprints`, and
234
+ * `openPgpKeys` the pinned OpenPGP certificates themselves (base64, `PublicKey.publicKey`) - an OpenPGP signature carries only
235
+ * a key ID, so its key has to be supplied to be checked at all (`pinnedSigners.ts`'s `getPinnedSigners()`). */
236
+ export interface SignerPins {
237
+ fingerprints: string[];
238
+ openPgpKeys: string[];
203
239
  }
240
+ /** What `pinnedSignerFingerprints` accepts: one fingerprint, several, or `SignerPins`. */
241
+ export type SignerPinsInput = string | string[] | SignerPins | undefined;
242
+ /**
243
+ * The subject and preview of a zero-access message (`Message.zeroAccess.sealedSummary`, base64 CMS of JSON `{ subject, preview }`),
244
+ * decrypted with the mailbox's zero-access keys - a few hundred bytes, so a message list shows real subjects without downloading
245
+ * each message. `undefined` when no key opens it or it isn't a summary.
246
+ */
247
+ export declare function openZeroAccessSummary(sealedSummary: string, keys: DecryptionKey[] | undefined): Promise<{
248
+ subject: string;
249
+ preview: string;
250
+ } | undefined>;
251
+ /**
252
+ * The original MIME (as a binary string) of a stored message the server sealed at rest, opened with `unlocked.zeroAccessKeys` -
253
+ * `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
254
+ * filter (`reportMessage()`'s `rawMessage`).
255
+ */
256
+ export declare function openZeroAccessMessage(rawMime: string, unlocked: MessageSecurityKeys | undefined): Promise<string | undefined>;
204
257
  /** Options for `evaluateMessageSecurityWithSeal()`. */
205
258
  export interface VerificationSealOptions {
206
259
  mailboxUid: string;
@@ -248,4 +301,4 @@ export interface VerificationSealOptions {
248
301
  * `unlocked` was destroyed (`keySession.ts`), before evaluating anything, or if it is destroyed while sealing; otherwise
249
302
  * never throws - a seal that can't be built (e.g. uids too long for the size limit) just leaves `sealToWrite` unset.
250
303
  */
251
- export declare function evaluateMessageSecurityWithSeal(rawMime: string, unlocked: (MessageSecurityKeys & SealKeyMaterial) | undefined, pinnedSignerFingerprints: string | string[] | undefined, readerAddress: string | undefined, options: VerificationSealOptions): Promise<MessageSecurityResult>;
304
+ export declare function evaluateMessageSecurityWithSeal(rawMime: string, unlocked: (MessageSecurityKeys & SealKeyMaterial) | undefined, pinnedSignerFingerprints: SignerPinsInput, readerAddress: string | undefined, options: VerificationSealOptions): Promise<MessageSecurityResult>;
@@ -37,10 +37,10 @@
37
37
  * `untrusted_signer`. Previously pinned keys count as pins (see `signingKeyFingerprints()`), so mail signed before an
38
38
  * accepted or automatic rotation still verifies.
39
39
  */
40
- import { decodeHeaderText, extractAddresses, parseMimeEntity, parseParameterizedHeader, plainTextToHtml } from "./mime.js";
41
- import { toBase64 } from "./encoding.js";
42
- import { computeCertFingerprint, extractCertificateEmails } from "./smime.js";
43
- import { parseEncryptedMessageWithKeys, parseSignedOnlyMessage } from "./smimeMessage.js";
40
+ import { bytesToBinaryString, decodeBodyText, decodeBase64Text, decodeHeaderText, extractAddresses, extractAttachments, extractCalendarText, extractDisplayBody, parseMimeEntity, parseParameterizedHeader, plainTextToHtml, } from "./mime.js";
41
+ import { fromBase64, toBase64 } from "./encoding.js";
42
+ import { computeCertFingerprint, decryptEnvelopedDataWithKeys, extractCertificateEmails } from "./smime.js";
43
+ import { extractHpOuterHeaders, headersToProtectedHeaders, outerHeadersMatch, parseEncryptedMessageWithKeys, parseSignedOnlyMessage, } from "./smimeMessage.js";
44
44
  import { isTrustedForVerification } from "./keyvaultApi.js";
45
45
  import { KeysLockedError } from "./masterKey.js";
46
46
  import { buildVerificationSeal, openVerificationSeal, rawMimeSha256 } from "./verificationSeal.js";
@@ -103,7 +103,11 @@ function sameInstant(signed, outer) {
103
103
  return Number.isNaN(left) || left === Date.parse(String(outer));
104
104
  }
105
105
  function normalizePins(pins) {
106
- return (Array.isArray(pins) ? pins : pins === undefined ? [] : [pins]).map((pin) => pin.toLowerCase());
106
+ const list = Array.isArray(pins) ? pins : pins === undefined ? [] : typeof pins === "string" ? [pins] : pins.fingerprints;
107
+ return list.map((pin) => pin.toLowerCase());
108
+ }
109
+ function pinnedOpenPgpKeys(pins) {
110
+ return pins !== undefined && typeof pins === "object" && !Array.isArray(pins) ? pins.openPgpKeys : [];
107
111
  }
108
112
  /** Checks 2-4 from this module's doc comment for an already cryptographically verified signature.
109
113
  * Returns `undefined` when the signer is accepted, otherwise the failure reason. A pin mismatch is
@@ -120,12 +124,13 @@ export async function checkSignerBinding(input) {
120
124
  if (pins.length > 0 && !pins.includes(await computeCertFingerprint(certDer))) {
121
125
  // A certificate that otherwise binds cleanly to the sender is a key change for the user to judge; anything else
122
126
  // (no certificate, wrong identity, header tampering) stays a plain untrusted signer.
123
- return input.signerCertificateDer && checkIdentityAndHeaders(input, certDer) === undefined ? "signer_key_changed" : "untrusted_signer";
127
+ return input.signerCertificateDer && checkIdentityAndHeaders(input, extractCertificateEmails(certDer)) === undefined ? "signer_key_changed" : "untrusted_signer";
124
128
  }
125
- return checkIdentityAndHeaders(input, certDer);
129
+ return checkIdentityAndHeaders(input, extractCertificateEmails(certDer));
126
130
  }
127
- /** Checks 3-4 from this module's doc comment. */
128
- function checkIdentityAndHeaders(input, certDer) {
131
+ /** Checks 3-4 from this module's doc comment, for a signer whose key names `signerEmails` (an X.509 certificate's, or an
132
+ * OpenPGP key's user IDs). */
133
+ function checkIdentityAndHeaders(input, signerEmails) {
129
134
  const { protectedHeaders, outerHeaders } = input;
130
135
  if (hasRepeatedAddressField(input.outerFields) || hasRepeatedAddressField(input.protectedFields)) {
131
136
  return "header_mismatch";
@@ -133,7 +138,7 @@ function checkIdentityAndHeaders(input, certDer) {
133
138
  const outerFrom = extractAddresses(outerHeaders["from"]);
134
139
  const protectedFrom = extractAddresses(protectedHeaders?.from);
135
140
  const senderAddresses = protectedFrom.length > 0 ? protectedFrom : outerFrom;
136
- if (senderAddresses.length !== 1 || !extractCertificateEmails(certDer).includes(senderAddresses[0])) {
141
+ if (senderAddresses.length !== 1 || !signerEmails.includes(senderAddresses[0])) {
137
142
  return "signer_identity_mismatch";
138
143
  }
139
144
  const protectedTo = extractAddresses(protectedHeaders?.to);
@@ -174,13 +179,66 @@ function checkIdentityAndHeaders(input, certDer) {
174
179
  export async function evaluateMessageSecurity(rawMime, unlocked, pinnedSignerFingerprints, readerAddress) {
175
180
  return (await evaluateLive(rawMime, unlocked, pinnedSignerFingerprints, readerAddress)).result;
176
181
  }
177
- async function evaluateLive(rawMime, unlocked, pinnedSignerFingerprints, readerAddress) {
178
- const { headers, body, fields } = parseMimeEntity(rawMime);
182
+ /**
183
+ * The subject and preview of a zero-access message (`Message.zeroAccess.sealedSummary`, base64 CMS of JSON `{ subject, preview }`),
184
+ * decrypted with the mailbox's zero-access keys - a few hundred bytes, so a message list shows real subjects without downloading
185
+ * each message. `undefined` when no key opens it or it isn't a summary.
186
+ */
187
+ export async function openZeroAccessSummary(sealedSummary, keys) {
188
+ if (!keys?.length) {
189
+ return undefined;
190
+ }
191
+ try {
192
+ const json = JSON.parse(new TextDecoder().decode(await decryptEnvelopedDataWithKeys(fromBase64(sealedSummary), keys)));
193
+ const { subject, preview } = (json ?? {});
194
+ return typeof subject === "string" && typeof preview === "string" ? { subject, preview } : undefined;
195
+ }
196
+ catch {
197
+ return undefined;
198
+ }
199
+ }
200
+ /**
201
+ * The original MIME (as a binary string) of a stored message the server sealed at rest, opened with `unlocked.zeroAccessKeys` -
202
+ * `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
203
+ * filter (`reportMessage()`'s `rawMessage`).
204
+ */
205
+ export async function openZeroAccessMessage(rawMime, unlocked) {
206
+ const { headers, body } = parseMimeEntity(rawMime);
207
+ const rawContentType = headers["content-type"];
208
+ if (!unlocked?.zeroAccessKeys?.length || !rawContentType || !isEncryptedContentType(parseParameterizedHeader(rawContentType))) {
209
+ return undefined;
210
+ }
211
+ return openZeroAccess(body, unlocked.zeroAccessKeys);
212
+ }
213
+ /**
214
+ * The original MIME of a message the server sealed at rest (restapi `specs/search.md` §3) - the stored S/MIME `enveloped-data`
215
+ * message's content, decrypted with one of `keys` - or `undefined` when none of them opens it. Only the server ever encrypts to a
216
+ * zero-access key (it is never published), so a message that opens with one was sealed at rest, never sent end-to-end.
217
+ */
218
+ async function openZeroAccess(body, keys) {
219
+ const der = decodeBase64Text(body);
220
+ if (!der) {
221
+ return undefined;
222
+ }
223
+ try {
224
+ return bytesToBinaryString(await decryptEnvelopedDataWithKeys(der, keys));
225
+ }
226
+ catch {
227
+ return undefined;
228
+ }
229
+ }
230
+ async function evaluateLive(rawMime, unlocked, pinnedSignerFingerprints, readerAddress, sealedAtRest = false) {
231
+ const outerEntity = parseMimeEntity(rawMime);
232
+ const { headers, body, fields } = outerEntity;
179
233
  const rawContentType = headers["content-type"];
234
+ const contentType = parseParameterizedHeader(rawContentType ?? "text/plain");
235
+ const pgp = pgpKind(contentType, outerEntity);
236
+ if (pgp) {
237
+ return await evaluatePgp(pgp, rawContentType ?? "text/plain", outerEntity, unlocked, pinnedSignerFingerprints, readerAddress);
238
+ }
180
239
  if (!rawContentType) {
181
240
  return { result: { state: "unprotected" }, kind: "other" };
182
241
  }
183
- const contentType = parseParameterizedHeader(rawContentType);
184
242
  const callerPins = normalizePins(pinnedSignerFingerprints);
185
243
  // The received message's own *real* outer envelope - what `HP-Outer`'s field copies (written at
186
244
  // send time, inside the encrypted content) are compared against to detect post-send tampering.
@@ -248,6 +306,34 @@ async function evaluateLive(rawMime, unlocked, pinnedSignerFingerprints, readerA
248
306
  kind: "signed",
249
307
  };
250
308
  }
309
+ // A message the server sealed at rest: evaluate the original it holds - which may itself be signed or end-to-end encrypted -
310
+ // and report one that was unprotected when it arrived as `protected_at_rest`, with its content.
311
+ if (isEncryptedContentType(contentType) && !sealedAtRest && unlocked?.zeroAccessKeys?.length) {
312
+ const original = await openZeroAccess(body, unlocked.zeroAccessKeys);
313
+ if (original !== undefined) {
314
+ const inner = await evaluateLive(original, unlocked, pinnedSignerFingerprints, readerAddress, true);
315
+ const entity = parseMimeEntity(original);
316
+ const subject = entity.headers["subject"];
317
+ const calendarText = extractCalendarText(entity);
318
+ const calendar = calendarText !== undefined ? { calendar: calendarText } : {};
319
+ if (inner.result.state !== "unprotected") {
320
+ // The stored message's own Subject is the placeholder: the original's stands in when the inner result has none.
321
+ const fallbackSubject = inner.result.subject === undefined && subject !== undefined ? { subject: decodeHeaderText(subject) } : {};
322
+ return { ...inner, result: { ...inner.result, ...fallbackSubject, ...calendar, zeroAccess: true } };
323
+ }
324
+ return {
325
+ result: {
326
+ state: "protected_at_rest",
327
+ zeroAccess: true,
328
+ ...calendar,
329
+ ...renderDisplayBody(extractDisplayBody(entity)),
330
+ ...(subject !== undefined ? { subject: decodeHeaderText(subject) } : {}),
331
+ attachments: extractAttachments(entity),
332
+ },
333
+ kind: "other",
334
+ };
335
+ }
336
+ }
251
337
  if (isEncryptedContentType(contentType)) {
252
338
  // The active key first, then the retained older ones (see `keySession.ts`'s `retainedEncryptionKeys`), so mail
253
339
  // encrypted to a key since replaced still opens. Only ever used to decrypt here - never to encrypt or sign.
@@ -290,6 +376,108 @@ async function evaluateLive(rawMime, unlocked, pinnedSignerFingerprints, readerA
290
376
  }
291
377
  return { result: { state: "unprotected" }, kind: "other" };
292
378
  }
379
+ const INLINE_PGP_MESSAGE = /^-----BEGIN PGP MESSAGE-----\r?\n[\s\S]*-----END PGP MESSAGE-----$/;
380
+ const INLINE_PGP_SIGNED = /^-----BEGIN PGP SIGNED MESSAGE-----\r?\n[\s\S]*-----END PGP SIGNATURE-----$/;
381
+ function pgpKind(contentType, entity) {
382
+ const protocol = contentType.params["protocol"]?.toLowerCase();
383
+ if (contentType.value === "multipart/signed" && protocol === "application/pgp-signature") {
384
+ return "signed";
385
+ }
386
+ if (contentType.value === "multipart/encrypted" && protocol === "application/pgp-encrypted") {
387
+ return "encrypted";
388
+ }
389
+ if (contentType.value !== "text/plain") {
390
+ return undefined;
391
+ }
392
+ const text = decodeBodyText(entity).trim();
393
+ return INLINE_PGP_MESSAGE.test(text) ? "inline-encrypted" : INLINE_PGP_SIGNED.test(text) ? "inline-signed" : undefined;
394
+ }
395
+ /**
396
+ * `evaluateLive()` for an OpenPGP message (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*), with the same
397
+ * trust rules as S/MIME: a signature by a key pinned for the sender (`SignerPins.openPgpKeys`) or the reader's own is verified;
398
+ * one by a key only attached to the message is `*_unverified_signer` - or `signer_key_changed` when the sender has a different key
399
+ * pinned; and the key must name the sender, with the protected headers matching the outer ones (`checkIdentityAndHeaders()`).
400
+ * A signature nobody's key can check is reported through `signerKeyUnknown`, never as failed. The crypto is `pgpMessage.ts`'s,
401
+ * loaded only here.
402
+ */
403
+ async function evaluatePgp(kind, rawContentType, outer, unlocked, pins, readerAddress) {
404
+ const pgp = await import("./pgpMessage.js");
405
+ const pinned = (await pgp.readKeysBase64(pinnedOpenPgpKeys(pins))).map((key) => ({ key, trusted: true }));
406
+ const ownKeys = unlocked?.openPgpKeys ?? [];
407
+ const candidates = [...pinned, ...ownKeys.map(({ privateKey }) => ({ key: privateKey.toPublic(), trusted: true }))];
408
+ const decryptionKeys = ownKeys.map(({ privateKey }) => privateKey);
409
+ const encrypted = kind === "encrypted" || kind === "inline-encrypted";
410
+ const text = decodeBodyText(outer).trim();
411
+ let parsed;
412
+ if (kind === "signed") {
413
+ parsed = await pgp.parsePgpSignedMessage(rawContentType, outer.body, candidates);
414
+ }
415
+ else if (kind === "encrypted") {
416
+ parsed = await pgp.parsePgpEncryptedMessage(rawContentType, outer.body, decryptionKeys, candidates);
417
+ }
418
+ else if (kind === "inline-encrypted") {
419
+ parsed = await pgp.parsePgpInlineEncrypted(text, decryptionKeys, candidates);
420
+ }
421
+ else {
422
+ parsed = await pgp.parsePgpCleartextSigned(text, candidates);
423
+ }
424
+ const resultKind = encrypted ? "encrypted" : "signed";
425
+ if (encrypted && !parsed.decrypted) {
426
+ return { result: { state: "encrypted", decryptError: NO_KEY_ERROR }, kind: resultKind };
427
+ }
428
+ if (parsed.entityText === undefined) {
429
+ return { result: { state: "signature_failed", signatureFailureReason: "invalid_signature" }, kind: resultKind };
430
+ }
431
+ const entity = parseMimeEntity(parsed.entityText);
432
+ const inline = kind === "inline-encrypted" || kind === "inline-signed";
433
+ const protectedHeaders = inline ? undefined : headersToProtectedHeaders(entity.headers);
434
+ const hasProtected = !!protectedHeaders && extractAddresses(protectedHeaders.from).length > 0;
435
+ const hpOuter = encrypted && !inline ? extractHpOuterHeaders(entity.fields) : undefined;
436
+ const outerComparable = { from: outer.headers["from"], to: outer.headers["to"], cc: outer.headers["cc"], date: outer.headers["date"], subject: outer.headers["subject"] };
437
+ const recipients = [...extractAddresses(protectedHeaders?.to), ...extractAddresses(protectedHeaders?.cc)];
438
+ const content = {
439
+ ...renderDisplayBody(extractDisplayBody(entity)),
440
+ subject: (hasProtected ? protectedHeaders?.subject : undefined) || undefined,
441
+ attachments: extractAttachments(entity),
442
+ ...(hpOuter ? { headerTamperDetected: !outerHeadersMatch(hpOuter, outerComparable) } : {}),
443
+ ...(readerAddress && recipients.length > 0 ? { notAddressedToReader: !recipients.includes(readerAddress.trim().toLowerCase()) } : {}),
444
+ };
445
+ const signature = parsed.signature;
446
+ if (!signature) {
447
+ // Encrypted without a signature (an unsigned signed-only message can't happen: it is the signature).
448
+ return { result: { state: "encrypted", ...content }, kind: resultKind };
449
+ }
450
+ if (signature.status === "unknown_key") {
451
+ return { result: encrypted ? { state: "encrypted", ...content, signerKeyUnknown: true } : { state: "unprotected", signerKeyUnknown: true }, kind: resultKind };
452
+ }
453
+ if (signature.status === "invalid") {
454
+ return { result: { state: "signature_failed", signatureFailureReason: "invalid_signature", ...(encrypted ? content : {}) }, kind: resultKind };
455
+ }
456
+ const signer = { signerFingerprint: signature.fingerprint, signerEmails: signature.emails, signerKeyType: "openpgp" };
457
+ const acceptedSigner = { ...signer, signerCertificate: toBase64(signature.candidate.key.write()) };
458
+ const signedHeaders = hasProtected
459
+ ? { protectedHeaders: { from: protectedHeaders.from, to: protectedHeaders.to, ...(protectedHeaders.cc !== undefined ? { cc: protectedHeaders.cc } : {}), subject: protectedHeaders.subject } }
460
+ : {};
461
+ const failure = checkIdentityAndHeaders({
462
+ protectedHeaders: hasProtected ? protectedHeaders : undefined,
463
+ outerHeaders: outer.headers,
464
+ outerFields: outer.fields,
465
+ protectedFields: hasProtected ? entity.fields : undefined,
466
+ compareSubject: !encrypted,
467
+ }, signature.emails);
468
+ // Signed by a key only attached to the message while the sender has OpenPGP keys pinned: they appear to use a new key.
469
+ const keyChanged = !failure && !signature.candidate.trusted && pinned.length > 0;
470
+ if (failure || keyChanged) {
471
+ const reason = failure ?? "signer_key_changed";
472
+ return {
473
+ result: { state: "signature_failed", signatureFailureReason: reason, ...(encrypted ? content : {}), ...(reason === "signer_key_changed" ? acceptedSigner : signer) },
474
+ kind: resultKind,
475
+ ...(reason === "signer_key_changed" ? { signedContent: { ...content, ...signedHeaders } } : {}),
476
+ };
477
+ }
478
+ const state = signature.candidate.trusted ? (encrypted ? "encrypted_verified" : "signed_verified") : encrypted ? "encrypted_unverified_signer" : "signed_unverified_signer";
479
+ return { result: { state, ...content, ...signedHeaders, ...acceptedSigner }, kind: resultKind };
480
+ }
293
481
  const LIVE_VERIFIED_STATES = ["signed_verified", "encrypted_verified"];
294
482
  /** Whether a live result failed only because of the signer key's status - the cases a seal may override. Every other
295
483
  * failure (an invalid signature, identity or header mismatch, `untrusted_signer`, HP-Outer tampering) never is. */
@@ -135,6 +135,11 @@ export interface MimeAttachment {
135
135
  * reported as one attachment, never expanded.
136
136
  */
137
137
  export declare function extractAttachments(entity: MimeEntity, depth?: number): MimeAttachment[];
138
+ /**
139
+ * The text of the first calendar file in `entity` (descending into multipart children, bounded depth): a `text/calendar` or
140
+ * `application/ics` leaf, whether a body alternative (as Outlook sends an invitation) or an attached `.ics`. `undefined` when there is none.
141
+ */
142
+ export declare function extractCalendarText(entity: MimeEntity, depth?: number): string | undefined;
138
143
  /** HTML-escapes plain text and wraps it in a whitespace-preserving `<pre>`, so a `text/plain` body can be
139
144
  * rendered through the same HTML path without ever being interpreted as markup. */
140
145
  export declare function plainTextToHtml(text: string): string;
@@ -478,6 +478,31 @@ export function extractAttachments(entity, depth = 0) {
478
478
  },
479
479
  ];
480
480
  }
481
+ /**
482
+ * The text of the first calendar file in `entity` (descending into multipart children, bounded depth): a `text/calendar` or
483
+ * `application/ics` leaf, whether a body alternative (as Outlook sends an invitation) or an attached `.ics`. `undefined` when there is none.
484
+ */
485
+ export function extractCalendarText(entity, depth = 0) {
486
+ const contentType = parseParameterizedHeader(entity.headers["content-type"] ?? "text/plain");
487
+ if (contentType.value.startsWith("multipart/")) {
488
+ const boundary = contentType.params["boundary"];
489
+ if (!boundary || depth >= MAX_MULTIPART_DEPTH) {
490
+ return undefined;
491
+ }
492
+ for (const part of splitMultipart(entity.body, boundary)) {
493
+ const found = extractCalendarText(parseMimeEntity(part), depth + 1);
494
+ if (found !== undefined) {
495
+ return found;
496
+ }
497
+ }
498
+ return undefined;
499
+ }
500
+ if (contentType.value !== "text/calendar" && contentType.value !== "application/ics") {
501
+ return undefined;
502
+ }
503
+ const bytes = decodeBodyBytes(entity);
504
+ return bytes ? new TextDecoder().decode(bytes) : undefined;
505
+ }
481
506
  /** HTML-escapes plain text and wraps it in a whitespace-preserving `<pre>`, so a `text/plain` body can be
482
507
  * rendered through the same HTML path without ever being interpreted as markup. */
483
508
  export function plainTextToHtml(text) {
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The mailbox's own OpenPGP key (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*): generated here, its secret
3
+ * key sealed under the master key like every other private key, its certificate enrolled with restapi - which publishes it on the
4
+ * RapidMX discovery endpoint, the Web Key Directory and in an `Autocrypt` header, so OpenPGP users (Proton Mail and others) can
5
+ * encrypt to the mailbox. S/MIME stays the mailbox's own format; this key is for correspondents who can't receive S/MIME.
6
+ */
7
+ import type { ApiClient } from "../util/api.js";
8
+ import { type KeyVault } from "./keyvaultApi.js";
9
+ import type { OpenPgpPrivateKey } from "./openpgp.js";
10
+ /**
11
+ * Generates an OpenPGP key for `mailboxAddress` (`openpgp.ts`'s `generateOpenPgpKey()`), seals its secret key under `masterKey`
12
+ * and enrolls it; restapi makes it the mailbox's active OpenPGP key, superseding any earlier one. Pass the vault's
13
+ * `masterKeyGeneration` read with `masterKey` as `expectedMasterKeyGeneration` (`409` when another device rotated it meanwhile).
14
+ * Resolves with the vault and the new key, ready to decrypt and sign with.
15
+ */
16
+ export declare function provisionOpenPgpKey(mailboxUid: string, mailboxAddress: string, masterKey: Uint8Array, expectedMasterKeyGeneration?: number, displayName?: string, client?: ApiClient): Promise<{
17
+ vault: KeyVault;
18
+ key: {
19
+ fingerprint: string;
20
+ privateKey: OpenPgpPrivateKey;
21
+ };
22
+ }>;
23
+ /**
24
+ * Gives an unlocked mailbox an OpenPGP key when it has none - how every mailbox, including one set up before OpenPGP was
25
+ * supported, gets one on its owner's next unlock. Resolves `true` when it enrolled one, `false` when there was nothing to do:
26
+ * the mailbox isn't unlocked, the server doesn't allow OpenPGP (`EncryptionPolicy.allowOpenPgp`), the vault isn't set up yet, or it
27
+ * already holds an OpenPGP key. Only the mailbox's owner may call this (restapi refuses anybody else's enrollment).
28
+ */
29
+ export declare function ensureOpenPgpKey(mailboxUid: string, mailboxAddress: string, displayName?: string, client?: ApiClient): Promise<boolean>;
30
+ /**
31
+ * The mailbox's active OpenPGP secret key, armored and protected with `passphrase` - for a user who reads this mailbox's OpenPGP
32
+ * mail in another OpenPGP app too (Thunderbird, GnuPG...). Like the PKCS#12 export (`keyExport.ts`) it re-opens the vault with
33
+ * the master password, whatever the session, never touches the session's keys, and is recorded with restapi (kind `encrypt`).
34
+ * Throws when the mailbox has no OpenPGP key.
35
+ */
36
+ export declare function exportOpenPgpSecretKey(mailboxUid: string, masterPassword: string, passphrase: string, client?: ApiClient): Promise<{
37
+ armored: string;
38
+ fingerprint: string;
39
+ }>;
@@ -0,0 +1,89 @@
1
+ import { OPENPGP_KEY_TYPE, enrollKey, getEncryptionPolicy, getKeyVault, isOpenPgpKey, recordKeyExport } from "./keyvaultApi.js";
2
+ import { addOpenPgpKey, getUnlockedKeys, OPENPGP_PRIVATE_KEY_AAD_PURPOSE, openMasterKeyWithPassword } from "./keySession.js";
3
+ import { buildAad, openWithKey, sealWithKey } from "./masterKey.js";
4
+ /**
5
+ * Generates an OpenPGP key for `mailboxAddress` (`openpgp.ts`'s `generateOpenPgpKey()`), seals its secret key under `masterKey`
6
+ * and enrolls it; restapi makes it the mailbox's active OpenPGP key, superseding any earlier one. Pass the vault's
7
+ * `masterKeyGeneration` read with `masterKey` as `expectedMasterKeyGeneration` (`409` when another device rotated it meanwhile).
8
+ * Resolves with the vault and the new key, ready to decrypt and sign with.
9
+ */
10
+ export async function provisionOpenPgpKey(mailboxUid, mailboxAddress, masterKey, expectedMasterKeyGeneration, displayName, client) {
11
+ const { generateOpenPgpKey, readSecretKey } = await import("./openpgp.js");
12
+ const generated = await generateOpenPgpKey(mailboxAddress, displayName);
13
+ try {
14
+ const sealed = await sealWithKey(masterKey, generated.secretKey, buildAad(mailboxUid, OPENPGP_PRIVATE_KEY_AAD_PURPOSE));
15
+ const vault = await enrollKey(mailboxUid, {
16
+ useType: "encrypt",
17
+ type: OPENPGP_KEY_TYPE,
18
+ publicKey: generated.armoredPublicKey,
19
+ wrappedKey: { ciphertext: sealed.ciphertext, nonce: sealed.nonce, algorithm: "AES-256-GCM" },
20
+ ...(expectedMasterKeyGeneration !== undefined ? { expectedMasterKeyGeneration } : {}),
21
+ }, client);
22
+ return { vault, key: { fingerprint: generated.fingerprint, privateKey: await readSecretKey(generated.secretKey) } };
23
+ }
24
+ finally {
25
+ generated.secretKey.fill(0);
26
+ }
27
+ }
28
+ /** Provisions already in flight, per mailbox - a second caller joins the first. */
29
+ const inFlight = new Map();
30
+ /**
31
+ * Gives an unlocked mailbox an OpenPGP key when it has none - how every mailbox, including one set up before OpenPGP was
32
+ * supported, gets one on its owner's next unlock. Resolves `true` when it enrolled one, `false` when there was nothing to do:
33
+ * the mailbox isn't unlocked, the server doesn't allow OpenPGP (`EncryptionPolicy.allowOpenPgp`), the vault isn't set up yet, or it
34
+ * already holds an OpenPGP key. Only the mailbox's owner may call this (restapi refuses anybody else's enrollment).
35
+ */
36
+ export function ensureOpenPgpKey(mailboxUid, mailboxAddress, displayName, client) {
37
+ const pending = inFlight.get(mailboxUid);
38
+ if (pending) {
39
+ return pending;
40
+ }
41
+ const run = (async () => {
42
+ const unlocked = getUnlockedKeys(mailboxUid);
43
+ if (!unlocked || unlocked.shared || unlocked.openPgpKeys?.length) {
44
+ return false;
45
+ }
46
+ const policy = await getEncryptionPolicy(client);
47
+ if (policy.allowOpenPgp !== true) {
48
+ return false;
49
+ }
50
+ const vault = await getKeyVault(mailboxUid, client);
51
+ if (vault.wrappedKeys.length === 0 || vault.wrappedKeys.some((k) => isOpenPgpKey(k))) {
52
+ return false;
53
+ }
54
+ const { key } = await provisionOpenPgpKey(mailboxUid, mailboxAddress, unlocked.masterKey, vault.masterKeyGeneration, displayName, client);
55
+ addOpenPgpKey(mailboxUid, unlocked, key);
56
+ return true;
57
+ })().finally(() => inFlight.delete(mailboxUid));
58
+ inFlight.set(mailboxUid, run);
59
+ return run;
60
+ }
61
+ /**
62
+ * The mailbox's active OpenPGP secret key, armored and protected with `passphrase` - for a user who reads this mailbox's OpenPGP
63
+ * mail in another OpenPGP app too (Thunderbird, GnuPG...). Like the PKCS#12 export (`keyExport.ts`) it re-opens the vault with
64
+ * the master password, whatever the session, never touches the session's keys, and is recorded with restapi (kind `encrypt`).
65
+ * Throws when the mailbox has no OpenPGP key.
66
+ */
67
+ export async function exportOpenPgpSecretKey(mailboxUid, masterPassword, passphrase, client) {
68
+ const { readSecretKey, exportProtectedSecretKey } = await import("./openpgp.js");
69
+ const { vault, masterKey } = await openMasterKeyWithPassword(mailboxUid, masterPassword, client);
70
+ try {
71
+ // The newest OpenPGP key enrolled is the active one (restapi appends, and supersedes the earlier ones).
72
+ const wrapped = [...vault.wrappedKeys].reverse().find((k) => isOpenPgpKey(k));
73
+ if (!wrapped) {
74
+ throw new Error("This mailbox has no OpenPGP key to export.");
75
+ }
76
+ const raw = await openWithKey(masterKey, wrapped, buildAad(mailboxUid, OPENPGP_PRIVATE_KEY_AAD_PURPOSE));
77
+ try {
78
+ const armored = await exportProtectedSecretKey(await readSecretKey(raw), passphrase);
79
+ await recordKeyExport(mailboxUid, [{ kind: "encrypt", fingerprint: wrapped.fingerprint }], client);
80
+ return { armored, fingerprint: wrapped.fingerprint };
81
+ }
82
+ finally {
83
+ raw.fill(0);
84
+ }
85
+ }
86
+ finally {
87
+ masterKey.fill(0);
88
+ }
89
+ }