@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
@@ -41,9 +41,17 @@ function policyStateForTier(policy, tier) {
41
41
  }
42
42
  /** Per-recipient encryption eligibility, given this device's own encryption preference and the
43
43
  * system-wide policy. One entry per (address, lookup result) pair the caller already resolved via
44
- * `lookupKeys()` - this function does no discovery itself. */
45
- export function resolveRecipientEncryption(ownAddress, ownPrefersMutual, policy, recipientAddress, lookup) {
46
- const encryptCert = lookup ? findActivePublicKey(lookup.keys, "encrypt") : undefined;
44
+ * `lookupKeys()` - this function does no discovery itself. `ownCanUseOpenPgp` false: the sender has no OpenPGP key to use here
45
+ * (an OpenPGP copy is encrypted to the sender too), so a recipient who prefers OpenPGP but has an S/MIME certificate gets S/MIME - a
46
+ * preference never leaves them unencrypted (restapi `specs/end-to-end_encryption.md` *Choosing the format*). */
47
+ export function resolveRecipientEncryption(ownAddress, ownPrefersMutual, policy, recipientAddress, lookup, ownCanUseOpenPgp = true) {
48
+ const smimeCert = lookup ? findActivePublicKey(lookup.keys, "encrypt") : undefined;
49
+ const usableOpenPgpKey = lookup && policy.allowOpenPgp === true ? findActivePublicKey(lookup.keys, "encrypt", "openpgp") : undefined;
50
+ // S/MIME, unless the recipient has no certificate or prefers OpenPGP (restapi *Choosing the format*: the user's choice for the
51
+ // contact, what the address advertises, what it was last seen using) - and OpenPGP only where the server allows it.
52
+ const useOpenPgp = !!usableOpenPgpKey && (!smimeCert || (lookup?.preferredFormat === "openpgp" && ownCanUseOpenPgp));
53
+ const encryptCert = useOpenPgp ? undefined : smimeCert;
54
+ const openPgpKey = useOpenPgp ? usableOpenPgpKey : undefined;
47
55
  const tier = classifyRecipientTier(ownAddress, recipientAddress, !!lookup && lookup.keys.length > 0);
48
56
  const policyState = policyStateForTier(policy, tier);
49
57
  if (policyState === "prohibited") {
@@ -56,7 +64,7 @@ export function resolveRecipientEncryption(ownAddress, ownPrefersMutual, policy,
56
64
  prohibitedReason: "Your organization's encryption policy does not allow encrypting messages to this recipient.",
57
65
  };
58
66
  }
59
- if (!encryptCert) {
67
+ if (!encryptCert && !openPgpKey) {
60
68
  return { address: recipientAddress, tier, canEncrypt: false, autoEncrypt: false };
61
69
  }
62
70
  const recipientPrefersMutual = lookup?.encryptPreference?.preferEncrypt === "mutual";
@@ -64,7 +72,7 @@ export function resolveRecipientEncryption(ownAddress, ownPrefersMutual, policy,
64
72
  return {
65
73
  address: recipientAddress,
66
74
  tier,
67
- encryptCert,
75
+ ...(encryptCert ? { encryptCert, format: "smime" } : { openPgpKey, format: "openpgp" }),
68
76
  canEncrypt: true,
69
77
  autoEncrypt: policyState === "automatic" && mutual,
70
78
  };
@@ -14,7 +14,7 @@ import "reflect-metadata";
14
14
  import * as asn1js from "asn1js";
15
15
  import * as pkijs from "pkijs";
16
16
  import { fromBase64 } from "./encoding.js";
17
- import { findActivePublicKey, recordKeyExport } from "./keyvaultApi.js";
17
+ import { findActivePublicKey, isOpenPgpKey, recordKeyExport } from "./keyvaultApi.js";
18
18
  import { ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, MAX_RETAINED_ENCRYPTION_KEYS, openMasterKeyWithPassword, SIGNING_PRIVATE_KEY_AAD_PURPOSE, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE, } from "./keySession.js";
19
19
  import { buildAad, openWithKey } from "./masterKey.js";
20
20
  /** PBKDF2 iterations for the shrouded key bags and the PKCS#12 MAC. Windows, macOS and iOS all accept this. */
@@ -90,7 +90,7 @@ async function openExportedKeys(input, vault, masterKey) {
90
90
  const active = findActivePublicKey(input.mailboxKeys, "encrypt");
91
91
  const encryption = [
92
92
  ...(active ? [active] : []),
93
- ...input.mailboxKeys.filter((k) => k.useType === "encrypt" && k !== active).sort((a, b) => b.notBefore - a.notBefore),
93
+ ...input.mailboxKeys.filter((k) => k.useType === "encrypt" && !isOpenPgpKey(k) && k !== active).sort((a, b) => b.notBefore - a.notBefore),
94
94
  ];
95
95
  await take("encrypt", encryption, ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, MAX_RETAINED_ENCRYPTION_KEYS + 1);
96
96
  if (input.includeSigningKey) {
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Whether a certificate the server lists is really the one of a private key this device opened. A zero-access key's private half comes
3
+ * from an authenticated seal (or a member's wrap), but its certificate comes from the server as it is: what this device then encrypts
4
+ * to that certificate - the search index segments it builds from end-to-end mail, a protected meeting's key - must not go to a key the
5
+ * server swapped in. Plain WebCrypto and a few lines of DER, so the unlock path loads no certificate library.
6
+ */
7
+ /** Whether `certDer` (a P-256 certificate) carries the public half of `pkcs8` (a P-256 private key). Never rejects: anything that won't
8
+ * parse or import is `false`. */
9
+ export declare function certificateHoldsKey(certDer: Uint8Array, pkcs8: Uint8Array): Promise<boolean>;
@@ -0,0 +1,65 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Whether a certificate the server lists is really the one of a private key this device opened. A zero-access key's private half comes
7
+ * from an authenticated seal (or a member's wrap), but its certificate comes from the server as it is: what this device then encrypts
8
+ * to that certificate - the search index segments it builds from end-to-end mail, a protected meeting's key - must not go to a key the
9
+ * server swapped in. Plain WebCrypto and a few lines of DER, so the unlock path loads no certificate library.
10
+ */
11
+ /** One DER element of `der` at `offset`: where its header starts, where its content starts, where it ends. */
12
+ function element(der, offset) {
13
+ const tag = der[offset];
14
+ let length = der[offset + 1];
15
+ let content = offset + 2;
16
+ if (length & 0x80) {
17
+ const bytes = length & 0x7f;
18
+ if (bytes === 0 || bytes > 4) {
19
+ throw new Error("Unsupported DER length.");
20
+ }
21
+ length = 0;
22
+ for (let i = 0; i < bytes; i++) {
23
+ length = length * 256 + der[content + i];
24
+ }
25
+ content += bytes;
26
+ }
27
+ const end = content + length;
28
+ if (tag === undefined || end > der.length) {
29
+ throw new Error("Truncated DER.");
30
+ }
31
+ return { tag, header: offset, content, end };
32
+ }
33
+ /** The children of the constructed element `parent`. */
34
+ function children(der, parent) {
35
+ const found = [];
36
+ for (let offset = parent.content; offset < parent.end; offset = found[found.length - 1].end) {
37
+ found.push(element(der, offset));
38
+ }
39
+ return found;
40
+ }
41
+ /** The SubjectPublicKeyInfo of X.509 certificate `certDer` (RFC 5280: the seventh field of `tbsCertificate`, the sixth when it has no
42
+ * explicit version). */
43
+ function subjectPublicKeyInfo(certDer) {
44
+ const [tbs] = children(certDer, element(certDer, 0));
45
+ const fields = children(certDer, tbs);
46
+ const spki = fields[fields[0].tag === 0xa0 ? 6 : 5];
47
+ if (!spki || spki.tag !== 0x30) {
48
+ throw new Error("No subject public key.");
49
+ }
50
+ return certDer.subarray(spki.header, spki.end);
51
+ }
52
+ const P256_ECDH = { name: "ECDH", namedCurve: "P-256" };
53
+ /** Whether `certDer` (a P-256 certificate) carries the public half of `pkcs8` (a P-256 private key). Never rejects: anything that won't
54
+ * parse or import is `false`. */
55
+ export async function certificateHoldsKey(certDer, pkcs8) {
56
+ try {
57
+ const publicKey = await crypto.subtle.importKey("spki", subjectPublicKeyInfo(certDer), P256_ECDH, true, []);
58
+ const privateKey = await crypto.subtle.importKey("pkcs8", pkcs8, P256_ECDH, true, ["deriveBits"]);
59
+ const [certified, held] = await Promise.all([crypto.subtle.exportKey("jwk", publicKey), crypto.subtle.exportKey("jwk", privateKey)]);
60
+ return !!certified.x && certified.x === held.x && certified.y === held.y;
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
@@ -20,6 +20,7 @@
20
20
  import { type KeyVault, type PublicKey } from "./keyvaultApi.js";
21
21
  import type { ApiClient } from "../util/api.js";
22
22
  import { KeysLockedError } from "./masterKey.js";
23
+ import type { OpenPgpPrivateKey } from "./openpgp.js";
23
24
  /** AAD purpose labels — MUST exactly match what `KeyEnrollmentGate.tsx` used when it originally
24
25
  * sealed each of these values, or `openWithKey()` fails (GCM authenticates the AAD, not just the
25
26
  * ciphertext). Centralized here as the one place both wrapping and unwrapping refer to. */
@@ -27,6 +28,7 @@ export declare const MASTER_KEY_AAD_PURPOSE = "master-key";
27
28
  export declare const SIGNING_PRIVATE_KEY_AAD_PURPOSE = "sign-private-key";
28
29
  export declare const ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE = "encrypt-private-key";
29
30
  export declare const ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE = "zero-access-private-key";
31
+ export declare const OPENPGP_PRIVATE_KEY_AAD_PURPOSE = "openpgp-private-key";
30
32
  export { KeysLockedError };
31
33
  /** How many retained (non-active) encryption keys an unlock opens at most: the most recently issued ones by
32
34
  * `notBefore`. Each costs an AEAD open and a key import at unlock, and `smime.ts`'s `decryptEnvelopedDataWithKeys()`
@@ -66,6 +68,13 @@ export interface UnlockedKeys {
66
68
  * index entries and sealed summaries to - the active one first, then retained ones newest first, at most
67
69
  * `MAX_RETAINED_ENCRYPTION_KEYS + 1`. Decryption only. Absent when the mailbox has none (or none opened). */
68
70
  zeroAccessKeys?: RetainedEncryptionKey[];
71
+ /** The mailbox's OpenPGP secret keys (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*) - the active one
72
+ * first, then retained ones newest first, at most `MAX_RETAINED_ENCRYPTION_KEYS + 1`: what opens mail OpenPGP users encrypt to
73
+ * the mailbox, and signs what it sends them. Absent when the mailbox has none (or none opened). */
74
+ openPgpKeys?: {
75
+ fingerprint: string;
76
+ privateKey: OpenPgpPrivateKey;
77
+ }[];
69
78
  /** A shared mailbox opened through this user's member key wraps (`sharedMailboxKeys.ts`), not with its own master key: it holds
70
79
  * only `zeroAccessKeys`, and `masterKey` is a per-key secret derived from its zero-access private key (what the local search
71
80
  * index's key is derived from), never a key that opens the mailbox's vault. */
@@ -96,6 +105,14 @@ export declare function subscribeKeySession(listener: KeySessionListener): () =>
96
105
  * meanwhile; the next unlock opens the key from the vault anyway).
97
106
  */
98
107
  export declare function addZeroAccessKey(mailboxUid: string, unlocked: UnlockedKeys, key: RetainedEncryptionKey): void;
108
+ /**
109
+ * Makes an OpenPGP key this device has just enrolled (`openPgpKey.ts`'s `ensureOpenPgpKey()`) the active one of `unlocked`, so
110
+ * OpenPGP mail to it reads without another unlock - the same rules as `addZeroAccessKey()`.
111
+ */
112
+ export declare function addOpenPgpKey(mailboxUid: string, unlocked: UnlockedKeys, key: {
113
+ fingerprint: string;
114
+ privateKey: OpenPgpPrivateKey;
115
+ }): void;
99
116
  /**
100
117
  * Stores the keys of a shared mailbox opened through member key wraps (`sharedMailboxKeys.ts`'s `unlockSharedMailbox()`) as that mailbox's
101
118
  * session, exactly as an unlock would - tracked for `destroyUnlockedKeys()`, and notified - unless the mailbox was locked since `generation`
@@ -163,8 +180,8 @@ export declare class UnopenableEncryptionKeyError extends Error {
163
180
  * "incorrect password," not a generic error, but this module doesn't presume a specific UI's error copy.
164
181
  * Once the master key has opened, the password is known to be right: an active signing key that then
165
182
  * won't open is skipped and listed in `UnlockResult.unopenableKeys` (so is a retained encryption key); an active
166
- * encryption key that won't
167
- * open still fails the unlock, with `UnopenableEncryptionKeyError`. Throws `KeysLockedError` when
183
+ * encryption key that won't open, or whose certificate isn't its own,
184
+ * still fails the unlock, with `UnopenableEncryptionKeyError`. Throws `KeysLockedError` when
168
185
  * `destroyUnlockedKeys()` locked this mailbox (or all mailboxes) while the unlock was in flight.
169
186
  */
170
187
  export declare function unlockWithPassword(mailboxUid: string, mailboxKeys: PublicKey[], password: string): Promise<UnlockResult>;
@@ -21,8 +21,9 @@
21
21
  * follow-up, not a silent gap: a mailbox enrolled *only* with a passkey has no way to unlock through this
22
22
  * module today.
23
23
  */
24
- import { findActivePublicKey, getKeyVault } from "./keyvaultApi.js";
24
+ import { findActivePublicKey, getKeyVault, isOpenPgpKey } from "./keyvaultApi.js";
25
25
  import { fromBase64 } from "./encoding.js";
26
+ import { certificateHoldsKey } from "./keyPairMatch.js";
26
27
  import { importPrivateKeyPkcs8 } from "./keys.js";
27
28
  import { KeysLockedError, buildAad, openWithKey } from "./masterKey.js";
28
29
  import { deriveFromPassword, parseArgon2idKdfLabel } from "./passwordUnlock.js";
@@ -34,6 +35,7 @@ export const MASTER_KEY_AAD_PURPOSE = "master-key";
34
35
  export const SIGNING_PRIVATE_KEY_AAD_PURPOSE = "sign-private-key";
35
36
  export const ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE = "encrypt-private-key";
36
37
  export const ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE = "zero-access-private-key";
38
+ export const OPENPGP_PRIVATE_KEY_AAD_PURPOSE = "openpgp-private-key";
37
39
  export { KeysLockedError };
38
40
  /** How many retained (non-active) encryption keys an unlock opens at most: the most recently issued ones by
39
41
  * `notBefore`. Each costs an AEAD open and a key import at unlock, and `smime.ts`'s `decryptEnvelopedDataWithKeys()`
@@ -102,6 +104,16 @@ export function addZeroAccessKey(mailboxUid, unlocked, key) {
102
104
  }
103
105
  unlocked.zeroAccessKeys = [key, ...(unlocked.zeroAccessKeys ?? []).filter((k) => k.fingerprint !== key.fingerprint)];
104
106
  }
107
+ /**
108
+ * Makes an OpenPGP key this device has just enrolled (`openPgpKey.ts`'s `ensureOpenPgpKey()`) the active one of `unlocked`, so
109
+ * OpenPGP mail to it reads without another unlock - the same rules as `addZeroAccessKey()`.
110
+ */
111
+ export function addOpenPgpKey(mailboxUid, unlocked, key) {
112
+ if (unlocked.destroyed || sessions.get(mailboxUid) !== unlocked) {
113
+ return;
114
+ }
115
+ unlocked.openPgpKeys = [key, ...(unlocked.openPgpKeys ?? []).filter((k) => k.fingerprint !== key.fingerprint)];
116
+ }
105
117
  /**
106
118
  * Stores the keys of a shared mailbox opened through member key wraps (`sharedMailboxKeys.ts`'s `unlockSharedMailbox()`) as that mailbox's
107
119
  * session, exactly as an unlock would - tracked for `destroyUnlockedKeys()`, and notified - unless the mailbox was locked since `generation`
@@ -197,6 +209,15 @@ function destroyObject(unlocked) {
197
209
  unlocked.zeroAccessKeys.length = 0;
198
210
  delete unlocked.zeroAccessKeys;
199
211
  }
212
+ if (unlocked.openPgpKeys) {
213
+ // OpenPGP.js keeps a decrypted secret key's material in its own objects: wipe it there too.
214
+ for (const { privateKey } of unlocked.openPgpKeys) {
215
+ // Present at runtime on every `PrivateKey`, missing from OpenPGP.js's type declarations.
216
+ privateKey.clearPrivateParams();
217
+ }
218
+ unlocked.openPgpKeys.length = 0;
219
+ delete unlocked.openPgpKeys;
220
+ }
200
221
  }
201
222
  /** Finds the wrapped private key whose fingerprint matches a given published public key. */
202
223
  function findWrappedPrivateKey(vault, fingerprint) {
@@ -217,10 +238,15 @@ export class UnopenableEncryptionKeyError extends Error {
217
238
  }
218
239
  }
219
240
  /** Opens one wrapped PKCS#8 private key under `masterKey` and imports it (non-extractable by default, like
220
- * `importPrivateKeyPkcs8()` itself), zeroing the transient plaintext whether or not the import succeeds. */
221
- async function openPrivateKey(masterKey, wrapped, aad, algorithm, usages, extractable = false) {
241
+ * `importPrivateKeyPkcs8()` itself), zeroing the transient plaintext whether or not the import succeeds. With `certDer` - the server's
242
+ * certificate for it, unauthenticated - it rejects a key that certificate isn't the one of (`certificateHoldsKey()`), so a swapped
243
+ * certificate never pairs with the opened key. */
244
+ async function openPrivateKey(masterKey, wrapped, aad, algorithm, usages, extractable = false, certDer) {
222
245
  const raw = await openWithKey(masterKey, wrapped, aad);
223
246
  try {
247
+ if (certDer && !(await certificateHoldsKey(certDer, raw))) {
248
+ throw new Error("The certificate isn't the key's.");
249
+ }
224
250
  return await importPrivateKeyPkcs8(raw, algorithm, usages, extractable);
225
251
  }
226
252
  finally {
@@ -231,7 +257,7 @@ async function openPrivateKey(masterKey, wrapped, aad, algorithm, usages, extrac
231
257
  * Opens the retained encryption keys onto `unlocked.retainedEncryptionKeys`: every published `encrypt` key in
232
258
  * `mailboxKeys` other than the active one (`activeFingerprint`), whatever its revocation or expiry, that has a wrapped
233
259
  * private key in the vault - de-duplicated by fingerprint, newest `notBefore` first, at most
234
- * `MAX_RETAINED_ENCRYPTION_KEYS`. A key that won't open or import, or whose certificate won't decode, is skipped and
260
+ * `MAX_RETAINED_ENCRYPTION_KEYS`. A key that won't open or import, or whose certificate won't decode or isn't the key's, is skipped and
235
261
  * added to `unopenableKeys`. Retained keys are imported non-extractable. Each key is attached as it opens, so a failed
236
262
  * unlock's `destroyObject()` drops those too.
237
263
  */
@@ -239,7 +265,7 @@ async function openRetainedEncryptionKeys(mailboxUid, mailboxKeys, vault, unlock
239
265
  const seen = new Set(activeFingerprint !== undefined ? [activeFingerprint] : []);
240
266
  const candidates = [];
241
267
  for (const publicKey of [...mailboxKeys].sort((a, b) => b.notBefore - a.notBefore)) {
242
- const wrapped = publicKey.useType === "encrypt" && !seen.has(publicKey.fingerprint) ? findWrappedPrivateKey(vault, publicKey.fingerprint) : undefined;
268
+ const wrapped = publicKey.useType === "encrypt" && !isOpenPgpKey(publicKey) && !seen.has(publicKey.fingerprint) ? findWrappedPrivateKey(vault, publicKey.fingerprint) : undefined;
243
269
  if (!wrapped) {
244
270
  continue;
245
271
  }
@@ -253,7 +279,7 @@ async function openRetainedEncryptionKeys(mailboxUid, mailboxKeys, vault, unlock
253
279
  for (const { publicKey, wrapped } of candidates) {
254
280
  try {
255
281
  const certDer = fromBase64(publicKey.publicKey);
256
- const privateKey = await openPrivateKey(unlocked.masterKey, wrapped, aad, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
282
+ const privateKey = await openPrivateKey(unlocked.masterKey, wrapped, aad, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false, certDer);
257
283
  (unlocked.retainedEncryptionKeys ?? (unlocked.retainedEncryptionKeys = [])).push({ fingerprint: publicKey.fingerprint, certDer, privateKey });
258
284
  }
259
285
  catch {
@@ -265,7 +291,9 @@ async function openRetainedEncryptionKeys(mailboxUid, mailboxKeys, vault, unlock
265
291
  * Opens the mailbox's zero-access private keys into `unlocked.zeroAccessKeys`: the vault's `zeroAccessKeys` that have a
266
292
  * wrapped private key, the unrevoked one with the latest `notBefore` first, then the rest newest first, at most
267
293
  * `MAX_RETAINED_ENCRYPTION_KEYS + 1`. A key that won't open is listed in `unopenableKeys` and skipped - zero-access mail
268
- * encrypted to it doesn't decrypt on this device, but nothing else about the unlock depends on it.
294
+ * encrypted to it doesn't decrypt on this device, but nothing else about the unlock depends on it. So is one whose certificate (the
295
+ * server's, unauthenticated) isn't the private key's: this device seals to the active key's certificate - search index segments of
296
+ * end-to-end mail, protected meetings' keys - so a certificate swapped by the server must never become it.
269
297
  */
270
298
  async function openZeroAccessKeys(mailboxUid, vault, unlocked, unopenableKeys) {
271
299
  const ordered = [...(vault.zeroAccessKeys ?? [])].sort((a, b) => Number(!!a.revokedAt) - Number(!!b.revokedAt) || b.notBefore - a.notBefore);
@@ -277,8 +305,18 @@ async function openZeroAccessKeys(mailboxUid, vault, unlocked, unopenableKeys) {
277
305
  continue;
278
306
  }
279
307
  try {
280
- const privateKey = await openPrivateKey(unlocked.masterKey, wrapped, aad, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
281
- (unlocked.zeroAccessKeys ?? (unlocked.zeroAccessKeys = [])).push({ fingerprint: publicKey.fingerprint, certDer: fromBase64(publicKey.publicKey), privateKey });
308
+ const certDer = fromBase64(publicKey.publicKey);
309
+ const raw = await openWithKey(unlocked.masterKey, wrapped, aad);
310
+ try {
311
+ if (!(await certificateHoldsKey(certDer, raw))) {
312
+ throw new Error("The zero-access certificate isn't the key's.");
313
+ }
314
+ const privateKey = await importPrivateKeyPkcs8(raw, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
315
+ (unlocked.zeroAccessKeys ?? (unlocked.zeroAccessKeys = [])).push({ fingerprint: publicKey.fingerprint, certDer, privateKey });
316
+ }
317
+ finally {
318
+ raw.fill(0);
319
+ }
282
320
  }
283
321
  catch {
284
322
  unopenableKeys.push(publicKey.fingerprint);
@@ -288,6 +326,39 @@ async function openZeroAccessKeys(mailboxUid, vault, unlocked, unopenableKeys) {
288
326
  }
289
327
  }
290
328
  }
329
+ /**
330
+ * Opens the mailbox's OpenPGP secret keys into `unlocked.openPgpKeys`: its published OpenPGP certificates (`type: "openpgp"`) that
331
+ * have a wrapped secret key, the active one first (unrevoked, unexpired, latest `notBefore`), then the rest newest first, at most
332
+ * `MAX_RETAINED_ENCRYPTION_KEYS + 1`. OpenPGP.js is loaded only for a mailbox that has one. A key that won't open is listed in
333
+ * `unopenableKeys` and skipped.
334
+ */
335
+ async function openOpenPgpKeys(mailboxUid, mailboxKeys, vault, unlocked, unopenableKeys) {
336
+ const now = Date.now();
337
+ const active = (key) => !key.revokedAt && key.notAfter > now;
338
+ const ordered = mailboxKeys
339
+ .filter((key) => isOpenPgpKey(key) && findWrappedPrivateKey(vault, key.fingerprint))
340
+ .sort((a, b) => Number(active(b)) - Number(active(a)) || b.notBefore - a.notBefore)
341
+ .slice(0, MAX_RETAINED_ENCRYPTION_KEYS + 1);
342
+ if (ordered.length === 0) {
343
+ return;
344
+ }
345
+ const { readSecretKey } = await import("./openpgp.js");
346
+ const aad = buildAad(mailboxUid, OPENPGP_PRIVATE_KEY_AAD_PURPOSE);
347
+ for (const publicKey of ordered) {
348
+ try {
349
+ const raw = await openWithKey(unlocked.masterKey, findWrappedPrivateKey(vault, publicKey.fingerprint), aad);
350
+ try {
351
+ (unlocked.openPgpKeys ?? (unlocked.openPgpKeys = [])).push({ fingerprint: publicKey.fingerprint, privateKey: await readSecretKey(raw) });
352
+ }
353
+ finally {
354
+ raw.fill(0);
355
+ }
356
+ }
357
+ catch {
358
+ unopenableKeys.push(publicKey.fingerprint);
359
+ }
360
+ }
361
+ }
291
362
  /**
292
363
  * Unlocks a mailbox's key vault with its password unlock method: fetches the vault, derives the
293
364
  * wrapping key from `password` using the *exact* KDF parameters that wrap was created with, unwraps
@@ -300,8 +371,8 @@ async function openZeroAccessKeys(mailboxUid, vault, unlocked, unopenableKeys) {
300
371
  * "incorrect password," not a generic error, but this module doesn't presume a specific UI's error copy.
301
372
  * Once the master key has opened, the password is known to be right: an active signing key that then
302
373
  * won't open is skipped and listed in `UnlockResult.unopenableKeys` (so is a retained encryption key); an active
303
- * encryption key that won't
304
- * open still fails the unlock, with `UnopenableEncryptionKeyError`. Throws `KeysLockedError` when
374
+ * encryption key that won't open, or whose certificate isn't its own,
375
+ * still fails the unlock, with `UnopenableEncryptionKeyError`. Throws `KeysLockedError` when
305
376
  * `destroyUnlockedKeys()` locked this mailbox (or all mailboxes) while the unlock was in flight.
306
377
  */
307
378
  export async function unlockWithPassword(mailboxUid, mailboxKeys, password) {
@@ -416,18 +487,23 @@ async function openSession(mailboxUid, mailboxKeys, vault, masterKey, generation
416
487
  const encryptionPublicKey = findActivePublicKey(mailboxKeys, "encrypt");
417
488
  const wrappedEncryptionKey = encryptionPublicKey && findWrappedPrivateKey(vault, encryptionPublicKey.fingerprint);
418
489
  if (encryptionPublicKey && wrappedEncryptionKey) {
490
+ let encryptionCertDer;
419
491
  try {
492
+ // The certificate is the server's: one that isn't this key's - what this device encrypts its own copies to, and what it
493
+ // hands out as its S/MIME certificate - makes the key unopenable, exactly as a wrap that won't open.
494
+ encryptionCertDer = fromBase64(encryptionPublicKey.publicKey);
420
495
  const aad = buildAad(mailboxUid, ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE);
421
- unlocked.encryptionPrivateKey = await openPrivateKey(masterKey, wrappedEncryptionKey, aad, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"]);
496
+ unlocked.encryptionPrivateKey = await openPrivateKey(masterKey, wrappedEncryptionKey, aad, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false, encryptionCertDer);
422
497
  }
423
498
  catch (err) {
424
499
  throw new UnopenableEncryptionKeyError(encryptionPublicKey.fingerprint, err);
425
500
  }
426
- unlocked.encryptionCertDer = fromBase64(encryptionPublicKey.publicKey);
501
+ unlocked.encryptionCertDer = encryptionCertDer;
427
502
  unlocked.encryptionFingerprint = encryptionPublicKey.fingerprint;
428
503
  }
429
504
  await openRetainedEncryptionKeys(mailboxUid, mailboxKeys, vault, unlocked, encryptionPublicKey?.fingerprint, unopenableKeys);
430
505
  await openZeroAccessKeys(mailboxUid, vault, unlocked, unopenableKeys);
506
+ await openOpenPgpKeys(mailboxUid, mailboxKeys, vault, unlocked, unopenableKeys);
431
507
  if (lockGeneration(mailboxUid) !== generation) {
432
508
  // Locked (e.g. logout) while this unlock was awaiting - never restore keys after that.
433
509
  throw new KeysLockedError();
@@ -10,9 +10,10 @@ import { ApiClient, ApiRequestError } from "../util/api.js";
10
10
  /** A cryptographic public key used to sign or encrypt messages — safe to expose publicly. Mirrors
11
11
  * `@rapidmx/restapi`'s `PublicKey` type exactly. */
12
12
  export interface PublicKey {
13
- /** Base64-encoded DER X.509 certificate. */
13
+ /** Base64-encoded DER X.509 certificate - or, for `type: "openpgp"`, the binary OpenPGP transferable public key. */
14
14
  publicKey: string;
15
- /** The key's type/format (e.g. `x509`). */
15
+ /** The key's type/format: `x509` (S/MIME), or `openpgp` - an OpenPGP certificate, which both encrypts and signs and is listed
16
+ * once, with `useType: "encrypt"` (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*). */
16
17
  type: string;
17
18
  useType: "sign" | "encrypt";
18
19
  /** SHA-256 fingerprint of the certificate, hex encoded. */
@@ -42,7 +43,20 @@ export interface WrappedPrivateKey {
42
43
  algorithm: string;
43
44
  fingerprint: string;
44
45
  useType: KeyUseType;
46
+ /** `"openpgp"` for an OpenPGP secret key (sealed with `OPENPGP_PRIVATE_KEY_AAD_PURPOSE`); absent for a PKCS#8 key. */
47
+ type?: string;
45
48
  }
49
+ /** `PublicKey.type` of an OpenPGP certificate. */
50
+ export declare const OPENPGP_KEY_TYPE = "openpgp";
51
+ /** Whether `key` is an OpenPGP certificate (or an OpenPGP secret key's wrap). */
52
+ export declare function isOpenPgpKey(key: {
53
+ type?: string;
54
+ } | undefined): boolean;
55
+ /** A key's format: `"openpgp"`, or `"x509"` for every other key (S/MIME). */
56
+ export type KeyFormat = "x509" | "openpgp";
57
+ export declare function keyFormat(key: {
58
+ type?: string;
59
+ }): KeyFormat;
46
60
  /** What a mailbox's own key is used for. `sign`/`encrypt` keys are published (`Mailbox.keys`); `zero-access` keys never are.
47
61
  * Mirrors `@rapidmx/restapi`'s `KeyUseType`. */
48
62
  export type KeyUseType = "sign" | "encrypt" | "zero-access";
@@ -73,7 +87,12 @@ export interface MasterKeyWrap {
73
87
  export interface EncryptionPreference {
74
88
  lastSeen?: number;
75
89
  preferEncrypt: "mutual" | "nopreference";
90
+ /** Which end-to-end format the address would rather receive when a sender can use either (restapi
91
+ * `specs/end-to-end_encryption.md` *Choosing the format*). Absent: no preference. */
92
+ preferFormat?: EndToEndFormat;
76
93
  }
94
+ /** The two end-to-end encryption formats. */
95
+ export type EndToEndFormat = "smime" | "openpgp";
77
96
  /** A newly observed key that differs from a contact's pinned key of the same `useType` - restapi records it instead of
78
97
  * silently replacing the pin (the spec's Key Conflict Handling) until the user resolves it with `resolveKeyConflict()`.
79
98
  * A contact holds at most one per `useType`. Mirrors `@rapidmx/restapi`'s `KeyConflict`. */
@@ -133,7 +152,7 @@ export declare function getKeyVault(mailboxUid: string, client?: ApiClient): Pro
133
152
  * `revocationReason` - a superseded key may still verify old mail (`isTrustedForVerification()`) but is never used for
134
153
  * new mail. Shared by `crypto/keySession.ts` (the
135
154
  * mailbox's own keys) and `crypto/composeSecurity.ts` (a recipient's discovered keys). */
136
- export declare function findActivePublicKey(keys: PublicKey[], useType: "sign" | "encrypt"): PublicKey | undefined;
155
+ export declare function findActivePublicKey(keys: PublicKey[], useType: "sign" | "encrypt", format?: KeyFormat): PublicKey | undefined;
137
156
  /**
138
157
  * Whether `key` may still be trusted to *verify* existing signatures - never whether to sign or encrypt with it (that is
139
158
  * `findActivePublicKey()`, which skips every revoked key). Expired keys are trusted: mail signed while a key was valid
@@ -165,7 +184,11 @@ export interface EnrollKeyInput extends ExpectedMasterKeyGeneration {
165
184
  csr?: string;
166
185
  /** An already-issued PEM certificate — required (and only meaningful) for `useType: "sign"`. */
167
186
  certificate?: string;
168
- wrappedKey: Omit<WrappedPrivateKey, "fingerprint" | "useType">;
187
+ /** `"openpgp"` to enroll the mailbox's OpenPGP key (with `useType: "encrypt"`): self-certified, no CA. */
188
+ type?: string;
189
+ /** The OpenPGP public key (armored or base64 binary) - required, and only meaningful, with `type: "openpgp"`. */
190
+ publicKey?: string;
191
+ wrappedKey: Omit<WrappedPrivateKey, "fingerprint" | "useType" | "type">;
169
192
  /** Only meaningful the very first time a mailbox enrolls a key at all (bootstraps its master key). */
170
193
  masterKeyWraps?: MasterKeyWrap[];
171
194
  }
@@ -401,6 +424,11 @@ export interface KeyLookupResult {
401
424
  keyConflicts?: KeyConflict[];
402
425
  /** Keys that were pinned before, newest first - see `PreviousKey`. */
403
426
  previousKeys?: PreviousKey[];
427
+ /** The format to use for a recipient reachable in both (restapi *Choosing the format*): the user's choice for the contact, else what
428
+ * the address advertises, else what it was last seen using. Absent when nothing says - S/MIME then. */
429
+ preferredFormat?: EndToEndFormat;
430
+ /** Where `preferredFormat` came from. */
431
+ preferredFormatSource?: "contact" | "advertised" | "observed";
404
432
  }
405
433
  /** Server-side Discovery: the server itself performs the `_rapidmx` DNS lookup and remote key-endpoint
406
434
  * fetch (browsers can't do DNS TXT lookups, and a direct cross-origin fetch would hit CORS), persisting
@@ -410,8 +438,11 @@ export declare function lookupKeys(mailboxUid: string, addr: string, client?: Ap
410
438
  /** What `trustSigner()` pins: the sender's address and the certificate that signed their message. */
411
439
  export interface TrustSignerInput {
412
440
  address: string;
413
- /** Base64 DER of the signer certificate - `MessageSecurityResult.signerCertificate`. */
441
+ /** Base64 DER of the signer certificate - `MessageSecurityResult.signerCertificate` - or, with `type: "openpgp"`, an OpenPGP
442
+ * public key (armored or base64 binary): a signer's, a key attached to a message, or one the user pasted. */
414
443
  certificate: string;
444
+ /** `"openpgp"` to pin an OpenPGP key (it encrypts and signs); omitted for an X.509 signing certificate. */
445
+ type?: string;
415
446
  }
416
447
  /**
417
448
  * Thrown by `trustSigner()` when restapi answers `409`: a different signing key is already pinned for that address, so
@@ -444,6 +475,8 @@ export interface ResolveKeyConflictInput {
444
475
  /** For `"accept"`: base64 DER of the certificate to pin (e.g. `MessageSecurityResult.signerCertificate` of a
445
476
  * `signer_key_changed` result). Omitted, restapi pins the recorded conflict's `observedKey`. */
446
477
  certificate?: string;
478
+ /** `"openpgp"` for the contact's OpenPGP key (always `useType: "encrypt"`); omitted for its S/MIME certificate. */
479
+ type?: string;
447
480
  }
448
481
  /** The contact's resulting key state after `resolveKeyConflict()`. */
449
482
  export type ResolveKeyConflictResult = KeyLookupResult;
@@ -471,6 +504,9 @@ export interface EncryptionPolicy {
471
504
  * supports zero-access encryption (its stored value or the server default); absent from one that doesn't, which a
472
505
  * caller treats as not allowed. */
473
506
  allowZeroAccess?: boolean;
507
+ /** "Allow OpenPGP": whether mailboxes may enroll and publish OpenPGP keys, for mail with OpenPGP users. Absent from a server
508
+ * that predates it, which a caller treats as not allowed. */
509
+ allowOpenPgp?: boolean;
474
510
  }
475
511
  /** The system-wide encryption policy (readable by any authenticated user, used to decide what encryption
476
512
  * controls a compose UI should offer). */
@@ -11,6 +11,15 @@
11
11
  * and `crypto/keys.ts` for the client-side cryptography that produces the values passed here.
12
12
  */
13
13
  import { ApiRequestError, withClient } from "../util/api.js";
14
+ /** `PublicKey.type` of an OpenPGP certificate. */
15
+ export const OPENPGP_KEY_TYPE = "openpgp";
16
+ /** Whether `key` is an OpenPGP certificate (or an OpenPGP secret key's wrap). */
17
+ export function isOpenPgpKey(key) {
18
+ return key?.type === OPENPGP_KEY_TYPE;
19
+ }
20
+ export function keyFormat(key) {
21
+ return isOpenPgpKey(key) ? "openpgp" : "x509";
22
+ }
14
23
  /** `client`, given by every function below that calls the network, is an explicit `ApiClient` from `createApiClient()`
15
24
  * (e.g. one account of a multi-account app) to call instead of the default global `apiFetch()` - see `withClient()`'s
16
25
  * own doc comment in `util/api.ts`. Omitted (the default), every function here behaves exactly as before. */
@@ -24,10 +33,10 @@ export function getKeyVault(mailboxUid, client) {
24
33
  * `revocationReason` - a superseded key may still verify old mail (`isTrustedForVerification()`) but is never used for
25
34
  * new mail. Shared by `crypto/keySession.ts` (the
26
35
  * mailbox's own keys) and `crypto/composeSecurity.ts` (a recipient's discovered keys). */
27
- export function findActivePublicKey(keys, useType) {
36
+ export function findActivePublicKey(keys, useType, format = "x509") {
28
37
  const now = Date.now();
29
38
  return keys
30
- .filter((k) => k.useType === useType && !k.revokedAt && k.notAfter > now)
39
+ .filter((k) => k.useType === useType && keyFormat(k) === format && !k.revokedAt && k.notAfter > now)
31
40
  .sort((a, b) => b.notBefore - a.notBefore)[0];
32
41
  }
33
42
  /**
@@ -320,7 +329,7 @@ export async function trustSigner(mailboxUid, input, client) {
320
329
  try {
321
330
  return await withClient(client, `/mail/mailboxes/${encodeURIComponent(mailboxUid)}/keys/trust`, {
322
331
  method: "POST",
323
- body: JSON.stringify({ address: input.address, certificate: input.certificate }),
332
+ body: JSON.stringify({ address: input.address, certificate: input.certificate, ...(input.type ? { type: input.type } : {}) }),
324
333
  });
325
334
  }
326
335
  catch (err) {
@@ -354,6 +363,7 @@ export async function resolveKeyConflict(mailboxUid, input, client) {
354
363
  action: input.action,
355
364
  expectedPinnedFingerprint: input.expectedPinnedFingerprint,
356
365
  ...(input.certificate !== undefined ? { certificate: input.certificate } : {}),
366
+ ...(input.type ? { type: input.type } : {}),
357
367
  };
358
368
  try {
359
369
  return await withClient(client, `/mail/mailboxes/${encodeURIComponent(mailboxUid)}/keys/resolve`, {
@@ -0,0 +1,25 @@
1
+ import type { DecryptionKey } from "./smime.js";
2
+ /** The `#` parameter of a link that carries a meeting key. */
3
+ export declare const MEETING_KEY_FRAGMENT_PARAM = "k";
4
+ /** A meeting key as its creator holds it. */
5
+ export interface GeneratedMeetingKey {
6
+ /** PKCS#8 DER of the private key - what a link carries and a wrap seals. */
7
+ pkcs8: Uint8Array;
8
+ /** The self-signed certificate's DER - what the server stores and data is sealed to. */
9
+ certificateDer: Uint8Array;
10
+ /** SHA-256 of `certificateDer`, lowercase hex. */
11
+ fingerprint: string;
12
+ }
13
+ /** A new meeting key valid until `expiresAt` (its certificate's `notAfter`). */
14
+ export declare function generateMeetingKey(expiresAt: Date): Promise<GeneratedMeetingKey>;
15
+ /** The `#` part a guest's link carries for `pkcs8`: `k=<base64url>`. */
16
+ export declare function meetingKeyFragment(pkcs8: Uint8Array): string;
17
+ /** The meeting key in `hash` (a `location.hash`, with or without its `#`), or `undefined` when it carries none or it is malformed. */
18
+ export declare function readMeetingKeyFragment(hash: string): Uint8Array | undefined;
19
+ /** 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. */
20
+ export declare function importMeetingKey(pkcs8: Uint8Array, certificateDer: Uint8Array, meetingUid: string): Promise<{
21
+ decryptionKey: DecryptionKey;
22
+ liveKey: CryptoKey;
23
+ }>;
24
+ /** The AES-256-GCM live key of meeting `meetingUid` from `ikm`, the meeting key's private scalar. Non-extractable. */
25
+ export declare function deriveLiveKey(ikm: Uint8Array, meetingUid: string): Promise<CryptoKey>;