@rapidmx/web-client 0.36.0 → 0.37.1

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
@@ -15,9 +15,10 @@ import { ApiClient, ApiRequestError, withClient } from "../util/api.js";
15
15
  /** A cryptographic public key used to sign or encrypt messages — safe to expose publicly. Mirrors
16
16
  * `@rapidmx/restapi`'s `PublicKey` type exactly. */
17
17
  export interface PublicKey {
18
- /** Base64-encoded DER X.509 certificate. */
18
+ /** Base64-encoded DER X.509 certificate - or, for `type: "openpgp"`, the binary OpenPGP transferable public key. */
19
19
  publicKey: string;
20
- /** The key's type/format (e.g. `x509`). */
20
+ /** The key's type/format: `x509` (S/MIME), or `openpgp` - an OpenPGP certificate, which both encrypts and signs and is listed
21
+ * once, with `useType: "encrypt"` (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*). */
21
22
  type: string;
22
23
  useType: "sign" | "encrypt";
23
24
  /** SHA-256 fingerprint of the certificate, hex encoded. */
@@ -48,6 +49,23 @@ export interface WrappedPrivateKey {
48
49
  algorithm: string;
49
50
  fingerprint: string;
50
51
  useType: KeyUseType;
52
+ /** `"openpgp"` for an OpenPGP secret key (sealed with `OPENPGP_PRIVATE_KEY_AAD_PURPOSE`); absent for a PKCS#8 key. */
53
+ type?: string;
54
+ }
55
+
56
+ /** `PublicKey.type` of an OpenPGP certificate. */
57
+ export const OPENPGP_KEY_TYPE = "openpgp";
58
+
59
+ /** Whether `key` is an OpenPGP certificate (or an OpenPGP secret key's wrap). */
60
+ export function isOpenPgpKey(key: { type?: string } | undefined): boolean {
61
+ return key?.type === OPENPGP_KEY_TYPE;
62
+ }
63
+
64
+ /** A key's format: `"openpgp"`, or `"x509"` for every other key (S/MIME). */
65
+ export type KeyFormat = "x509" | "openpgp";
66
+
67
+ export function keyFormat(key: { type?: string }): KeyFormat {
68
+ return isOpenPgpKey(key) ? "openpgp" : "x509";
51
69
  }
52
70
 
53
71
  /** What a mailbox's own key is used for. `sign`/`encrypt` keys are published (`Mailbox.keys`); `zero-access` keys never are.
@@ -83,8 +101,14 @@ export interface MasterKeyWrap {
83
101
  export interface EncryptionPreference {
84
102
  lastSeen?: number;
85
103
  preferEncrypt: "mutual" | "nopreference";
104
+ /** Which end-to-end format the address would rather receive when a sender can use either (restapi
105
+ * `specs/end-to-end_encryption.md` *Choosing the format*). Absent: no preference. */
106
+ preferFormat?: EndToEndFormat;
86
107
  }
87
108
 
109
+ /** The two end-to-end encryption formats. */
110
+ export type EndToEndFormat = "smime" | "openpgp";
111
+
88
112
  /** A newly observed key that differs from a contact's pinned key of the same `useType` - restapi records it instead of
89
113
  * silently replacing the pin (the spec's Key Conflict Handling) until the user resolves it with `resolveKeyConflict()`.
90
114
  * A contact holds at most one per `useType`. Mirrors `@rapidmx/restapi`'s `KeyConflict`. */
@@ -152,10 +176,10 @@ export function getKeyVault(mailboxUid: string, client?: ApiClient): Promise<Key
152
176
  * `revocationReason` - a superseded key may still verify old mail (`isTrustedForVerification()`) but is never used for
153
177
  * new mail. Shared by `crypto/keySession.ts` (the
154
178
  * mailbox's own keys) and `crypto/composeSecurity.ts` (a recipient's discovered keys). */
155
- export function findActivePublicKey(keys: PublicKey[], useType: "sign" | "encrypt"): PublicKey | undefined {
179
+ export function findActivePublicKey(keys: PublicKey[], useType: "sign" | "encrypt", format: KeyFormat = "x509"): PublicKey | undefined {
156
180
  const now = Date.now();
157
181
  return keys
158
- .filter((k) => k.useType === useType && !k.revokedAt && k.notAfter > now)
182
+ .filter((k) => k.useType === useType && keyFormat(k) === format && !k.revokedAt && k.notAfter > now)
159
183
  .sort((a, b) => b.notBefore - a.notBefore)[0];
160
184
  }
161
185
 
@@ -199,7 +223,11 @@ export interface EnrollKeyInput extends ExpectedMasterKeyGeneration {
199
223
  csr?: string;
200
224
  /** An already-issued PEM certificate — required (and only meaningful) for `useType: "sign"`. */
201
225
  certificate?: string;
202
- wrappedKey: Omit<WrappedPrivateKey, "fingerprint" | "useType">;
226
+ /** `"openpgp"` to enroll the mailbox's OpenPGP key (with `useType: "encrypt"`): self-certified, no CA. */
227
+ type?: string;
228
+ /** The OpenPGP public key (armored or base64 binary) - required, and only meaningful, with `type: "openpgp"`. */
229
+ publicKey?: string;
230
+ wrappedKey: Omit<WrappedPrivateKey, "fingerprint" | "useType" | "type">;
203
231
  /** Only meaningful the very first time a mailbox enrolls a key at all (bootstraps its master key). */
204
232
  masterKeyWraps?: MasterKeyWrap[];
205
233
  }
@@ -655,6 +683,11 @@ export interface KeyLookupResult {
655
683
  keyConflicts?: KeyConflict[];
656
684
  /** Keys that were pinned before, newest first - see `PreviousKey`. */
657
685
  previousKeys?: PreviousKey[];
686
+ /** The format to use for a recipient reachable in both (restapi *Choosing the format*): the user's choice for the contact, else what
687
+ * the address advertises, else what it was last seen using. Absent when nothing says - S/MIME then. */
688
+ preferredFormat?: EndToEndFormat;
689
+ /** Where `preferredFormat` came from. */
690
+ preferredFormatSource?: "contact" | "advertised" | "observed";
658
691
  }
659
692
 
660
693
  /** Server-side Discovery: the server itself performs the `_rapidmx` DNS lookup and remote key-endpoint
@@ -669,8 +702,11 @@ export function lookupKeys(mailboxUid: string, addr: string, client?: ApiClient)
669
702
  /** What `trustSigner()` pins: the sender's address and the certificate that signed their message. */
670
703
  export interface TrustSignerInput {
671
704
  address: string;
672
- /** Base64 DER of the signer certificate - `MessageSecurityResult.signerCertificate`. */
705
+ /** Base64 DER of the signer certificate - `MessageSecurityResult.signerCertificate` - or, with `type: "openpgp"`, an OpenPGP
706
+ * public key (armored or base64 binary): a signer's, a key attached to a message, or one the user pasted. */
673
707
  certificate: string;
708
+ /** `"openpgp"` to pin an OpenPGP key (it encrypts and signs); omitted for an X.509 signing certificate. */
709
+ type?: string;
674
710
  }
675
711
 
676
712
  /**
@@ -696,7 +732,7 @@ export async function trustSigner(mailboxUid: string, input: TrustSignerInput, c
696
732
  try {
697
733
  return await withClient<KeyLookupResult>(client, `/mail/mailboxes/${encodeURIComponent(mailboxUid)}/keys/trust`, {
698
734
  method: "POST",
699
- body: JSON.stringify({ address: input.address, certificate: input.certificate }),
735
+ body: JSON.stringify({ address: input.address, certificate: input.certificate, ...(input.type ? { type: input.type } : {}) }),
700
736
  });
701
737
  } catch (err) {
702
738
  if (err instanceof ApiRequestError && err.status === 409) {
@@ -721,6 +757,8 @@ export interface ResolveKeyConflictInput {
721
757
  /** For `"accept"`: base64 DER of the certificate to pin (e.g. `MessageSecurityResult.signerCertificate` of a
722
758
  * `signer_key_changed` result). Omitted, restapi pins the recorded conflict's `observedKey`. */
723
759
  certificate?: string;
760
+ /** `"openpgp"` for the contact's OpenPGP key (always `useType: "encrypt"`); omitted for its S/MIME certificate. */
761
+ type?: string;
724
762
  }
725
763
 
726
764
  /** The contact's resulting key state after `resolveKeyConflict()`. */
@@ -755,6 +793,7 @@ export async function resolveKeyConflict(
755
793
  action: input.action,
756
794
  expectedPinnedFingerprint: input.expectedPinnedFingerprint,
757
795
  ...(input.certificate !== undefined ? { certificate: input.certificate } : {}),
796
+ ...(input.type ? { type: input.type } : {}),
758
797
  };
759
798
  try {
760
799
  return await withClient<ResolveKeyConflictResult>(client, `/mail/mailboxes/${encodeURIComponent(mailboxUid)}/keys/resolve`, {
@@ -779,6 +818,9 @@ export interface EncryptionPolicy {
779
818
  * supports zero-access encryption (its stored value or the server default); absent from one that doesn't, which a
780
819
  * caller treats as not allowed. */
781
820
  allowZeroAccess?: boolean;
821
+ /** "Allow OpenPGP": whether mailboxes may enroll and publish OpenPGP keys, for mail with OpenPGP users. Absent from a server
822
+ * that predates it, which a caller treats as not allowed. */
823
+ allowOpenPgp?: boolean;
782
824
  }
783
825
 
784
826
  /** The system-wide encryption policy (readable by any authenticated user, used to decide what encryption
@@ -0,0 +1,118 @@
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
+ import type { DecryptionKey } from "./smime.js";
16
+
17
+ /** The `#` parameter of a link that carries a meeting key. */
18
+ export const MEETING_KEY_FRAGMENT_PARAM = "k";
19
+
20
+ /** HKDF `info` of the live key (`specs/meeting_privacy.md` §3). */
21
+ const LIVE_KEY_INFO = "rapidmx-meeting-live-v1";
22
+
23
+ const EC_PARAMS: EcKeyImportParams = { name: "ECDH", namedCurve: "P-256" };
24
+
25
+ /** A meeting key as its creator holds it. */
26
+ export interface GeneratedMeetingKey {
27
+ /** PKCS#8 DER of the private key - what a link carries and a wrap seals. */
28
+ pkcs8: Uint8Array;
29
+ /** The self-signed certificate's DER - what the server stores and data is sealed to. */
30
+ certificateDer: Uint8Array;
31
+ /** SHA-256 of `certificateDer`, lowercase hex. */
32
+ fingerprint: string;
33
+ }
34
+
35
+ async function loadX509(): Promise<typeof import("@peculiar/x509")> {
36
+ await import("reflect-metadata");
37
+ const x509 = await import("@peculiar/x509");
38
+ x509.cryptoProvider.set(crypto);
39
+ return x509;
40
+ }
41
+
42
+ async function sha256Hex(bytes: Uint8Array): Promise<string> {
43
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes as BufferSource));
44
+ return Array.from(digest, (b) => b.toString(16).padStart(2, "0")).join("");
45
+ }
46
+
47
+ /** A new meeting key valid until `expiresAt` (its certificate's `notAfter`). */
48
+ export async function generateMeetingKey(expiresAt: Date): Promise<GeneratedMeetingKey> {
49
+ const x509 = await loadX509();
50
+ // ECDSA to self-sign the certificate; the same P-256 key is re-imported as ECDH to open what is sealed to it (`importMeetingKey()`).
51
+ const keys = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]));
52
+ const cert = await x509.X509CertificateGenerator.createSelfSigned({
53
+ serialNumber: toHex(crypto.getRandomValues(new Uint8Array(16))),
54
+ name: "CN=meeting",
55
+ notBefore: new Date(Date.now() - 60_000),
56
+ notAfter: expiresAt,
57
+ signingAlgorithm: { name: "ECDSA", hash: "SHA-256" },
58
+ keys,
59
+ });
60
+ const certificateDer = new Uint8Array(cert.rawData);
61
+ const pkcs8 = new Uint8Array(await crypto.subtle.exportKey("pkcs8", keys.privateKey));
62
+ return { pkcs8, certificateDer, fingerprint: await sha256Hex(certificateDer) };
63
+ }
64
+
65
+ function toHex(bytes: Uint8Array): string {
66
+ // A positive serial: the top bit clear.
67
+ const copy = Uint8Array.from(bytes);
68
+ copy[0] &= 0x7f;
69
+ return Array.from(copy, (b) => b.toString(16).padStart(2, "0")).join("");
70
+ }
71
+
72
+ /** The `#` part a guest's link carries for `pkcs8`: `k=<base64url>`. */
73
+ export function meetingKeyFragment(pkcs8: Uint8Array): string {
74
+ return `${MEETING_KEY_FRAGMENT_PARAM}=${toBase64Url(pkcs8)}`;
75
+ }
76
+
77
+ /** The meeting key in `hash` (a `location.hash`, with or without its `#`), or `undefined` when it carries none or it is malformed. */
78
+ export function readMeetingKeyFragment(hash: string): Uint8Array | undefined {
79
+ const params = new URLSearchParams(hash.replace(/^#/, ""));
80
+ const value = params.get(MEETING_KEY_FRAGMENT_PARAM);
81
+ if (!value || !/^[A-Za-z0-9_-]{16,1024}$/.test(value)) {
82
+ return undefined;
83
+ }
84
+ try {
85
+ return fromBase64Url(value);
86
+ } catch {
87
+ return undefined;
88
+ }
89
+ }
90
+
91
+ /** 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. */
92
+ export async function importMeetingKey(
93
+ pkcs8: Uint8Array,
94
+ certificateDer: Uint8Array,
95
+ meetingUid: string,
96
+ ): Promise<{ decryptionKey: DecryptionKey; liveKey: CryptoKey }> {
97
+ // Extractable once, only to read its scalar for the live key; the key kept for CMS isn't.
98
+ const readable = await crypto.subtle.importKey("pkcs8", pkcs8 as BufferSource, EC_PARAMS, true, ["deriveBits"]);
99
+ const jwk = await crypto.subtle.exportKey("jwk", readable);
100
+ /* v8 ignore next 3 -- an imported PKCS#8 private key always exports its scalar */
101
+ if (!jwk.d) {
102
+ throw new Error("This is not a meeting key.");
103
+ }
104
+ const privateKey = await crypto.subtle.importKey("pkcs8", pkcs8 as BufferSource, EC_PARAMS, false, ["deriveBits"]);
105
+ return { decryptionKey: { certDer: certificateDer, privateKey }, liveKey: await deriveLiveKey(fromBase64Url(jwk.d), meetingUid) };
106
+ }
107
+
108
+ /** The AES-256-GCM live key of meeting `meetingUid` from `ikm`, the meeting key's private scalar. Non-extractable. */
109
+ export async function deriveLiveKey(ikm: Uint8Array, meetingUid: string): Promise<CryptoKey> {
110
+ const base = await crypto.subtle.importKey("raw", ikm as BufferSource, "HKDF", false, ["deriveKey"]);
111
+ return crypto.subtle.deriveKey(
112
+ { name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode(meetingUid), info: new TextEncoder().encode(LIVE_KEY_INFO) },
113
+ base,
114
+ { name: "AES-GCM", length: 256 },
115
+ false,
116
+ ["encrypt", "decrypt"],
117
+ );
118
+ }
@@ -42,6 +42,8 @@ import {
42
42
  MimeAttachment,
43
43
  MimeHeaderField,
44
44
  bytesToBinaryString,
45
+ decodeBodyText,
46
+ type MimeEntity,
45
47
  decodeBase64Text,
46
48
  decodeHeaderText,
47
49
  extractAddresses,
@@ -54,7 +56,17 @@ import {
54
56
  } from "./mime.js";
55
57
  import { fromBase64, toBase64 } from "./encoding.js";
56
58
  import { type DecryptionKey, computeCertFingerprint, decryptEnvelopedDataWithKeys, extractCertificateEmails } from "./smime.js";
57
- import { ComparableOuterHeaders, ProtectedHeaders, parseEncryptedMessageWithKeys, parseSignedOnlyMessage } from "./smimeMessage.js";
59
+ import {
60
+ ComparableOuterHeaders,
61
+ ProtectedHeaders,
62
+ extractHpOuterHeaders,
63
+ headersToProtectedHeaders,
64
+ outerHeadersMatch,
65
+ parseEncryptedMessageWithKeys,
66
+ parseSignedOnlyMessage,
67
+ } from "./smimeMessage.js";
68
+ import type { OpenPgpPrivateKey } from "./openpgp.js";
69
+ import type { ParsedPgpMessage, PgpSignerCandidate } from "./pgpMessage.js";
58
70
  import { type PublicKey, isTrustedForVerification } from "./keyvaultApi.js";
59
71
  import { KeysLockedError } from "./masterKey.js";
60
72
  import { type SealKeyMaterial, type SealedVerificationState, buildVerificationSeal, openVerificationSeal, rawMimeSha256 } from "./verificationSeal.js";
@@ -161,6 +173,14 @@ export interface MessageSecurityResult {
161
173
  signerFingerprint?: string;
162
174
  /** The email addresses that signer certificate asserts (lowercased), alongside `signerFingerprint`. */
163
175
  signerEmails?: string[];
176
+ /** `"openpgp"` when the signer is an OpenPGP key: `signerCertificate` is then its certificate (base64 binary) and
177
+ * `signerFingerprint` its primary key's fingerprint - pin it with `trustSigner()`'s `type: "openpgp"`. */
178
+ signerKeyType?: "openpgp";
179
+ /** `true` for an OpenPGP message whose signature was made by a key this reader doesn't have (neither pinned for the sender,
180
+ * nor the reader's own, nor attached to the message), so it couldn't be checked at all. A signed-only one is then reported
181
+ * `"unprotected"` (its content is readable anyway), an encrypted one `"encrypted"`; a UI can say the sender's key isn't known
182
+ * yet. */
183
+ signerKeyUnknown?: boolean;
164
184
  /** Base64 DER of the certificate that verified the signature - what `keyvaultApi.ts`'s `trustSigner()` pins for
165
185
  * a "trust this signer" action. Present alongside `signerFingerprint` for `"signed_verified"`,
166
186
  * `"encrypted_verified"`, the `*_unverified_signer` states, and `"signature_failed"` with reason
@@ -280,8 +300,13 @@ function sameInstant(signed: string | undefined, outer: string | undefined): boo
280
300
  return Number.isNaN(left) || left === Date.parse(String(outer));
281
301
  }
282
302
 
283
- function normalizePins(pins: string | string[] | undefined): string[] {
284
- return (Array.isArray(pins) ? pins : pins === undefined ? [] : [pins]).map((pin) => pin.toLowerCase());
303
+ function normalizePins(pins: SignerPinsInput): string[] {
304
+ const list: string[] = Array.isArray(pins) ? pins : pins === undefined ? [] : typeof pins === "string" ? [pins] : pins.fingerprints;
305
+ return list.map((pin) => pin.toLowerCase());
306
+ }
307
+
308
+ function pinnedOpenPgpKeys(pins: SignerPinsInput): string[] {
309
+ return pins !== undefined && typeof pins === "object" && !Array.isArray(pins) ? pins.openPgpKeys : [];
285
310
  }
286
311
 
287
312
  /** Checks 2-4 from this module's doc comment for an already cryptographically verified signature.
@@ -299,13 +324,14 @@ export async function checkSignerBinding(input: SignerBindingInput): Promise<Sig
299
324
  if (pins.length > 0 && !pins.includes(await computeCertFingerprint(certDer))) {
300
325
  // A certificate that otherwise binds cleanly to the sender is a key change for the user to judge; anything else
301
326
  // (no certificate, wrong identity, header tampering) stays a plain untrusted signer.
302
- return input.signerCertificateDer && checkIdentityAndHeaders(input, certDer) === undefined ? "signer_key_changed" : "untrusted_signer";
327
+ return input.signerCertificateDer && checkIdentityAndHeaders(input, extractCertificateEmails(certDer)) === undefined ? "signer_key_changed" : "untrusted_signer";
303
328
  }
304
- return checkIdentityAndHeaders(input, certDer);
329
+ return checkIdentityAndHeaders(input, extractCertificateEmails(certDer));
305
330
  }
306
331
 
307
- /** Checks 3-4 from this module's doc comment. */
308
- function checkIdentityAndHeaders(input: SignerBindingInput, certDer: Uint8Array): SignatureFailureReason | undefined {
332
+ /** Checks 3-4 from this module's doc comment, for a signer whose key names `signerEmails` (an X.509 certificate's, or an
333
+ * OpenPGP key's user IDs). */
334
+ function checkIdentityAndHeaders(input: Omit<SignerBindingInput, "signerCertificateDer">, signerEmails: string[]): SignatureFailureReason | undefined {
309
335
  const { protectedHeaders, outerHeaders } = input;
310
336
  if (hasRepeatedAddressField(input.outerFields) || hasRepeatedAddressField(input.protectedFields)) {
311
337
  return "header_mismatch";
@@ -313,7 +339,7 @@ function checkIdentityAndHeaders(input: SignerBindingInput, certDer: Uint8Array)
313
339
  const outerFrom = extractAddresses(outerHeaders["from"]);
314
340
  const protectedFrom = extractAddresses(protectedHeaders?.from);
315
341
  const senderAddresses = protectedFrom.length > 0 ? protectedFrom : outerFrom;
316
- if (senderAddresses.length !== 1 || !extractCertificateEmails(certDer).includes(senderAddresses[0])) {
342
+ if (senderAddresses.length !== 1 || !signerEmails.includes(senderAddresses[0])) {
317
343
  return "signer_identity_mismatch";
318
344
  }
319
345
  const protectedTo = extractAddresses(protectedHeaders?.to);
@@ -357,7 +383,7 @@ function checkIdentityAndHeaders(input: SignerBindingInput, certDer: Uint8Array)
357
383
  export async function evaluateMessageSecurity(
358
384
  rawMime: string,
359
385
  unlocked: MessageSecurityKeys | undefined,
360
- pinnedSignerFingerprints?: string | string[],
386
+ pinnedSignerFingerprints?: SignerPinsInput,
361
387
  readerAddress?: string,
362
388
  ): Promise<MessageSecurityResult> {
363
389
  return (await evaluateLive(rawMime, unlocked, pinnedSignerFingerprints, readerAddress)).result;
@@ -372,8 +398,22 @@ export interface MessageSecurityKeys {
372
398
  /** The mailbox's zero-access keys (`keySession.ts`'s `UnlockedKeys.zeroAccessKeys`) - what opens a message the server sealed at
373
399
  * rest on arrival. */
374
400
  zeroAccessKeys?: { certDer: Uint8Array; privateKey: CryptoKey }[];
401
+ /** The mailbox's OpenPGP secret keys (`keySession.ts`'s `UnlockedKeys.openPgpKeys`), the active one first - what opens mail an
402
+ * OpenPGP user encrypted to the mailbox, and verifies mail it signed itself. */
403
+ openPgpKeys?: { fingerprint: string; privateKey: OpenPgpPrivateKey }[];
404
+ }
405
+
406
+ /** The sender's trusted keys, when they include OpenPGP certificates: `fingerprints` as `pinnedSignerFingerprints`, and
407
+ * `openPgpKeys` the pinned OpenPGP certificates themselves (base64, `PublicKey.publicKey`) - an OpenPGP signature carries only
408
+ * a key ID, so its key has to be supplied to be checked at all (`pinnedSigners.ts`'s `getPinnedSigners()`). */
409
+ export interface SignerPins {
410
+ fingerprints: string[];
411
+ openPgpKeys: string[];
375
412
  }
376
413
 
414
+ /** What `pinnedSignerFingerprints` accepts: one fingerprint, several, or `SignerPins`. */
415
+ export type SignerPinsInput = string | string[] | SignerPins | undefined;
416
+
377
417
  /**
378
418
  * The subject and preview of a zero-access message (`Message.zeroAccess.sealedSummary`, base64 CMS of JSON `{ subject, preview }`),
379
419
  * decrypted with the mailbox's zero-access keys - a few hundred bytes, so a message list shows real subjects without downloading
@@ -437,16 +477,21 @@ interface LiveEvaluation {
437
477
  async function evaluateLive(
438
478
  rawMime: string,
439
479
  unlocked: MessageSecurityKeys | undefined,
440
- pinnedSignerFingerprints: string | string[] | undefined,
480
+ pinnedSignerFingerprints: SignerPinsInput,
441
481
  readerAddress: string | undefined,
442
482
  sealedAtRest = false,
443
483
  ): Promise<LiveEvaluation> {
444
- const { headers, body, fields } = parseMimeEntity(rawMime);
484
+ const outerEntity = parseMimeEntity(rawMime);
485
+ const { headers, body, fields } = outerEntity;
445
486
  const rawContentType = headers["content-type"];
487
+ const contentType = parseParameterizedHeader(rawContentType ?? "text/plain");
488
+ const pgp = pgpKind(contentType, outerEntity);
489
+ if (pgp) {
490
+ return await evaluatePgp(pgp, rawContentType ?? "text/plain", outerEntity, unlocked, pinnedSignerFingerprints, readerAddress);
491
+ }
446
492
  if (!rawContentType) {
447
493
  return { result: { state: "unprotected" }, kind: "other" };
448
494
  }
449
- const contentType = parseParameterizedHeader(rawContentType);
450
495
  const callerPins = normalizePins(pinnedSignerFingerprints);
451
496
 
452
497
  // The received message's own *real* outer envelope - what `HP-Outer`'s field copies (written at
@@ -603,6 +648,122 @@ async function evaluateLive(
603
648
  return { result: { state: "unprotected" }, kind: "other" };
604
649
  }
605
650
 
651
+ /** Which OpenPGP form a received message is, if any: RFC 3156 PGP/MIME (`multipart/signed` or `multipart/encrypted` with an OpenPGP
652
+ * `protocol`), or an inline message - a `text/plain` body that is one armored `PGP MESSAGE`, or a cleartext signature. */
653
+ type PgpKind = "signed" | "encrypted" | "inline-encrypted" | "inline-signed";
654
+
655
+ const INLINE_PGP_MESSAGE = /^-----BEGIN PGP MESSAGE-----\r?\n[\s\S]*-----END PGP MESSAGE-----$/;
656
+ const INLINE_PGP_SIGNED = /^-----BEGIN PGP SIGNED MESSAGE-----\r?\n[\s\S]*-----END PGP SIGNATURE-----$/;
657
+
658
+ function pgpKind(contentType: { value: string; params: Record<string, string> }, entity: MimeEntity): PgpKind | undefined {
659
+ const protocol = contentType.params["protocol"]?.toLowerCase();
660
+ if (contentType.value === "multipart/signed" && protocol === "application/pgp-signature") {
661
+ return "signed";
662
+ }
663
+ if (contentType.value === "multipart/encrypted" && protocol === "application/pgp-encrypted") {
664
+ return "encrypted";
665
+ }
666
+ if (contentType.value !== "text/plain") {
667
+ return undefined;
668
+ }
669
+ const text = decodeBodyText(entity).trim();
670
+ return INLINE_PGP_MESSAGE.test(text) ? "inline-encrypted" : INLINE_PGP_SIGNED.test(text) ? "inline-signed" : undefined;
671
+ }
672
+
673
+ /**
674
+ * `evaluateLive()` for an OpenPGP message (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*), with the same
675
+ * trust rules as S/MIME: a signature by a key pinned for the sender (`SignerPins.openPgpKeys`) or the reader's own is verified;
676
+ * one by a key only attached to the message is `*_unverified_signer` - or `signer_key_changed` when the sender has a different key
677
+ * pinned; and the key must name the sender, with the protected headers matching the outer ones (`checkIdentityAndHeaders()`).
678
+ * A signature nobody's key can check is reported through `signerKeyUnknown`, never as failed. The crypto is `pgpMessage.ts`'s,
679
+ * loaded only here.
680
+ */
681
+ async function evaluatePgp(
682
+ kind: PgpKind,
683
+ rawContentType: string,
684
+ outer: MimeEntity,
685
+ unlocked: MessageSecurityKeys | undefined,
686
+ pins: SignerPinsInput,
687
+ readerAddress: string | undefined,
688
+ ): Promise<LiveEvaluation> {
689
+ const pgp = await import("./pgpMessage.js");
690
+ const pinned = (await pgp.readKeysBase64(pinnedOpenPgpKeys(pins))).map((key) => ({ key, trusted: true }));
691
+ const ownKeys = unlocked?.openPgpKeys ?? [];
692
+ const candidates: PgpSignerCandidate[] = [...pinned, ...ownKeys.map(({ privateKey }) => ({ key: privateKey.toPublic(), trusted: true }))];
693
+ const decryptionKeys = ownKeys.map(({ privateKey }) => privateKey);
694
+ const encrypted = kind === "encrypted" || kind === "inline-encrypted";
695
+ const text = decodeBodyText(outer).trim();
696
+ let parsed: ParsedPgpMessage;
697
+ if (kind === "signed") {
698
+ parsed = await pgp.parsePgpSignedMessage(rawContentType, outer.body, candidates);
699
+ } else if (kind === "encrypted") {
700
+ parsed = await pgp.parsePgpEncryptedMessage(rawContentType, outer.body, decryptionKeys, candidates);
701
+ } else if (kind === "inline-encrypted") {
702
+ parsed = await pgp.parsePgpInlineEncrypted(text, decryptionKeys, candidates);
703
+ } else {
704
+ parsed = await pgp.parsePgpCleartextSigned(text, candidates);
705
+ }
706
+ const resultKind: LiveEvaluation["kind"] = encrypted ? "encrypted" : "signed";
707
+ if (encrypted && !parsed.decrypted) {
708
+ return { result: { state: "encrypted", decryptError: NO_KEY_ERROR }, kind: resultKind };
709
+ }
710
+ if (parsed.entityText === undefined) {
711
+ return { result: { state: "signature_failed", signatureFailureReason: "invalid_signature" }, kind: resultKind };
712
+ }
713
+ const entity = parseMimeEntity(parsed.entityText);
714
+ const inline = kind === "inline-encrypted" || kind === "inline-signed";
715
+ const protectedHeaders: ProtectedHeaders | undefined = inline ? undefined : headersToProtectedHeaders(entity.headers);
716
+ const hasProtected = !!protectedHeaders && extractAddresses(protectedHeaders.from).length > 0;
717
+ const hpOuter = encrypted && !inline ? extractHpOuterHeaders(entity.fields) : undefined;
718
+ const outerComparable: ComparableOuterHeaders = { from: outer.headers["from"], to: outer.headers["to"], cc: outer.headers["cc"], date: outer.headers["date"], subject: outer.headers["subject"] };
719
+ const recipients = [...extractAddresses(protectedHeaders?.to), ...extractAddresses(protectedHeaders?.cc)];
720
+ const content: Partial<MessageSecurityResult> = {
721
+ ...renderDisplayBody(extractDisplayBody(entity)),
722
+ subject: (hasProtected ? protectedHeaders?.subject : undefined) || undefined,
723
+ attachments: extractAttachments(entity),
724
+ ...(hpOuter ? { headerTamperDetected: !outerHeadersMatch(hpOuter, outerComparable) } : {}),
725
+ ...(readerAddress && recipients.length > 0 ? { notAddressedToReader: !recipients.includes(readerAddress.trim().toLowerCase()) } : {}),
726
+ };
727
+ const signature = parsed.signature;
728
+ if (!signature) {
729
+ // Encrypted without a signature (an unsigned signed-only message can't happen: it is the signature).
730
+ return { result: { state: "encrypted", ...content }, kind: resultKind };
731
+ }
732
+ if (signature.status === "unknown_key") {
733
+ return { result: encrypted ? { state: "encrypted", ...content, signerKeyUnknown: true } : { state: "unprotected", signerKeyUnknown: true }, kind: resultKind };
734
+ }
735
+ if (signature.status === "invalid") {
736
+ return { result: { state: "signature_failed", signatureFailureReason: "invalid_signature", ...(encrypted ? content : {}) }, kind: resultKind };
737
+ }
738
+ const signer = { signerFingerprint: signature.fingerprint, signerEmails: signature.emails, signerKeyType: "openpgp" as const };
739
+ const acceptedSigner = { ...signer, signerCertificate: toBase64(signature.candidate.key.write()) };
740
+ const signedHeaders: Partial<MessageSecurityResult> = hasProtected
741
+ ? { protectedHeaders: { from: protectedHeaders.from, to: protectedHeaders.to, ...(protectedHeaders.cc !== undefined ? { cc: protectedHeaders.cc } : {}), subject: protectedHeaders.subject } }
742
+ : {};
743
+ const failure = checkIdentityAndHeaders(
744
+ {
745
+ protectedHeaders: hasProtected ? protectedHeaders : undefined,
746
+ outerHeaders: outer.headers,
747
+ outerFields: outer.fields,
748
+ protectedFields: hasProtected ? entity.fields : undefined,
749
+ compareSubject: !encrypted,
750
+ },
751
+ signature.emails,
752
+ );
753
+ // Signed by a key only attached to the message while the sender has OpenPGP keys pinned: they appear to use a new key.
754
+ const keyChanged = !failure && !signature.candidate.trusted && pinned.length > 0;
755
+ if (failure || keyChanged) {
756
+ const reason: SignatureFailureReason = failure ?? "signer_key_changed";
757
+ return {
758
+ result: { state: "signature_failed", signatureFailureReason: reason, ...(encrypted ? content : {}), ...(reason === "signer_key_changed" ? acceptedSigner : signer) },
759
+ kind: resultKind,
760
+ ...(reason === "signer_key_changed" ? { signedContent: { ...content, ...signedHeaders } } : {}),
761
+ };
762
+ }
763
+ const state: MessageSecurityState = signature.candidate.trusted ? (encrypted ? "encrypted_verified" : "signed_verified") : encrypted ? "encrypted_unverified_signer" : "signed_unverified_signer";
764
+ return { result: { state, ...content, ...signedHeaders, ...acceptedSigner }, kind: resultKind };
765
+ }
766
+
606
767
  /** Options for `evaluateMessageSecurityWithSeal()`. */
607
768
  export interface VerificationSealOptions {
608
769
  mailboxUid: string;
@@ -669,7 +830,7 @@ function failedOnlyOnKeyStatus(result: MessageSecurityResult): boolean {
669
830
  export async function evaluateMessageSecurityWithSeal(
670
831
  rawMime: string,
671
832
  unlocked: (MessageSecurityKeys & SealKeyMaterial) | undefined,
672
- pinnedSignerFingerprints: string | string[] | undefined,
833
+ pinnedSignerFingerprints: SignerPinsInput,
673
834
  readerAddress: string | undefined,
674
835
  options: VerificationSealOptions,
675
836
  ): Promise<MessageSecurityResult> {
@@ -0,0 +1,118 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The mailbox's own OpenPGP key (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*): generated here, its secret
7
+ * key sealed under the master key like every other private key, its certificate enrolled with restapi - which publishes it on the
8
+ * RapidMX discovery endpoint, the Web Key Directory and in an `Autocrypt` header, so OpenPGP users (Proton Mail and others) can
9
+ * encrypt to the mailbox. S/MIME stays the mailbox's own format; this key is for correspondents who can't receive S/MIME.
10
+ */
11
+ import type { ApiClient } from "../util/api.js";
12
+ import { type KeyVault, OPENPGP_KEY_TYPE, enrollKey, getEncryptionPolicy, getKeyVault, isOpenPgpKey, recordKeyExport } from "./keyvaultApi.js";
13
+ import { addOpenPgpKey, getUnlockedKeys, OPENPGP_PRIVATE_KEY_AAD_PURPOSE, openMasterKeyWithPassword } from "./keySession.js";
14
+ import type { OpenPgpPrivateKey } from "./openpgp.js";
15
+ import { buildAad, openWithKey, sealWithKey } from "./masterKey.js";
16
+
17
+ /**
18
+ * Generates an OpenPGP key for `mailboxAddress` (`openpgp.ts`'s `generateOpenPgpKey()`), seals its secret key under `masterKey`
19
+ * and enrolls it; restapi makes it the mailbox's active OpenPGP key, superseding any earlier one. Pass the vault's
20
+ * `masterKeyGeneration` read with `masterKey` as `expectedMasterKeyGeneration` (`409` when another device rotated it meanwhile).
21
+ * Resolves with the vault and the new key, ready to decrypt and sign with.
22
+ */
23
+ export async function provisionOpenPgpKey(
24
+ mailboxUid: string,
25
+ mailboxAddress: string,
26
+ masterKey: Uint8Array,
27
+ expectedMasterKeyGeneration?: number,
28
+ displayName?: string,
29
+ client?: ApiClient,
30
+ ): Promise<{ vault: KeyVault; key: { fingerprint: string; privateKey: OpenPgpPrivateKey } }> {
31
+ const { generateOpenPgpKey, readSecretKey } = await import("./openpgp.js");
32
+ const generated = await generateOpenPgpKey(mailboxAddress, displayName);
33
+ try {
34
+ const sealed = await sealWithKey(masterKey, generated.secretKey, buildAad(mailboxUid, OPENPGP_PRIVATE_KEY_AAD_PURPOSE));
35
+ const vault = await enrollKey(
36
+ mailboxUid,
37
+ {
38
+ useType: "encrypt",
39
+ type: OPENPGP_KEY_TYPE,
40
+ publicKey: generated.armoredPublicKey,
41
+ wrappedKey: { ciphertext: sealed.ciphertext, nonce: sealed.nonce, algorithm: "AES-256-GCM" },
42
+ ...(expectedMasterKeyGeneration !== undefined ? { expectedMasterKeyGeneration } : {}),
43
+ },
44
+ client,
45
+ );
46
+ return { vault, key: { fingerprint: generated.fingerprint, privateKey: await readSecretKey(generated.secretKey) } };
47
+ } finally {
48
+ generated.secretKey.fill(0);
49
+ }
50
+ }
51
+
52
+ /** Provisions already in flight, per mailbox - a second caller joins the first. */
53
+ const inFlight = new Map<string, Promise<boolean>>();
54
+
55
+ /**
56
+ * Gives an unlocked mailbox an OpenPGP key when it has none - how every mailbox, including one set up before OpenPGP was
57
+ * supported, gets one on its owner's next unlock. Resolves `true` when it enrolled one, `false` when there was nothing to do:
58
+ * the mailbox isn't unlocked, the server doesn't allow OpenPGP (`EncryptionPolicy.allowOpenPgp`), the vault isn't set up yet, or it
59
+ * already holds an OpenPGP key. Only the mailbox's owner may call this (restapi refuses anybody else's enrollment).
60
+ */
61
+ export function ensureOpenPgpKey(mailboxUid: string, mailboxAddress: string, displayName?: string, client?: ApiClient): Promise<boolean> {
62
+ const pending = inFlight.get(mailboxUid);
63
+ if (pending) {
64
+ return pending;
65
+ }
66
+ const run = (async (): Promise<boolean> => {
67
+ const unlocked = getUnlockedKeys(mailboxUid);
68
+ if (!unlocked || unlocked.shared || unlocked.openPgpKeys?.length) {
69
+ return false;
70
+ }
71
+ const policy = await getEncryptionPolicy(client);
72
+ if (policy.allowOpenPgp !== true) {
73
+ return false;
74
+ }
75
+ const vault = await getKeyVault(mailboxUid, client);
76
+ if (vault.wrappedKeys.length === 0 || vault.wrappedKeys.some((k) => isOpenPgpKey(k))) {
77
+ return false;
78
+ }
79
+ const { key } = await provisionOpenPgpKey(mailboxUid, mailboxAddress, unlocked.masterKey, vault.masterKeyGeneration, displayName, client);
80
+ addOpenPgpKey(mailboxUid, unlocked, key);
81
+ return true;
82
+ })().finally(() => inFlight.delete(mailboxUid));
83
+ inFlight.set(mailboxUid, run);
84
+ return run;
85
+ }
86
+
87
+ /**
88
+ * The mailbox's active OpenPGP secret key, armored and protected with `passphrase` - for a user who reads this mailbox's OpenPGP
89
+ * mail in another OpenPGP app too (Thunderbird, GnuPG...). Like the PKCS#12 export (`keyExport.ts`) it re-opens the vault with
90
+ * the master password, whatever the session, never touches the session's keys, and is recorded with restapi (kind `encrypt`).
91
+ * Throws when the mailbox has no OpenPGP key.
92
+ */
93
+ export async function exportOpenPgpSecretKey(
94
+ mailboxUid: string,
95
+ masterPassword: string,
96
+ passphrase: string,
97
+ client?: ApiClient,
98
+ ): Promise<{ armored: string; fingerprint: string }> {
99
+ const { readSecretKey, exportProtectedSecretKey } = await import("./openpgp.js");
100
+ const { vault, masterKey } = await openMasterKeyWithPassword(mailboxUid, masterPassword, client);
101
+ try {
102
+ // The newest OpenPGP key enrolled is the active one (restapi appends, and supersedes the earlier ones).
103
+ const wrapped = [...vault.wrappedKeys].reverse().find((k) => isOpenPgpKey(k));
104
+ if (!wrapped) {
105
+ throw new Error("This mailbox has no OpenPGP key to export.");
106
+ }
107
+ const raw = await openWithKey(masterKey, wrapped, buildAad(mailboxUid, OPENPGP_PRIVATE_KEY_AAD_PURPOSE));
108
+ try {
109
+ const armored = await exportProtectedSecretKey(await readSecretKey(raw), passphrase);
110
+ await recordKeyExport(mailboxUid, [{ kind: "encrypt", fingerprint: wrapped.fingerprint }], client);
111
+ return { armored, fingerprint: wrapped.fingerprint };
112
+ } finally {
113
+ raw.fill(0);
114
+ }
115
+ } finally {
116
+ masterKey.fill(0);
117
+ }
118
+ }