@rapidmx/web-client 0.36.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 (227) hide show
  1. package/apps/shared/calendar/calendarReminders.ts +35 -0
  2. package/apps/shared/calendar/sealedEvents.ts +42 -9
  3. package/apps/shared/calendar/useCalendarReminders.ts +28 -9
  4. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
  5. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +29 -1
  6. package/apps/shared/components/calendar/EventDetails.tsx +13 -2
  7. package/apps/shared/components/calendar/EventEditor.tsx +124 -17
  8. package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
  9. package/apps/shared/components/calendar/EventModal.tsx +19 -2
  10. package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
  11. package/apps/shared/components/calendar/eventForm.ts +2 -0
  12. package/apps/shared/components/contacts/ContactCard.tsx +13 -1
  13. package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
  14. package/apps/shared/components/contacts/ContactForm.tsx +56 -7
  15. package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
  16. package/apps/shared/components/contacts/contactCardData.ts +6 -3
  17. package/apps/shared/components/contacts/contactKeys.ts +3 -3
  18. package/apps/shared/components/layout/AppShell.tsx +34 -5
  19. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +11 -4
  20. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +37 -6
  21. package/apps/shared/components/mail/InviteCard.tsx +11 -1
  22. package/apps/shared/components/mail/MessageDetailPane.tsx +25 -14
  23. package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
  24. package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
  25. package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
  26. package/apps/shared/components/mail/invite/inviteStore.ts +11 -1
  27. package/apps/shared/components/mail/pinnedSigners.ts +14 -0
  28. package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
  29. package/apps/shared/components/settings/KeyExportSetting.tsx +4 -1
  30. package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
  31. package/apps/shared/components/settings/ZeroAccessSetting.tsx +13 -2
  32. package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
  33. package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
  34. package/apps/shared/mail/outbox/sendJob.ts +349 -50
  35. package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
  36. package/apps/shared/search/LocalIndexLifecycle.tsx +62 -33
  37. package/apps/shared/search/localIndexBuilder.ts +35 -24
  38. package/apps/shared/search/localIndexRpcClient.ts +91 -11
  39. package/apps/shared/search/localIndexSchema.ts +18 -2
  40. package/apps/shared/search/localIndexTransport.ts +6 -3
  41. package/apps/shared/search/localIndexWorker.ts +35 -18
  42. package/apps/shared/search/searchTier2.ts +5 -0
  43. package/apps/shared/search/zeroAccessIndexSync.ts +232 -40
  44. package/apps/shared/search/zeroAccessSyncState.ts +70 -0
  45. package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
  46. package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
  47. package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
  48. package/apps/www/_shell.tsx +2 -0
  49. package/apps/www/calendar/index.tsx +49 -5
  50. package/apps/www/contacts/index.tsx +67 -11
  51. package/apps/www/index.tsx +10 -1
  52. package/apps/www/settings/encryption/index.tsx +16 -1
  53. package/apps/www/settings/privacy/index.tsx +87 -38
  54. package/apps/www/tasks/index.tsx +24 -6
  55. package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
  56. package/dist/apps/shared/calendar/calendarReminders.js +26 -0
  57. package/dist/apps/shared/calendar/sealedEvents.d.ts +3 -2
  58. package/dist/apps/shared/calendar/sealedEvents.js +40 -9
  59. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
  60. package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
  61. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
  62. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +9 -2
  63. package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
  64. package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
  65. package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
  66. package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
  67. package/dist/apps/shared/components/calendar/EventModal.js +15 -2
  68. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
  69. package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
  70. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
  71. package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
  72. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
  73. package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
  74. package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
  75. package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
  76. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
  77. package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
  78. package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
  79. package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
  80. package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
  81. package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
  82. package/dist/apps/shared/components/layout/AppShell.js +17 -6
  83. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -4
  84. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +3 -1
  85. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +30 -4
  86. package/dist/apps/shared/components/mail/InviteCard.js +11 -1
  87. package/dist/apps/shared/components/mail/MessageDetailPane.js +15 -11
  88. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
  89. package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
  90. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
  91. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
  92. package/dist/apps/shared/components/mail/invite/inviteStore.js +11 -1
  93. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
  94. package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
  95. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
  96. package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
  97. package/dist/apps/shared/components/settings/KeyExportSetting.js +3 -1
  98. package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
  99. package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
  100. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +2 -0
  101. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +12 -3
  102. package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
  103. package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
  104. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  105. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  106. package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
  107. package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
  108. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
  109. package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
  110. package/dist/apps/shared/search/LocalIndexLifecycle.js +55 -33
  111. package/dist/apps/shared/search/localIndexBuilder.d.ts +4 -1
  112. package/dist/apps/shared/search/localIndexBuilder.js +31 -20
  113. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
  114. package/dist/apps/shared/search/localIndexRpcClient.js +126 -10
  115. package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
  116. package/dist/apps/shared/search/localIndexSchema.js +16 -2
  117. package/dist/apps/shared/search/localIndexTransport.d.ts +6 -3
  118. package/dist/apps/shared/search/localIndexWorker.d.ts +9 -0
  119. package/dist/apps/shared/search/localIndexWorker.js +22 -15
  120. package/dist/apps/shared/search/searchTier2.js +5 -0
  121. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +37 -6
  122. package/dist/apps/shared/search/zeroAccessIndexSync.js +199 -37
  123. package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
  124. package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
  125. package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
  126. package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
  127. package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
  128. package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
  129. package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
  130. package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
  131. package/dist/apps/www/_shell.d.ts +1 -1
  132. package/dist/apps/www/_shell.js +2 -2
  133. package/dist/apps/www/calendar/index.js +47 -5
  134. package/dist/apps/www/contacts/index.js +64 -11
  135. package/dist/apps/www/index.js +10 -1
  136. package/dist/apps/www/settings/encryption/index.js +14 -4
  137. package/dist/apps/www/settings/privacy/index.js +46 -5
  138. package/dist/apps/www/tasks/index.js +21 -7
  139. package/dist/lib/admin/domainsApi.d.ts +1 -1
  140. package/dist/lib/calendar/calendarApi.d.ts +46 -6
  141. package/dist/lib/calendar/calendarApi.js +110 -17
  142. package/dist/lib/calendar/calendarMutations.d.ts +20 -7
  143. package/dist/lib/calendar/calendarMutations.js +73 -14
  144. package/dist/lib/calendar/inviteApi.d.ts +17 -3
  145. package/dist/lib/calendar/inviteApi.js +61 -4
  146. package/dist/lib/calendar/protectedEvents.d.ts +31 -0
  147. package/dist/lib/calendar/protectedEvents.js +113 -0
  148. package/dist/lib/calendar/recurrence.d.ts +2 -0
  149. package/dist/lib/calendar/recurrence.js +16 -0
  150. package/dist/lib/calendar/sealedInvites.d.ts +6 -8
  151. package/dist/lib/calendar/sealedInvites.js +43 -6
  152. package/dist/lib/contacts/contactsApi.d.ts +29 -3
  153. package/dist/lib/contacts/contactsApi.js +23 -7
  154. package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
  155. package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
  156. package/dist/lib/crypto/composeSecurity.d.ts +11 -3
  157. package/dist/lib/crypto/composeSecurity.js +13 -5
  158. package/dist/lib/crypto/keyExport.js +2 -2
  159. package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
  160. package/dist/lib/crypto/keyPairMatch.js +65 -0
  161. package/dist/lib/crypto/keySession.d.ts +19 -2
  162. package/dist/lib/crypto/keySession.js +89 -13
  163. package/dist/lib/crypto/keyvaultApi.d.ts +41 -5
  164. package/dist/lib/crypto/keyvaultApi.js +13 -3
  165. package/dist/lib/crypto/meetingKeys.d.ts +25 -0
  166. package/dist/lib/crypto/meetingKeys.js +87 -0
  167. package/dist/lib/crypto/messageSecurity.d.ts +26 -2
  168. package/dist/lib/crypto/messageSecurity.js +122 -10
  169. package/dist/lib/crypto/openPgpKey.d.ts +39 -0
  170. package/dist/lib/crypto/openPgpKey.js +89 -0
  171. package/dist/lib/crypto/openpgp.d.ts +47 -0
  172. package/dist/lib/crypto/openpgp.js +88 -0
  173. package/dist/lib/crypto/pgpMessage.d.ts +51 -0
  174. package/dist/lib/crypto/pgpMessage.js +271 -0
  175. package/dist/lib/crypto/sharedMailboxKeys.d.ts +15 -3
  176. package/dist/lib/crypto/sharedMailboxKeys.js +102 -29
  177. package/dist/lib/crypto/smimeMessage.d.ts +21 -0
  178. package/dist/lib/crypto/smimeMessage.js +11 -7
  179. package/dist/lib/crypto/zeroAccessKey.js +5 -0
  180. package/dist/lib/mail/mailApi.d.ts +33 -0
  181. package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
  182. package/dist/lib/mail/mailboxImportApi.js +15 -0
  183. package/dist/lib/search/zeroAccessIndexApi.d.ts +4 -0
  184. package/dist/lib/tasks/tasksApi.d.ts +11 -1
  185. package/dist/lib/tasks/tasksApi.js +10 -4
  186. package/dist/lib/util/clientPlatform.d.ts +14 -0
  187. package/dist/lib/util/clientPlatform.js +26 -0
  188. package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
  189. package/dist/lib/videoconf/protectedMeetings.js +181 -0
  190. package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
  191. package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
  192. package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
  193. package/dist/lib/zeroAccess/sealedMoves.js +63 -0
  194. package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
  195. package/dist/lib/zeroAccess/sealedRecords.js +136 -0
  196. package/lib/admin/domainsApi.ts +1 -1
  197. package/lib/calendar/calendarApi.ts +140 -17
  198. package/lib/calendar/calendarMutations.ts +79 -12
  199. package/lib/calendar/inviteApi.ts +75 -5
  200. package/lib/calendar/protectedEvents.ts +143 -0
  201. package/lib/calendar/recurrence.ts +17 -0
  202. package/lib/calendar/sealedInvites.ts +47 -7
  203. package/lib/contacts/contactsApi.ts +43 -17
  204. package/lib/contacts/useContactPhotoSrc.ts +103 -13
  205. package/lib/crypto/composeSecurity.ts +20 -5
  206. package/lib/crypto/keyExport.ts +2 -2
  207. package/lib/crypto/keyPairMatch.ts +69 -0
  208. package/lib/crypto/keySession.ts +102 -12
  209. package/lib/crypto/keyvaultApi.ts +49 -7
  210. package/lib/crypto/meetingKeys.ts +118 -0
  211. package/lib/crypto/messageSecurity.ts +174 -13
  212. package/lib/crypto/openPgpKey.ts +118 -0
  213. package/lib/crypto/openpgp.ts +115 -0
  214. package/lib/crypto/pgpMessage.ts +336 -0
  215. package/lib/crypto/sharedMailboxKeys.ts +106 -28
  216. package/lib/crypto/smimeMessage.ts +11 -7
  217. package/lib/crypto/zeroAccessKey.ts +5 -0
  218. package/lib/mail/mailApi.ts +27 -0
  219. package/lib/mail/mailboxImportApi.ts +24 -0
  220. package/lib/search/zeroAccessIndexApi.ts +4 -0
  221. package/lib/tasks/tasksApi.ts +19 -5
  222. package/lib/util/clientPlatform.ts +50 -0
  223. package/lib/videoconf/protectedMeetings.ts +221 -0
  224. package/lib/videoconf/videoMeetingsApi.ts +15 -4
  225. package/lib/zeroAccess/sealedMoves.ts +79 -0
  226. package/lib/zeroAccess/sealedRecords.ts +153 -0
  227. package/package.json +12 -3
@@ -0,0 +1,115 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * OpenPGP keys (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*): the mailbox's own key, generated here, and the
7
+ * keys of correspondents who use OpenPGP (Proton Mail, Thunderbird, GnuPG). Built on OpenPGP.js - which runs on WebCrypto and audited
8
+ * curve code, never OpenSSL - and loaded only when OpenPGP is used: this module is only ever imported dynamically.
9
+ *
10
+ * S/MIME stays RapidMX's own format; an OpenPGP key exists so OpenPGP users can reach RapidMX users, and RapidMX users them.
11
+ */
12
+ import * as openpgp from "openpgp";
13
+ import { fromBase64, toBase64 } from "./encoding.js";
14
+
15
+ export type OpenPgpPrivateKey = openpgp.PrivateKey;
16
+ export type OpenPgpPublicKey = openpgp.PublicKey;
17
+
18
+ /** A freshly generated key: the public certificate to publish and the secret key to seal under the master key. */
19
+ export interface GeneratedOpenPgpKey {
20
+ /** Base64 of the binary transferable public key (`PublicKey.publicKey`). */
21
+ publicKey: string;
22
+ /** The same certificate, ASCII armored (what enrollment sends and the user can export). */
23
+ armoredPublicKey: string;
24
+ /** The binary transferable secret key, unencrypted - the caller seals it under the master key and zeroes this copy. */
25
+ secretKey: Uint8Array;
26
+ /** The primary key's fingerprint (40 hex characters for a v4 key, 64 for v6). */
27
+ fingerprint: string;
28
+ }
29
+
30
+ /** Which OpenPGP key version to generate: 4 (RFC 4880, the default - what every OpenPGP app reads, GnuPG included) or 6 (RFC 9580). */
31
+ export type OpenPgpKeyVersion = 4 | 6;
32
+
33
+ /**
34
+ * Generates the mailbox's OpenPGP key: an Ed25519 primary key with a Curve25519 encryption subkey and one user ID,
35
+ * `displayName <address>`, never expiring (rotation replaces it). A v4 key uses the long-established EdDSA/ECDH Curve25519
36
+ * algorithms; a v6 key RFC 9580's Ed25519/X25519. It carries no passphrase: like every other private key, its secret half is
37
+ * sealed under the master key before it leaves this device.
38
+ */
39
+ export async function generateOpenPgpKey(address: string, displayName?: string, version: OpenPgpKeyVersion = 4): Promise<GeneratedOpenPgpKey> {
40
+ const userIDs = [{ ...(displayName ? { name: displayName } : {}), email: address }];
41
+ const { privateKey, publicKey } =
42
+ version === 6
43
+ ? await openpgp.generateKey({ type: "curve25519", userIDs, format: "object", config: { v6Keys: true } })
44
+ : await openpgp.generateKey({ type: "ecc", curve: "curve25519Legacy", userIDs, format: "object" });
45
+ return {
46
+ publicKey: toBase64(publicKey.write()),
47
+ armoredPublicKey: publicKey.armor(),
48
+ secretKey: privateKey.write(),
49
+ fingerprint: publicKey.getFingerprint(),
50
+ };
51
+ }
52
+
53
+ /** A rotation's new key with its user ID certified by the key it replaces, so correspondents who pinned the old one accept the
54
+ * new one without asking (restapi `OpenPgpKeyUtils.canReplaceOpenPgpKeyAutomatically()`). */
55
+ export async function certifyWithPredecessor(successor: GeneratedOpenPgpKey, predecessor: OpenPgpPrivateKey): Promise<GeneratedOpenPgpKey> {
56
+ const secret = await openpgp.readPrivateKey({ binaryKey: successor.secretKey });
57
+ const certified = await secret.signPrimaryUser([predecessor]);
58
+ const publicKey = certified.toPublic();
59
+ return { ...successor, publicKey: toBase64(publicKey.write()), armoredPublicKey: publicKey.armor(), secretKey: certified.write() };
60
+ }
61
+
62
+ /** The secret key in `bytes` (a sealed `WrappedPrivateKey`'s plaintext), ready to decrypt and sign with. */
63
+ export async function readSecretKey(bytes: Uint8Array): Promise<OpenPgpPrivateKey> {
64
+ return await openpgp.readPrivateKey({ binaryKey: bytes });
65
+ }
66
+
67
+ /** The certificate in `base64` (`PublicKey.publicKey`), or `undefined` when it isn't one. */
68
+ export async function readPublicKey(base64: string): Promise<OpenPgpPublicKey | undefined> {
69
+ try {
70
+ return await openpgp.readKey({ binaryKey: fromBase64(base64) });
71
+ } catch {
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ /** Every certificate in an armored or binary key block (e.g. an attached `application/pgp-keys` part, or a pasted one). */
77
+ export async function readPublicKeys(block: string | Uint8Array): Promise<OpenPgpPublicKey[]> {
78
+ try {
79
+ const keys =
80
+ typeof block === "string" && block.includes("-----BEGIN PGP PUBLIC KEY BLOCK-----")
81
+ ? await openpgp.readKeys({ armoredKeys: block })
82
+ : await openpgp.readKeys({ binaryKeys: typeof block === "string" ? fromBase64(block.replace(/\s+/g, "")) : block });
83
+ return keys.filter((key) => !key.isPrivate());
84
+ } catch {
85
+ return [];
86
+ }
87
+ }
88
+
89
+ /** The email addresses a certificate's user IDs name with a verifying self-signature, lowercased. */
90
+ export async function keyEmails(key: OpenPgpPublicKey): Promise<string[]> {
91
+ const emails: string[] = [];
92
+ for (const user of key.users) {
93
+ const email = user.userID?.email;
94
+ if (email && (await user.verify().then(() => true, () => false))) {
95
+ emails.push(email.toLowerCase());
96
+ }
97
+ }
98
+ return emails;
99
+ }
100
+
101
+ /** A certificate's armored form, for export or for attaching to a message. */
102
+ export function armorPublicKey(key: OpenPgpPublicKey): string {
103
+ return key.armor();
104
+ }
105
+
106
+ /** A secret key armored and protected with `passphrase` - the export a user imports into another OpenPGP app. */
107
+ export async function exportProtectedSecretKey(key: OpenPgpPrivateKey, passphrase: string): Promise<string> {
108
+ const copy = await openpgp.readPrivateKey({ binaryKey: key.write() });
109
+ return (await openpgp.encryptKey({ privateKey: copy, passphrase })).armor();
110
+ }
111
+
112
+ /** `key`'s public certificate as base64 (`PublicKey.publicKey`). */
113
+ export function publicKeyBase64(key: OpenPgpPrivateKey | OpenPgpPublicKey): string {
114
+ return toBase64(key.toPublic().write());
115
+ }
@@ -0,0 +1,336 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * OpenPGP messages (RFC 3156 PGP/MIME, and the inline forms some clients still send) - the OpenPGP counterpart of
7
+ * `smimeMessage.ts`, for mail with OpenPGP users (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*). Only ever
8
+ * imported dynamically: OpenPGP.js is loaded when an OpenPGP message is read or sent.
9
+ *
10
+ * Sending writes PGP/MIME only: `multipart/encrypted` (signed and encrypted in one OpenPGP message, RFC 3156 §6.2) and
11
+ * detached `multipart/signed`. The inner entity carries the protected headers exactly as an S/MIME message does
12
+ * (`smimeMessage.ts`'s `protectedHeaderLines()`, RFC 9788), marked both `hp=` and `protected-headers="v1"` (the form OpenPGP
13
+ * mail clients recognise), and its body is base64 so the signed bytes survive transport.
14
+ *
15
+ * Reading recovers the inner entity and reports what the signature proved; the trust decision (pins, identity binding,
16
+ * header checks) is `messageSecurity.ts`'s, the same as for S/MIME.
17
+ */
18
+ import * as openpgp from "openpgp";
19
+ import { fromBase64 } from "./encoding.js";
20
+ import { bytesToBinaryString, binaryStringToBytes, decodeBodyText, parseMimeEntity, parseParameterizedHeader, splitMultipart } from "./mime.js";
21
+ import { type OpenPgpPrivateKey, type OpenPgpPublicKey, keyEmails, readPublicKeys } from "./openpgp.js";
22
+ import {
23
+ base64Wrapped,
24
+ flattenHeaderValue,
25
+ generateBoundary,
26
+ type MimePart,
27
+ type ProtectedHeaders,
28
+ protectedHeaderLines,
29
+ } from "./smimeMessage.js";
30
+
31
+ const CRLF = "\r\n";
32
+
33
+ /** Builds the inner entity: protected headers, then the body (base64) - wrapped with `attachKey` (an armored public key) in a
34
+ * `multipart/mixed` when one is given, so a correspondent without our key can import it. */
35
+ function innerEntity(bodyContentType: string, bodyText: string, headerBlock: string, hp: "cipher" | "clear", attachKey?: string): string {
36
+ const marks = `hp="${hp}"; protected-headers="v1"`;
37
+ const bodyPart = [
38
+ `Content-Type: ${flattenHeaderValue(bodyContentType)}`,
39
+ `Content-Transfer-Encoding: base64`,
40
+ "",
41
+ base64Wrapped(new TextEncoder().encode(bodyText)),
42
+ ].join(CRLF);
43
+ if (!attachKey) {
44
+ return [headerBlock, bodyPart.replace(/^Content-Type: ([^\r\n]*)/, `Content-Type: $1; ${marks}`)].join(CRLF);
45
+ }
46
+ const boundary = generateBoundary();
47
+ return [
48
+ headerBlock,
49
+ `Content-Type: multipart/mixed; boundary="${boundary}"; ${marks}`,
50
+ "",
51
+ `--${boundary}`,
52
+ bodyPart,
53
+ `--${boundary}`,
54
+ `Content-Type: application/pgp-keys; name="OpenPGP_public_key.asc"`,
55
+ `Content-Disposition: attachment; filename="OpenPGP_public_key.asc"`,
56
+ "",
57
+ attachKey.replace(/\r?\n/g, CRLF).trim(),
58
+ `--${boundary}--`,
59
+ ].join(CRLF);
60
+ }
61
+
62
+ /**
63
+ * An RFC 3156 `multipart/encrypted` message: the inner entity (protected headers, the body and - with `attachKey` - our public
64
+ * key) signed with `signingKey` when given and encrypted to every key in `recipientKeys` in one OpenPGP message. Per the spec's
65
+ * "Encrypt to Self", `recipientKeys` MUST include the sender's own key.
66
+ */
67
+ export async function buildPgpEncryptedMessage(
68
+ bodyContentType: string,
69
+ bodyText: string,
70
+ protectedHeaders: ProtectedHeaders,
71
+ outerHeaders: ProtectedHeaders,
72
+ recipientKeys: OpenPgpPublicKey[],
73
+ signingKey?: OpenPgpPrivateKey,
74
+ attachKey?: string,
75
+ ): Promise<MimePart> {
76
+ const entity = innerEntity(bodyContentType, bodyText, protectedHeaderLines(protectedHeaders, outerHeaders), "cipher", attachKey);
77
+ const armored = await openpgp.encrypt({
78
+ message: await openpgp.createMessage({ binary: new TextEncoder().encode(entity) }),
79
+ encryptionKeys: recipientKeys,
80
+ ...(signingKey ? { signingKeys: signingKey } : {}),
81
+ format: "armored",
82
+ });
83
+ const boundary = generateBoundary();
84
+ const body = [
85
+ `--${boundary}`,
86
+ `Content-Type: application/pgp-encrypted`,
87
+ `Content-Description: PGP/MIME version identification`,
88
+ "",
89
+ "Version: 1",
90
+ "",
91
+ `--${boundary}`,
92
+ `Content-Type: application/octet-stream; name="encrypted.asc"`,
93
+ `Content-Description: OpenPGP encrypted message`,
94
+ `Content-Disposition: inline; filename="encrypted.asc"`,
95
+ "",
96
+ armored.replace(/\r?\n/g, CRLF).trim(),
97
+ `--${boundary}--`,
98
+ ].join(CRLF);
99
+ return { contentType: `multipart/encrypted; protocol="application/pgp-encrypted"; boundary="${boundary}"`, body };
100
+ }
101
+
102
+ /** The `micalg` of a detached signature: `pgp-` and its hash algorithm's name (RFC 3156 §5). */
103
+ async function micalg(armoredSignature: string): Promise<string> {
104
+ const signature = await openpgp.readSignature({ armoredSignature });
105
+ const id = signature.packets[0]?.hashAlgorithm;
106
+ const name = id !== undefined && id !== null ? openpgp.enums.read(openpgp.enums.hash, id) : "sha512";
107
+ return `pgp-${String(name).toLowerCase()}`;
108
+ }
109
+
110
+ /** An RFC 3156 detached `multipart/signed` message: the inner entity (protected headers, the body and - with `attachKey` - our
111
+ * public key), signed with `signingKey`. */
112
+ export async function buildPgpSignedOnlyMessage(
113
+ bodyContentType: string,
114
+ bodyText: string,
115
+ protectedHeaders: ProtectedHeaders,
116
+ signingKey: OpenPgpPrivateKey,
117
+ attachKey?: string,
118
+ ): Promise<MimePart> {
119
+ const entity = innerEntity(bodyContentType, bodyText, protectedHeaderLines(protectedHeaders), "clear", attachKey);
120
+ const signature = await openpgp.sign({
121
+ message: await openpgp.createMessage({ binary: new TextEncoder().encode(entity) }),
122
+ signingKeys: signingKey,
123
+ detached: true,
124
+ format: "armored",
125
+ });
126
+ const boundary = generateBoundary();
127
+ const body = [
128
+ `--${boundary}`,
129
+ entity,
130
+ `--${boundary}`,
131
+ `Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"`,
132
+ `Content-Description: OpenPGP digital signature`,
133
+ `Content-Disposition: attachment; filename="OpenPGP_signature.asc"`,
134
+ "",
135
+ signature.replace(/\r?\n/g, CRLF).trim(),
136
+ `--${boundary}--`,
137
+ ].join(CRLF);
138
+ return {
139
+ contentType: `multipart/signed; protocol="application/pgp-signature"; micalg=${await micalg(signature)}; boundary="${boundary}"`,
140
+ body,
141
+ };
142
+ }
143
+
144
+ /** A key that may have made a signature: a pinned one is trusted, one only attached to the message is not. */
145
+ export interface PgpSignerCandidate {
146
+ key: OpenPgpPublicKey;
147
+ trusted: boolean;
148
+ }
149
+
150
+ /** What a signature proved. `"unknown_key"`: no candidate key has the signing key's ID, so it can't be checked at all. */
151
+ export type PgpSignatureOutcome =
152
+ | { status: "verified"; candidate: PgpSignerCandidate; fingerprint: string; emails: string[] }
153
+ | { status: "invalid" }
154
+ | { status: "unknown_key" };
155
+
156
+ /** The outcome over OpenPGP.js's `signatures`: verified by the first candidate whose key ID matches and whose check passes. */
157
+ /** OpenPGP.js's per-signature verification results. */
158
+ type Signatures = Awaited<ReturnType<typeof openpgp.verify>>["signatures"];
159
+
160
+ async function signatureOutcome(signatures: Signatures, candidates: PgpSignerCandidate[]): Promise<PgpSignatureOutcome> {
161
+ let known = false;
162
+ for (const signature of signatures) {
163
+ const candidate = candidates.find((c) => c.key.getKeyIDs().some((id) => id.equals(signature.keyID)));
164
+ if (!candidate) {
165
+ continue;
166
+ }
167
+ known = true;
168
+ const valid = await signature.verified.then(
169
+ () => true,
170
+ () => false,
171
+ );
172
+ if (valid) {
173
+ return { status: "verified", candidate, fingerprint: candidate.key.getFingerprint().toLowerCase(), emails: await keyEmails(candidate.key) };
174
+ }
175
+ }
176
+ return known ? { status: "invalid" } : { status: "unknown_key" };
177
+ }
178
+
179
+ /** The certificates attached to `entityText` (`application/pgp-keys` parts) - untrusted signer candidates. */
180
+ async function attachedKeys(entityText: string): Promise<OpenPgpPublicKey[]> {
181
+ const keys: OpenPgpPublicKey[] = [];
182
+ const visit = async (text: string, depth: number): Promise<void> => {
183
+ if (depth > 4) {
184
+ return;
185
+ }
186
+ const entity = parseMimeEntity(text);
187
+ const type = parseParameterizedHeader(entity.headers["content-type"]);
188
+ if (type.value === "application/pgp-keys") {
189
+ keys.push(...(await readPublicKeys(decodeBodyText(entity))));
190
+ } else if (type.value.startsWith("multipart/") && type.params["boundary"]) {
191
+ for (const part of splitMultipart(entity.body, type.params["boundary"]).slice(0, 16)) {
192
+ await visit(part, depth + 1);
193
+ }
194
+ }
195
+ };
196
+ await visit(entityText, 0);
197
+ return keys;
198
+ }
199
+
200
+ /** A recovered OpenPGP message: the inner entity (a binary string, as `mime.ts` parses it) and what its signature proved. */
201
+ export interface ParsedPgpMessage {
202
+ /** For an encrypted message: whether one of the keys opened it. Always `true` for a signed-only one. */
203
+ decrypted: boolean;
204
+ /** The recovered MIME entity (PGP/MIME), or for an inline message a `text/plain` entity built around its text. */
205
+ entityText?: string;
206
+ /** `undefined` when the message carried no signature. */
207
+ signature?: PgpSignatureOutcome;
208
+ }
209
+
210
+ /** Candidates for `entityText`: the given ones, then any key attached to the message (untrusted). */
211
+ async function withAttached(candidates: PgpSignerCandidate[], entityText: string): Promise<PgpSignerCandidate[]> {
212
+ return [...candidates, ...(await attachedKeys(entityText)).map((key) => ({ key, trusted: false }))];
213
+ }
214
+
215
+ /** Verifies an RFC 3156 `multipart/signed` message (`protocol="application/pgp-signature"`). Never throws. */
216
+ export async function parsePgpSignedMessage(contentType: string, body: string, candidates: PgpSignerCandidate[]): Promise<ParsedPgpMessage> {
217
+ const boundary = parseParameterizedHeader(contentType).params["boundary"];
218
+ const parts = boundary ? splitMultipart(body, boundary) : [];
219
+ if (parts.length !== 2) {
220
+ return { decrypted: true, signature: { status: "invalid" } };
221
+ }
222
+ const [signed, signaturePart] = parts;
223
+ try {
224
+ const signature = await openpgp.readSignature({ armoredSignature: decodeBodyText(parseMimeEntity(signaturePart)) });
225
+ const all = await withAttached(candidates, signed);
226
+ // What was signed is the part in canonical CRLF form (RFC 3156 §5).
227
+ const canonical = signed.replace(/\r?\n/g, CRLF);
228
+ const verification = await openpgp.verify({
229
+ message: await openpgp.createMessage({ binary: binaryStringToBytes(canonical) }),
230
+ signature,
231
+ verificationKeys: all.map((c) => c.key),
232
+ expectSigned: false,
233
+ });
234
+ return { decrypted: true, entityText: signed, signature: await signatureOutcome(verification.signatures, all) };
235
+ } catch {
236
+ return { decrypted: true, signature: { status: "invalid" } };
237
+ }
238
+ }
239
+
240
+ /** The armored OpenPGP message in an RFC 3156 `multipart/encrypted` body (its second part), or `undefined`. */
241
+ function encryptedPayload(contentType: string, body: string): string | undefined {
242
+ const boundary = parseParameterizedHeader(contentType).params["boundary"];
243
+ const parts = boundary ? splitMultipart(body, boundary) : [];
244
+ return parts.length === 2 ? decodeBodyText(parseMimeEntity(parts[1])) : undefined;
245
+ }
246
+
247
+ /**
248
+ * Decrypts an RFC 3156 `multipart/encrypted` message with `decryptionKeys`, verifying the signature it carries (a combined signed
249
+ * and encrypted message, RFC 3156 §6.2, or an inner `multipart/signed`, §6.1) against `candidates` and the keys attached inside.
250
+ * Never throws.
251
+ */
252
+ export async function parsePgpEncryptedMessage(
253
+ contentType: string,
254
+ body: string,
255
+ decryptionKeys: OpenPgpPrivateKey[],
256
+ candidates: PgpSignerCandidate[],
257
+ ): Promise<ParsedPgpMessage> {
258
+ const payload = encryptedPayload(contentType, body);
259
+ if (!payload || decryptionKeys.length === 0) {
260
+ return { decrypted: false };
261
+ }
262
+ let data: Uint8Array;
263
+ let signed: boolean;
264
+ try {
265
+ const result = await openpgp.decrypt({ message: await openpgp.readMessage({ armoredMessage: payload }), decryptionKeys, format: "binary", expectSigned: false });
266
+ data = result.data;
267
+ signed = result.signatures.length > 0;
268
+ } catch {
269
+ return { decrypted: false };
270
+ }
271
+ const entityText = bytesToBinaryString(data);
272
+ const inner = parseMimeEntity(entityText);
273
+ const innerType = parseParameterizedHeader(inner.headers["content-type"]);
274
+ if (!signed && innerType.value === "multipart/signed" && innerType.params["protocol"]?.toLowerCase() === "application/pgp-signature") {
275
+ const innerSigned = await parsePgpSignedMessage(inner.headers["content-type"], inner.body, candidates);
276
+ return { decrypted: true, entityText: innerSigned.entityText ?? entityText, signature: innerSigned.signature };
277
+ }
278
+ if (!signed) {
279
+ return { decrypted: true, entityText };
280
+ }
281
+ // The keys attached inside are only known now: verify the signature against them and the given ones together.
282
+ const all = await withAttached(candidates, entityText);
283
+ if (all.length === 0) {
284
+ return { decrypted: true, entityText, signature: { status: "unknown_key" } };
285
+ }
286
+ const verified = await openpgp.decrypt({
287
+ message: await openpgp.readMessage({ armoredMessage: payload }),
288
+ decryptionKeys,
289
+ verificationKeys: all.map((c) => c.key),
290
+ format: "binary",
291
+ expectSigned: false,
292
+ });
293
+ return { decrypted: true, entityText, signature: await signatureOutcome(verified.signatures, all) };
294
+ }
295
+
296
+ /** A `text/plain` entity for an inline message's recovered text. */
297
+ function plainEntity(text: string): string {
298
+ return bytesToBinaryString(new TextEncoder().encode(`Content-Type: text/plain; charset=utf-8${CRLF}${CRLF}${text}`));
299
+ }
300
+
301
+ /** Decrypts an inline OpenPGP message (an armored `PGP MESSAGE` as the whole text body), verifying a signature it carries. Never
302
+ * throws. */
303
+ export async function parsePgpInlineEncrypted(armored: string, decryptionKeys: OpenPgpPrivateKey[], candidates: PgpSignerCandidate[]): Promise<ParsedPgpMessage> {
304
+ if (decryptionKeys.length === 0) {
305
+ return { decrypted: false };
306
+ }
307
+ try {
308
+ const result = await openpgp.decrypt({
309
+ message: await openpgp.readMessage({ armoredMessage: armored }),
310
+ decryptionKeys,
311
+ verificationKeys: candidates.map((c) => c.key),
312
+ expectSigned: false,
313
+ });
314
+ const signature = result.signatures.length > 0 ? await signatureOutcome(result.signatures, candidates) : undefined;
315
+ return { decrypted: true, entityText: plainEntity(String(result.data)), ...(signature ? { signature } : {}) };
316
+ } catch {
317
+ return { decrypted: false };
318
+ }
319
+ }
320
+
321
+ /** Verifies an inline cleartext-signed message (`BEGIN PGP SIGNED MESSAGE`). Never throws. */
322
+ export async function parsePgpCleartextSigned(text: string, candidates: PgpSignerCandidate[]): Promise<ParsedPgpMessage> {
323
+ try {
324
+ const message = await openpgp.readCleartextMessage({ cleartextMessage: text });
325
+ const verification = await openpgp.verify({ message, verificationKeys: candidates.map((c) => c.key), expectSigned: false });
326
+ return { decrypted: true, entityText: plainEntity(message.getText()), signature: await signatureOutcome(verification.signatures, candidates) };
327
+ } catch {
328
+ return { decrypted: true, signature: { status: "invalid" } };
329
+ }
330
+ }
331
+
332
+ /** The certificates in a list of base64 keys (`PublicKey.publicKey` of OpenPGP entries) - unreadable ones dropped. */
333
+ export async function readKeysBase64(keys: string[]): Promise<OpenPgpPublicKey[]> {
334
+ const read = await Promise.all(keys.map((key) => readPublicKeys(fromBase64(key))));
335
+ return read.flat();
336
+ }