@rapidmx/web-client 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/apps/shared/calendar/calendarReminders.ts +35 -0
  2. package/apps/shared/calendar/sealedEvents.ts +42 -9
  3. package/apps/shared/calendar/useCalendarReminders.ts +28 -9
  4. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
  5. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +29 -1
  6. package/apps/shared/components/calendar/EventDetails.tsx +13 -2
  7. package/apps/shared/components/calendar/EventEditor.tsx +124 -17
  8. package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
  9. package/apps/shared/components/calendar/EventModal.tsx +19 -2
  10. package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
  11. package/apps/shared/components/calendar/eventForm.ts +2 -0
  12. package/apps/shared/components/contacts/ContactCard.tsx +13 -1
  13. package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
  14. package/apps/shared/components/contacts/ContactForm.tsx +56 -7
  15. package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
  16. package/apps/shared/components/contacts/contactCardData.ts +6 -3
  17. package/apps/shared/components/contacts/contactKeys.ts +3 -3
  18. package/apps/shared/components/layout/AppShell.tsx +34 -5
  19. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +11 -4
  20. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +37 -6
  21. package/apps/shared/components/mail/InviteCard.tsx +11 -1
  22. package/apps/shared/components/mail/MessageDetailPane.tsx +25 -14
  23. package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
  24. package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
  25. package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
  26. package/apps/shared/components/mail/invite/inviteStore.ts +11 -1
  27. package/apps/shared/components/mail/pinnedSigners.ts +14 -0
  28. package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
  29. package/apps/shared/components/settings/KeyExportSetting.tsx +4 -1
  30. package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
  31. package/apps/shared/components/settings/ZeroAccessSetting.tsx +13 -2
  32. package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
  33. package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
  34. package/apps/shared/mail/outbox/sendJob.ts +349 -50
  35. package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
  36. package/apps/shared/search/LocalIndexLifecycle.tsx +62 -33
  37. package/apps/shared/search/localIndexBuilder.ts +35 -24
  38. package/apps/shared/search/localIndexRpcClient.ts +91 -11
  39. package/apps/shared/search/localIndexSchema.ts +18 -2
  40. package/apps/shared/search/localIndexTransport.ts +6 -3
  41. package/apps/shared/search/localIndexWorker.ts +35 -18
  42. package/apps/shared/search/searchTier2.ts +5 -0
  43. package/apps/shared/search/zeroAccessIndexSync.ts +232 -40
  44. package/apps/shared/search/zeroAccessSyncState.ts +70 -0
  45. package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
  46. package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
  47. package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
  48. package/apps/www/_shell.tsx +2 -0
  49. package/apps/www/calendar/index.tsx +49 -5
  50. package/apps/www/contacts/index.tsx +67 -11
  51. package/apps/www/index.tsx +10 -1
  52. package/apps/www/settings/encryption/index.tsx +16 -1
  53. package/apps/www/settings/privacy/index.tsx +87 -38
  54. package/apps/www/tasks/index.tsx +24 -6
  55. package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
  56. package/dist/apps/shared/calendar/calendarReminders.js +26 -0
  57. package/dist/apps/shared/calendar/sealedEvents.d.ts +3 -2
  58. package/dist/apps/shared/calendar/sealedEvents.js +40 -9
  59. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
  60. package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
  61. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
  62. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +9 -2
  63. package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
  64. package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
  65. package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
  66. package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
  67. package/dist/apps/shared/components/calendar/EventModal.js +15 -2
  68. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
  69. package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
  70. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
  71. package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
  72. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
  73. package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
  74. package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
  75. package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
  76. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
  77. package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
  78. package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
  79. package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
  80. package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
  81. package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
  82. package/dist/apps/shared/components/layout/AppShell.js +17 -6
  83. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -4
  84. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +3 -1
  85. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +30 -4
  86. package/dist/apps/shared/components/mail/InviteCard.js +11 -1
  87. package/dist/apps/shared/components/mail/MessageDetailPane.js +15 -11
  88. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
  89. package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
  90. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
  91. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
  92. package/dist/apps/shared/components/mail/invite/inviteStore.js +11 -1
  93. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
  94. package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
  95. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
  96. package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
  97. package/dist/apps/shared/components/settings/KeyExportSetting.js +3 -1
  98. package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
  99. package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
  100. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +2 -0
  101. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +12 -3
  102. package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
  103. package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
  104. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  105. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  106. package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
  107. package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
  108. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
  109. package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
  110. package/dist/apps/shared/search/LocalIndexLifecycle.js +55 -33
  111. package/dist/apps/shared/search/localIndexBuilder.d.ts +4 -1
  112. package/dist/apps/shared/search/localIndexBuilder.js +31 -20
  113. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
  114. package/dist/apps/shared/search/localIndexRpcClient.js +126 -10
  115. package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
  116. package/dist/apps/shared/search/localIndexSchema.js +16 -2
  117. package/dist/apps/shared/search/localIndexTransport.d.ts +6 -3
  118. package/dist/apps/shared/search/localIndexWorker.d.ts +9 -0
  119. package/dist/apps/shared/search/localIndexWorker.js +22 -15
  120. package/dist/apps/shared/search/searchTier2.js +5 -0
  121. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +37 -6
  122. package/dist/apps/shared/search/zeroAccessIndexSync.js +199 -37
  123. package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
  124. package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
  125. package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
  126. package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
  127. package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
  128. package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
  129. package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
  130. package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
  131. package/dist/apps/www/_shell.d.ts +1 -1
  132. package/dist/apps/www/_shell.js +2 -2
  133. package/dist/apps/www/calendar/index.js +47 -5
  134. package/dist/apps/www/contacts/index.js +64 -11
  135. package/dist/apps/www/index.js +10 -1
  136. package/dist/apps/www/settings/encryption/index.js +14 -4
  137. package/dist/apps/www/settings/privacy/index.js +46 -5
  138. package/dist/apps/www/tasks/index.js +21 -7
  139. package/dist/lib/admin/domainsApi.d.ts +1 -1
  140. package/dist/lib/calendar/calendarApi.d.ts +46 -6
  141. package/dist/lib/calendar/calendarApi.js +110 -17
  142. package/dist/lib/calendar/calendarMutations.d.ts +20 -7
  143. package/dist/lib/calendar/calendarMutations.js +73 -14
  144. package/dist/lib/calendar/inviteApi.d.ts +17 -3
  145. package/dist/lib/calendar/inviteApi.js +61 -4
  146. package/dist/lib/calendar/protectedEvents.d.ts +31 -0
  147. package/dist/lib/calendar/protectedEvents.js +113 -0
  148. package/dist/lib/calendar/recurrence.d.ts +2 -0
  149. package/dist/lib/calendar/recurrence.js +16 -0
  150. package/dist/lib/calendar/sealedInvites.d.ts +6 -8
  151. package/dist/lib/calendar/sealedInvites.js +43 -6
  152. package/dist/lib/contacts/contactsApi.d.ts +29 -3
  153. package/dist/lib/contacts/contactsApi.js +23 -7
  154. package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
  155. package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
  156. package/dist/lib/crypto/composeSecurity.d.ts +11 -3
  157. package/dist/lib/crypto/composeSecurity.js +13 -5
  158. package/dist/lib/crypto/keyExport.js +2 -2
  159. package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
  160. package/dist/lib/crypto/keyPairMatch.js +65 -0
  161. package/dist/lib/crypto/keySession.d.ts +19 -2
  162. package/dist/lib/crypto/keySession.js +89 -13
  163. package/dist/lib/crypto/keyvaultApi.d.ts +41 -5
  164. package/dist/lib/crypto/keyvaultApi.js +13 -3
  165. package/dist/lib/crypto/meetingKeys.d.ts +25 -0
  166. package/dist/lib/crypto/meetingKeys.js +87 -0
  167. package/dist/lib/crypto/messageSecurity.d.ts +26 -2
  168. package/dist/lib/crypto/messageSecurity.js +122 -10
  169. package/dist/lib/crypto/openPgpKey.d.ts +39 -0
  170. package/dist/lib/crypto/openPgpKey.js +89 -0
  171. package/dist/lib/crypto/openpgp.d.ts +47 -0
  172. package/dist/lib/crypto/openpgp.js +88 -0
  173. package/dist/lib/crypto/pgpMessage.d.ts +51 -0
  174. package/dist/lib/crypto/pgpMessage.js +271 -0
  175. package/dist/lib/crypto/sharedMailboxKeys.d.ts +15 -3
  176. package/dist/lib/crypto/sharedMailboxKeys.js +102 -29
  177. package/dist/lib/crypto/smimeMessage.d.ts +21 -0
  178. package/dist/lib/crypto/smimeMessage.js +11 -7
  179. package/dist/lib/crypto/zeroAccessKey.js +5 -0
  180. package/dist/lib/mail/mailApi.d.ts +33 -0
  181. package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
  182. package/dist/lib/mail/mailboxImportApi.js +15 -0
  183. package/dist/lib/search/zeroAccessIndexApi.d.ts +4 -0
  184. package/dist/lib/tasks/tasksApi.d.ts +11 -1
  185. package/dist/lib/tasks/tasksApi.js +10 -4
  186. package/dist/lib/util/clientPlatform.d.ts +14 -0
  187. package/dist/lib/util/clientPlatform.js +26 -0
  188. package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
  189. package/dist/lib/videoconf/protectedMeetings.js +181 -0
  190. package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
  191. package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
  192. package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
  193. package/dist/lib/zeroAccess/sealedMoves.js +63 -0
  194. package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
  195. package/dist/lib/zeroAccess/sealedRecords.js +136 -0
  196. package/lib/admin/domainsApi.ts +1 -1
  197. package/lib/calendar/calendarApi.ts +140 -17
  198. package/lib/calendar/calendarMutations.ts +79 -12
  199. package/lib/calendar/inviteApi.ts +75 -5
  200. package/lib/calendar/protectedEvents.ts +143 -0
  201. package/lib/calendar/recurrence.ts +17 -0
  202. package/lib/calendar/sealedInvites.ts +47 -7
  203. package/lib/contacts/contactsApi.ts +43 -17
  204. package/lib/contacts/useContactPhotoSrc.ts +103 -13
  205. package/lib/crypto/composeSecurity.ts +20 -5
  206. package/lib/crypto/keyExport.ts +2 -2
  207. package/lib/crypto/keyPairMatch.ts +69 -0
  208. package/lib/crypto/keySession.ts +102 -12
  209. package/lib/crypto/keyvaultApi.ts +49 -7
  210. package/lib/crypto/meetingKeys.ts +118 -0
  211. package/lib/crypto/messageSecurity.ts +174 -13
  212. package/lib/crypto/openPgpKey.ts +118 -0
  213. package/lib/crypto/openpgp.ts +115 -0
  214. package/lib/crypto/pgpMessage.ts +336 -0
  215. package/lib/crypto/sharedMailboxKeys.ts +106 -28
  216. package/lib/crypto/smimeMessage.ts +11 -7
  217. package/lib/crypto/zeroAccessKey.ts +5 -0
  218. package/lib/mail/mailApi.ts +27 -0
  219. package/lib/mail/mailboxImportApi.ts +24 -0
  220. package/lib/search/zeroAccessIndexApi.ts +4 -0
  221. package/lib/tasks/tasksApi.ts +19 -5
  222. package/lib/util/clientPlatform.ts +50 -0
  223. package/lib/videoconf/protectedMeetings.ts +221 -0
  224. package/lib/videoconf/videoMeetingsApi.ts +15 -4
  225. package/lib/zeroAccess/sealedMoves.ts +79 -0
  226. package/lib/zeroAccess/sealedRecords.ts +153 -0
  227. package/package.json +12 -3
@@ -2,10 +2,31 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { useEffect, useState } from "react";
5
+ import { useEffect, useRef, useState } from "react";
6
6
  import type { ApiClient } from "../util/api.js";
7
+ import { getUnlockedKeys, subscribeKeySession } from "../crypto/keySession.js";
8
+ import { openSealedBytes } from "../zeroAccess/sealedRecords.js";
7
9
  import { type Contact, contactPhotoUrl, fetchContactPhoto } from "./contactsApi.js";
8
10
 
11
+ /** What a photo is fetched by: the contact's identity, and whether (and for which mailbox) it is sealed. */
12
+ type PhotoContact = Pick<Contact, "uid" | "version" | "photoBlobKey"> & Partial<Pick<Contact, "mailboxUid" | "photoSealed">>;
13
+
14
+ /** The cache identity of the global (cookie) client, for a sealed photo fetched without an explicit client. */
15
+ const GLOBAL_CLIENT = {} as ApiClient;
16
+
17
+ /** A contact's photo: the bytes as stored, or - sealed to the mailbox's zero-access key - opened (rejects when it can't be: initials show). */
18
+ async function loadPhoto(contact: PhotoContact, client: ApiClient | undefined): Promise<Blob> {
19
+ const blob = await fetchContactPhoto(contact.uid, contact.version, client);
20
+ if (!contact.photoSealed) {
21
+ return blob;
22
+ }
23
+ const opened = await openSealedBytes(contact.mailboxUid ?? "", new Uint8Array(await blob.arrayBuffer()));
24
+ if (!opened) {
25
+ throw new Error("The contact's picture is protected and can't be opened here.");
26
+ }
27
+ return new Blob([opened as BlobPart]);
28
+ }
29
+
9
30
  /** How many pictures stay loaded (as `blob:` URLs) after nothing shows them any more, so a list scrolled back over does not fetch them all again. */
10
31
  const CACHE_LIMIT = 50;
11
32
 
@@ -13,6 +34,8 @@ interface Entry {
13
34
  url: string;
14
35
  /** How many mounted avatars show this URL; it is never revoked while that is more than none. */
15
36
  refs: number;
37
+ /** A sealed picture's mailbox, whose keys opened it: dropped when that mailbox locks, shown or not, and never handed out while it is locked. */
38
+ mailboxUid?: string;
16
39
  }
17
40
 
18
41
  /** Least recently used first (a `Map` iterates in insertion order, and a use moves the entry to the end). */
@@ -22,6 +45,32 @@ const pending = new Map<string, { request: Promise<string>; waiters: number }>()
22
45
  const clientIds = new WeakMap<ApiClient, number>();
23
46
  let nextClientId = 0;
24
47
 
48
+ let watchingKeys = false;
49
+
50
+ /** A sealed picture opened with a mailbox's keys goes when that mailbox locks, like every other opened value (`sealedRecords.ts`): its URL
51
+ * is revoked even while an avatar shows it - each one drops it itself (`useContactPhoto()`). Subscribed with the first sealed picture. */
52
+ function forgetOnLock(): void {
53
+ if (watchingKeys) {
54
+ return;
55
+ }
56
+ watchingKeys = true;
57
+ subscribeKeySession(({ mailboxUid, state }) => {
58
+ if (state === "locked") {
59
+ forgetSealed(mailboxUid);
60
+ }
61
+ });
62
+ }
63
+
64
+ /** Revokes and forgets `mailboxUid`'s sealed pictures. */
65
+ function forgetSealed(mailboxUid: string): void {
66
+ for (const [key, entry] of entries) {
67
+ if (entry.mailboxUid === mailboxUid) {
68
+ URL.revokeObjectURL(entry.url);
69
+ entries.delete(key);
70
+ }
71
+ }
72
+ }
73
+
25
74
  /** One key per picture of one account: the same uid never collides across the clients of a multi-account app, and a replaced picture (new version) is a new key. */
26
75
  function keyOf(client: ApiClient, uid: string, version: number): string {
27
76
  let id = clientIds.get(client);
@@ -75,8 +124,20 @@ function limited<T>(task: () => Promise<T>): Promise<T> {
75
124
  });
76
125
  }
77
126
 
78
- /** The `blob:` URL for `key`, fetched with `load` unless it is cached, and counted as shown until `release()`. Concurrent callers share one fetch. */
79
- async function acquire(key: string, load: () => Promise<Blob>): Promise<string> {
127
+ /**
128
+ * The `blob:` URL for `key`, fetched with `load` unless it is cached, and counted as shown until `release()`. Concurrent callers share one fetch.
129
+ * `sealedMailboxUid` is a sealed picture's mailbox: opened only while it is unlocked, it is neither fetched, handed out from the cache nor
130
+ * stored once that mailbox is locked (rejects).
131
+ */
132
+ async function acquire(key: string, load: () => Promise<Blob>, sealedMailboxUid?: string): Promise<string> {
133
+ if (sealedMailboxUid !== undefined) {
134
+ forgetOnLock();
135
+ if (!getUnlockedKeys(sealedMailboxUid)) {
136
+ // Not fetched either: it couldn't be opened, and the avatar retries it once the mailbox unlocks (`useContactPhoto()`).
137
+ forgetSealed(sealedMailboxUid);
138
+ throw new Error("The contact's mailbox is locked.");
139
+ }
140
+ }
80
141
  const entry = entries.get(key);
81
142
  if (entry) {
82
143
  entry.refs++;
@@ -90,9 +151,13 @@ async function acquire(key: string, load: () => Promise<Blob>): Promise<string>
90
151
  waiters: 0,
91
152
  request: limited(load)
92
153
  .then((blob) => {
154
+ // Opened just before a lock came: nothing opened is kept past it.
155
+ if (sealedMailboxUid !== undefined && !getUnlockedKeys(sealedMailboxUid)) {
156
+ throw new Error("The contact's mailbox locked while its picture was opened.");
157
+ }
93
158
  const url = URL.createObjectURL(blob);
94
159
  // Claimed by everyone waiting from the start: an entry nobody holds yet could be trimmed before its avatar got to it.
95
- entries.set(key, { url, refs: created.waiters });
160
+ entries.set(key, { url, refs: created.waiters, mailboxUid: sealedMailboxUid });
96
161
  return url;
97
162
  })
98
163
  .finally(() => pending.delete(key)),
@@ -126,7 +191,7 @@ export function clearContactPhotoCache(): void {
126
191
  * token-authenticated client (the desktop and mobile apps) cannot do that - an `<img>` sends no `Authorization` header - so the picture is fetched through the client
127
192
  * and shown as a `blob:` URL, cached per client, contact and version with a small LRU and revoked once it falls out of it and nothing shows it.
128
193
  */
129
- export function useContactPhotoSrc(contact: Pick<Contact, "uid" | "version" | "photoBlobKey"> | undefined, client?: ApiClient): string | undefined {
194
+ export function useContactPhotoSrc(contact: PhotoContact | undefined, client?: ApiClient): string | undefined {
130
195
  return useContactPhoto(contact, client).src;
131
196
  }
132
197
 
@@ -135,23 +200,48 @@ export function useContactPhotoSrc(contact: Pick<Contact, "uid" | "version" | "p
135
200
  * that is not on screen yet has no reason to fetch its picture.
136
201
  */
137
202
  export function useContactPhoto(
138
- contact: Pick<Contact, "uid" | "version" | "photoBlobKey"> | undefined,
203
+ contact: PhotoContact | undefined,
139
204
  client?: ApiClient,
140
205
  active = true,
141
206
  ): { src: string | undefined; failed: boolean } {
142
207
  const [loaded, setLoaded] = useState<{ key: string; url: string } | undefined>();
143
208
  const [failedKey, setFailedKey] = useState<string | undefined>();
144
- const key = active && client && contact?.photoBlobKey ? keyOf(client, contact.uid, contact.version) : undefined;
145
- const uid = contact?.uid;
146
- const version = contact?.version;
209
+ // Bumped to load a sealed picture again: its mailbox locked (it can't be shown any more) or unlocked (one that failed can be now).
210
+ const [attempt, setAttempt] = useState(0);
211
+ // A sealed photo is always fetched (and opened) here: a URL would show the browser ciphertext.
212
+ const fetched = !!client || !!contact?.photoSealed;
213
+ const key = active && fetched && contact?.photoBlobKey ? keyOf(client ?? GLOBAL_CLIENT, contact.uid, contact.version) : undefined;
214
+ const sealedMailboxUid = contact?.photoSealed ? (contact.mailboxUid ?? "") : undefined;
215
+ const failedRef = useRef(failedKey);
216
+ failedRef.current = failedKey;
217
+
218
+ useEffect(() => {
219
+ if (!key || sealedMailboxUid === undefined) {
220
+ return;
221
+ }
222
+ return subscribeKeySession(({ mailboxUid, state }) => {
223
+ if (mailboxUid !== sealedMailboxUid) {
224
+ return;
225
+ }
226
+ if (state === "locked") {
227
+ // What the <img> decoded stays on screen until its src goes: drop it, then try again (it fails while locked: initials).
228
+ setLoaded(undefined);
229
+ setFailedKey(undefined);
230
+ setAttempt((n) => n + 1);
231
+ } else if (failedRef.current === key) {
232
+ setFailedKey(undefined);
233
+ setAttempt((n) => n + 1);
234
+ }
235
+ });
236
+ }, [key, sealedMailboxUid]);
147
237
 
148
238
  useEffect(() => {
149
- if (!key || !client) {
239
+ if (!key) {
150
240
  return;
151
241
  }
152
242
  let cancelled = false;
153
243
  let held = false;
154
- acquire(key, () => fetchContactPhoto(uid!, version!, client)).then(
244
+ acquire(key, () => loadPhoto(contact!, client), sealedMailboxUid).then(
155
245
  (url) => {
156
246
  if (cancelled) {
157
247
  release(key);
@@ -168,9 +258,9 @@ export function useContactPhoto(
168
258
  release(key);
169
259
  }
170
260
  };
171
- }, [key]);
261
+ }, [key, attempt]);
172
262
 
173
- if (client) {
263
+ if (fetched) {
174
264
  return { src: key !== undefined && loaded?.key === key ? loaded.url : undefined, failed: key !== undefined && failedKey === key };
175
265
  }
176
266
  return { src: contact && contactPhotoUrl(contact), failed: false };
@@ -47,9 +47,15 @@ function policyStateForTier(policy: EncryptionPolicy, tier: RecipientTier): Poli
47
47
  export interface RecipientEncryptionStatus {
48
48
  address: string;
49
49
  tier: RecipientTier;
50
- /** The recipient's currently-active encryption certificate, if they have published (and this device
50
+ /** The recipient's currently-active S/MIME encryption certificate, if they have published (and this device
51
51
  * has discovered) one. */
52
52
  encryptCert?: PublicKey;
53
+ /** The recipient's active OpenPGP certificate - only when they have no S/MIME certificate and the server allows OpenPGP
54
+ * (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*: S/MIME is preferred whenever it can be used). */
55
+ openPgpKey?: PublicKey;
56
+ /** Which format this recipient is encrypted in: `"smime"` whenever they have an S/MIME certificate, `"openpgp"` only for
57
+ * a recipient whose only key is an OpenPGP one. Absent when they can't be encrypted to. */
58
+ format?: "smime" | "openpgp";
53
59
  /** Whether this recipient *can* be encrypted to at all right now - false when there's no usable key,
54
60
  * or the tier's policy is `prohibited`. */
55
61
  canEncrypt: boolean;
@@ -64,15 +70,24 @@ export interface RecipientEncryptionStatus {
64
70
 
65
71
  /** Per-recipient encryption eligibility, given this device's own encryption preference and the
66
72
  * system-wide policy. One entry per (address, lookup result) pair the caller already resolved via
67
- * `lookupKeys()` - this function does no discovery itself. */
73
+ * `lookupKeys()` - this function does no discovery itself. `ownCanUseOpenPgp` false: the sender has no OpenPGP key to use here
74
+ * (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
75
+ * preference never leaves them unencrypted (restapi `specs/end-to-end_encryption.md` *Choosing the format*). */
68
76
  export function resolveRecipientEncryption(
69
77
  ownAddress: string,
70
78
  ownPrefersMutual: boolean,
71
79
  policy: EncryptionPolicy,
72
80
  recipientAddress: string,
73
81
  lookup: KeyLookupResult | undefined,
82
+ ownCanUseOpenPgp: boolean = true,
74
83
  ): RecipientEncryptionStatus {
75
- const encryptCert = lookup ? findActivePublicKey(lookup.keys, "encrypt") : undefined;
84
+ const smimeCert = lookup ? findActivePublicKey(lookup.keys, "encrypt") : undefined;
85
+ const usableOpenPgpKey = lookup && policy.allowOpenPgp === true ? findActivePublicKey(lookup.keys, "encrypt", "openpgp") : undefined;
86
+ // S/MIME, unless the recipient has no certificate or prefers OpenPGP (restapi *Choosing the format*: the user's choice for the
87
+ // contact, what the address advertises, what it was last seen using) - and OpenPGP only where the server allows it.
88
+ const useOpenPgp = !!usableOpenPgpKey && (!smimeCert || (lookup?.preferredFormat === "openpgp" && ownCanUseOpenPgp));
89
+ const encryptCert = useOpenPgp ? undefined : smimeCert;
90
+ const openPgpKey = useOpenPgp ? usableOpenPgpKey : undefined;
76
91
  const tier = classifyRecipientTier(ownAddress, recipientAddress, !!lookup && lookup.keys.length > 0);
77
92
  const policyState = policyStateForTier(policy, tier);
78
93
 
@@ -87,7 +102,7 @@ export function resolveRecipientEncryption(
87
102
  };
88
103
  }
89
104
 
90
- if (!encryptCert) {
105
+ if (!encryptCert && !openPgpKey) {
91
106
  return { address: recipientAddress, tier, canEncrypt: false, autoEncrypt: false };
92
107
  }
93
108
 
@@ -96,7 +111,7 @@ export function resolveRecipientEncryption(
96
111
  return {
97
112
  address: recipientAddress,
98
113
  tier,
99
- encryptCert,
114
+ ...(encryptCert ? { encryptCert, format: "smime" as const } : { openPgpKey, format: "openpgp" as const }),
100
115
  canEncrypt: true,
101
116
  autoEncrypt: policyState === "automatic" && mutual,
102
117
  };
@@ -15,7 +15,7 @@ import * as asn1js from "asn1js";
15
15
  import * as pkijs from "pkijs";
16
16
  import type { ApiClient } from "../util/api.js";
17
17
  import { fromBase64 } from "./encoding.js";
18
- import { type KeyVault, type PublicKey, findActivePublicKey, recordKeyExport, type WrappedPrivateKey } from "./keyvaultApi.js";
18
+ import { type KeyVault, type PublicKey, findActivePublicKey, isOpenPgpKey, recordKeyExport, type WrappedPrivateKey } from "./keyvaultApi.js";
19
19
  import {
20
20
  ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE,
21
21
  MAX_RETAINED_ENCRYPTION_KEYS,
@@ -147,7 +147,7 @@ async function openExportedKeys(input: KeyExportInput, vault: KeyVault, masterKe
147
147
  const active = findActivePublicKey(input.mailboxKeys, "encrypt");
148
148
  const encryption = [
149
149
  ...(active ? [active] : []),
150
- ...input.mailboxKeys.filter((k) => k.useType === "encrypt" && k !== active).sort((a, b) => b.notBefore - a.notBefore),
150
+ ...input.mailboxKeys.filter((k) => k.useType === "encrypt" && !isOpenPgpKey(k) && k !== active).sort((a, b) => b.notBefore - a.notBefore),
151
151
  ];
152
152
  await take("encrypt", encryption, ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, MAX_RETAINED_ENCRYPTION_KEYS + 1);
153
153
 
@@ -0,0 +1,69 @@
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
+
12
+ /** One DER element of `der` at `offset`: where its header starts, where its content starts, where it ends. */
13
+ function element(der: Uint8Array, offset: number): { tag: number; header: number; content: number; end: number } {
14
+ const tag = der[offset];
15
+ let length = der[offset + 1];
16
+ let content = offset + 2;
17
+ if (length & 0x80) {
18
+ const bytes = length & 0x7f;
19
+ if (bytes === 0 || bytes > 4) {
20
+ throw new Error("Unsupported DER length.");
21
+ }
22
+ length = 0;
23
+ for (let i = 0; i < bytes; i++) {
24
+ length = length * 256 + der[content + i];
25
+ }
26
+ content += bytes;
27
+ }
28
+ const end = content + length;
29
+ if (tag === undefined || end > der.length) {
30
+ throw new Error("Truncated DER.");
31
+ }
32
+ return { tag, header: offset, content, end };
33
+ }
34
+
35
+ /** The children of the constructed element `parent`. */
36
+ function children(der: Uint8Array, parent: { content: number; end: number }): ReturnType<typeof element>[] {
37
+ const found: ReturnType<typeof element>[] = [];
38
+ for (let offset = parent.content; offset < parent.end; offset = found[found.length - 1].end) {
39
+ found.push(element(der, offset));
40
+ }
41
+ return found;
42
+ }
43
+
44
+ /** The SubjectPublicKeyInfo of X.509 certificate `certDer` (RFC 5280: the seventh field of `tbsCertificate`, the sixth when it has no
45
+ * explicit version). */
46
+ function subjectPublicKeyInfo(certDer: Uint8Array): Uint8Array {
47
+ const [tbs] = children(certDer, element(certDer, 0));
48
+ const fields = children(certDer, tbs);
49
+ const spki = fields[fields[0].tag === 0xa0 ? 6 : 5];
50
+ if (!spki || spki.tag !== 0x30) {
51
+ throw new Error("No subject public key.");
52
+ }
53
+ return certDer.subarray(spki.header, spki.end);
54
+ }
55
+
56
+ const P256_ECDH: EcKeyImportParams = { name: "ECDH", namedCurve: "P-256" };
57
+
58
+ /** Whether `certDer` (a P-256 certificate) carries the public half of `pkcs8` (a P-256 private key). Never rejects: anything that won't
59
+ * parse or import is `false`. */
60
+ export async function certificateHoldsKey(certDer: Uint8Array, pkcs8: Uint8Array): Promise<boolean> {
61
+ try {
62
+ const publicKey = await crypto.subtle.importKey("spki", subjectPublicKeyInfo(certDer) as BufferSource, P256_ECDH, true, []);
63
+ const privateKey = await crypto.subtle.importKey("pkcs8", pkcs8 as BufferSource, P256_ECDH, true, ["deriveBits"]);
64
+ const [certified, held] = await Promise.all([crypto.subtle.exportKey("jwk", publicKey), crypto.subtle.exportKey("jwk", privateKey)]);
65
+ return !!certified.x && certified.x === held.x && certified.y === held.y;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
@@ -21,13 +21,15 @@
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 { type KeyVault, type PublicKey, type WrappedPrivateKey, findActivePublicKey, getKeyVault } from "./keyvaultApi.js";
24
+ import { type KeyVault, type PublicKey, type WrappedPrivateKey, findActivePublicKey, getKeyVault, isOpenPgpKey } from "./keyvaultApi.js";
25
25
  import type { ApiClient } from "../util/api.js";
26
26
  import { fromBase64 } from "./encoding.js";
27
+ import { certificateHoldsKey } from "./keyPairMatch.js";
27
28
  import { importPrivateKeyPkcs8 } from "./keys.js";
28
29
  import { KeysLockedError, buildAad, openWithKey } from "./masterKey.js";
29
30
  import { deriveFromPassword, parseArgon2idKdfLabel } from "./passwordUnlock.js";
30
31
  import { RECOVERY_KDF_LABEL, deriveFromRecoveryCode } from "./recoveryCode.js";
32
+ import type { OpenPgpPrivateKey } from "./openpgp.js";
31
33
 
32
34
  /** AAD purpose labels — MUST exactly match what `KeyEnrollmentGate.tsx` used when it originally
33
35
  * sealed each of these values, or `openWithKey()` fails (GCM authenticates the AAD, not just the
@@ -36,6 +38,7 @@ export const MASTER_KEY_AAD_PURPOSE = "master-key";
36
38
  export const SIGNING_PRIVATE_KEY_AAD_PURPOSE = "sign-private-key";
37
39
  export const ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE = "encrypt-private-key";
38
40
  export const ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE = "zero-access-private-key";
41
+ export const OPENPGP_PRIVATE_KEY_AAD_PURPOSE = "openpgp-private-key";
39
42
 
40
43
  export { KeysLockedError };
41
44
 
@@ -79,6 +82,10 @@ export interface UnlockedKeys {
79
82
  * index entries and sealed summaries to - the active one first, then retained ones newest first, at most
80
83
  * `MAX_RETAINED_ENCRYPTION_KEYS + 1`. Decryption only. Absent when the mailbox has none (or none opened). */
81
84
  zeroAccessKeys?: RetainedEncryptionKey[];
85
+ /** The mailbox's OpenPGP secret keys (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*) - the active one
86
+ * first, then retained ones newest first, at most `MAX_RETAINED_ENCRYPTION_KEYS + 1`: what opens mail OpenPGP users encrypt to
87
+ * the mailbox, and signs what it sends them. Absent when the mailbox has none (or none opened). */
88
+ openPgpKeys?: { fingerprint: string; privateKey: OpenPgpPrivateKey }[];
82
89
  /** A shared mailbox opened through this user's member key wraps (`sharedMailboxKeys.ts`), not with its own master key: it holds
83
90
  * only `zeroAccessKeys`, and `masterKey` is a per-key secret derived from its zero-access private key (what the local search
84
91
  * index's key is derived from), never a key that opens the mailbox's vault. */
@@ -172,6 +179,17 @@ export function addZeroAccessKey(mailboxUid: string, unlocked: UnlockedKeys, key
172
179
  unlocked.zeroAccessKeys = [key, ...(unlocked.zeroAccessKeys ?? []).filter((k) => k.fingerprint !== key.fingerprint)];
173
180
  }
174
181
 
182
+ /**
183
+ * Makes an OpenPGP key this device has just enrolled (`openPgpKey.ts`'s `ensureOpenPgpKey()`) the active one of `unlocked`, so
184
+ * OpenPGP mail to it reads without another unlock - the same rules as `addZeroAccessKey()`.
185
+ */
186
+ export function addOpenPgpKey(mailboxUid: string, unlocked: UnlockedKeys, key: { fingerprint: string; privateKey: OpenPgpPrivateKey }): void {
187
+ if (unlocked.destroyed || sessions.get(mailboxUid) !== unlocked) {
188
+ return;
189
+ }
190
+ unlocked.openPgpKeys = [key, ...(unlocked.openPgpKeys ?? []).filter((k) => k.fingerprint !== key.fingerprint)];
191
+ }
192
+
175
193
  /**
176
194
  * Stores the keys of a shared mailbox opened through member key wraps (`sharedMailboxKeys.ts`'s `unlockSharedMailbox()`) as that mailbox's
177
195
  * session, exactly as an unlock would - tracked for `destroyUnlockedKeys()`, and notified - unless the mailbox was locked since `generation`
@@ -272,6 +290,15 @@ function destroyObject(unlocked: UnlockedKeys): void {
272
290
  unlocked.zeroAccessKeys.length = 0;
273
291
  delete unlocked.zeroAccessKeys;
274
292
  }
293
+ if (unlocked.openPgpKeys) {
294
+ // OpenPGP.js keeps a decrypted secret key's material in its own objects: wipe it there too.
295
+ for (const { privateKey } of unlocked.openPgpKeys) {
296
+ // Present at runtime on every `PrivateKey`, missing from OpenPGP.js's type declarations.
297
+ (privateKey as unknown as { clearPrivateParams(): void }).clearPrivateParams();
298
+ }
299
+ unlocked.openPgpKeys.length = 0;
300
+ delete unlocked.openPgpKeys;
301
+ }
275
302
  }
276
303
 
277
304
  /** Finds the wrapped private key whose fingerprint matches a given published public key. */
@@ -309,7 +336,9 @@ export class UnopenableEncryptionKeyError extends Error {
309
336
  }
310
337
 
311
338
  /** Opens one wrapped PKCS#8 private key under `masterKey` and imports it (non-extractable by default, like
312
- * `importPrivateKeyPkcs8()` itself), zeroing the transient plaintext whether or not the import succeeds. */
339
+ * `importPrivateKeyPkcs8()` itself), zeroing the transient plaintext whether or not the import succeeds. With `certDer` - the server's
340
+ * certificate for it, unauthenticated - it rejects a key that certificate isn't the one of (`certificateHoldsKey()`), so a swapped
341
+ * certificate never pairs with the opened key. */
313
342
  async function openPrivateKey(
314
343
  masterKey: Uint8Array,
315
344
  wrapped: WrappedPrivateKey,
@@ -317,9 +346,13 @@ async function openPrivateKey(
317
346
  algorithm: EcKeyImportParams,
318
347
  usages: KeyUsage[],
319
348
  extractable = false,
349
+ certDer?: Uint8Array,
320
350
  ): Promise<CryptoKey> {
321
351
  const raw = await openWithKey(masterKey, wrapped, aad);
322
352
  try {
353
+ if (certDer && !(await certificateHoldsKey(certDer, raw))) {
354
+ throw new Error("The certificate isn't the key's.");
355
+ }
323
356
  return await importPrivateKeyPkcs8(raw, algorithm, usages, extractable);
324
357
  } finally {
325
358
  raw.fill(0);
@@ -330,7 +363,7 @@ async function openPrivateKey(
330
363
  * Opens the retained encryption keys onto `unlocked.retainedEncryptionKeys`: every published `encrypt` key in
331
364
  * `mailboxKeys` other than the active one (`activeFingerprint`), whatever its revocation or expiry, that has a wrapped
332
365
  * private key in the vault - de-duplicated by fingerprint, newest `notBefore` first, at most
333
- * `MAX_RETAINED_ENCRYPTION_KEYS`. A key that won't open or import, or whose certificate won't decode, is skipped and
366
+ * `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
334
367
  * added to `unopenableKeys`. Retained keys are imported non-extractable. Each key is attached as it opens, so a failed
335
368
  * unlock's `destroyObject()` drops those too.
336
369
  */
@@ -345,7 +378,8 @@ async function openRetainedEncryptionKeys(
345
378
  const seen = new Set<string>(activeFingerprint !== undefined ? [activeFingerprint] : []);
346
379
  const candidates: { publicKey: PublicKey; wrapped: WrappedPrivateKey }[] = [];
347
380
  for (const publicKey of [...mailboxKeys].sort((a, b) => b.notBefore - a.notBefore)) {
348
- const wrapped = publicKey.useType === "encrypt" && !seen.has(publicKey.fingerprint) ? findWrappedPrivateKey(vault, publicKey.fingerprint) : undefined;
381
+ const wrapped =
382
+ publicKey.useType === "encrypt" && !isOpenPgpKey(publicKey) && !seen.has(publicKey.fingerprint) ? findWrappedPrivateKey(vault, publicKey.fingerprint) : undefined;
349
383
  if (!wrapped) {
350
384
  continue;
351
385
  }
@@ -360,7 +394,7 @@ async function openRetainedEncryptionKeys(
360
394
  for (const { publicKey, wrapped } of candidates) {
361
395
  try {
362
396
  const certDer = fromBase64(publicKey.publicKey);
363
- const privateKey = await openPrivateKey(unlocked.masterKey, wrapped, aad, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
397
+ const privateKey = await openPrivateKey(unlocked.masterKey, wrapped, aad, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false, certDer);
364
398
  (unlocked.retainedEncryptionKeys ??= []).push({ fingerprint: publicKey.fingerprint, certDer, privateKey });
365
399
  } catch {
366
400
  unopenableKeys.push(publicKey.fingerprint);
@@ -372,7 +406,9 @@ async function openRetainedEncryptionKeys(
372
406
  * Opens the mailbox's zero-access private keys into `unlocked.zeroAccessKeys`: the vault's `zeroAccessKeys` that have a
373
407
  * wrapped private key, the unrevoked one with the latest `notBefore` first, then the rest newest first, at most
374
408
  * `MAX_RETAINED_ENCRYPTION_KEYS + 1`. A key that won't open is listed in `unopenableKeys` and skipped - zero-access mail
375
- * encrypted to it doesn't decrypt on this device, but nothing else about the unlock depends on it.
409
+ * encrypted to it doesn't decrypt on this device, but nothing else about the unlock depends on it. So is one whose certificate (the
410
+ * server's, unauthenticated) isn't the private key's: this device seals to the active key's certificate - search index segments of
411
+ * end-to-end mail, protected meetings' keys - so a certificate swapped by the server must never become it.
376
412
  */
377
413
  async function openZeroAccessKeys(mailboxUid: string, vault: KeyVault, unlocked: UnlockedKeys, unopenableKeys: string[]): Promise<void> {
378
414
  const ordered = [...(vault.zeroAccessKeys ?? [])].sort(
@@ -386,8 +422,17 @@ async function openZeroAccessKeys(mailboxUid: string, vault: KeyVault, unlocked:
386
422
  continue;
387
423
  }
388
424
  try {
389
- const privateKey = await openPrivateKey(unlocked.masterKey, wrapped, aad, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
390
- (unlocked.zeroAccessKeys ??= []).push({ fingerprint: publicKey.fingerprint, certDer: fromBase64(publicKey.publicKey), privateKey });
425
+ const certDer = fromBase64(publicKey.publicKey);
426
+ const raw = await openWithKey(unlocked.masterKey, wrapped, aad);
427
+ try {
428
+ if (!(await certificateHoldsKey(certDer, raw))) {
429
+ throw new Error("The zero-access certificate isn't the key's.");
430
+ }
431
+ const privateKey = await importPrivateKeyPkcs8(raw, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
432
+ (unlocked.zeroAccessKeys ??= []).push({ fingerprint: publicKey.fingerprint, certDer, privateKey });
433
+ } finally {
434
+ raw.fill(0);
435
+ }
391
436
  } catch {
392
437
  unopenableKeys.push(publicKey.fingerprint);
393
438
  }
@@ -397,6 +442,38 @@ async function openZeroAccessKeys(mailboxUid: string, vault: KeyVault, unlocked:
397
442
  }
398
443
  }
399
444
 
445
+ /**
446
+ * Opens the mailbox's OpenPGP secret keys into `unlocked.openPgpKeys`: its published OpenPGP certificates (`type: "openpgp"`) that
447
+ * have a wrapped secret key, the active one first (unrevoked, unexpired, latest `notBefore`), then the rest newest first, at most
448
+ * `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
449
+ * `unopenableKeys` and skipped.
450
+ */
451
+ async function openOpenPgpKeys(mailboxUid: string, mailboxKeys: PublicKey[], vault: KeyVault, unlocked: UnlockedKeys, unopenableKeys: string[]): Promise<void> {
452
+ const now = Date.now();
453
+ const active = (key: PublicKey): boolean => !key.revokedAt && key.notAfter > now;
454
+ const ordered = mailboxKeys
455
+ .filter((key) => isOpenPgpKey(key) && findWrappedPrivateKey(vault, key.fingerprint))
456
+ .sort((a, b) => Number(active(b)) - Number(active(a)) || b.notBefore - a.notBefore)
457
+ .slice(0, MAX_RETAINED_ENCRYPTION_KEYS + 1);
458
+ if (ordered.length === 0) {
459
+ return;
460
+ }
461
+ const { readSecretKey } = await import("./openpgp.js");
462
+ const aad = buildAad(mailboxUid, OPENPGP_PRIVATE_KEY_AAD_PURPOSE);
463
+ for (const publicKey of ordered) {
464
+ try {
465
+ const raw = await openWithKey(unlocked.masterKey, findWrappedPrivateKey(vault, publicKey.fingerprint)!, aad);
466
+ try {
467
+ (unlocked.openPgpKeys ??= []).push({ fingerprint: publicKey.fingerprint, privateKey: await readSecretKey(raw) });
468
+ } finally {
469
+ raw.fill(0);
470
+ }
471
+ } catch {
472
+ unopenableKeys.push(publicKey.fingerprint);
473
+ }
474
+ }
475
+ }
476
+
400
477
  /**
401
478
  * Unlocks a mailbox's key vault with its password unlock method: fetches the vault, derives the
402
479
  * wrapping key from `password` using the *exact* KDF parameters that wrap was created with, unwraps
@@ -409,8 +486,8 @@ async function openZeroAccessKeys(mailboxUid: string, vault: KeyVault, unlocked:
409
486
  * "incorrect password," not a generic error, but this module doesn't presume a specific UI's error copy.
410
487
  * Once the master key has opened, the password is known to be right: an active signing key that then
411
488
  * won't open is skipped and listed in `UnlockResult.unopenableKeys` (so is a retained encryption key); an active
412
- * encryption key that won't
413
- * open still fails the unlock, with `UnopenableEncryptionKeyError`. Throws `KeysLockedError` when
489
+ * encryption key that won't open, or whose certificate isn't its own,
490
+ * still fails the unlock, with `UnopenableEncryptionKeyError`. Throws `KeysLockedError` when
414
491
  * `destroyUnlockedKeys()` locked this mailbox (or all mailboxes) while the unlock was in flight.
415
492
  */
416
493
  export async function unlockWithPassword(mailboxUid: string, mailboxKeys: PublicKey[], password: string): Promise<UnlockResult> {
@@ -555,18 +632,31 @@ async function openSession(
555
632
  const encryptionPublicKey = findActivePublicKey(mailboxKeys, "encrypt");
556
633
  const wrappedEncryptionKey = encryptionPublicKey && findWrappedPrivateKey(vault, encryptionPublicKey.fingerprint);
557
634
  if (encryptionPublicKey && wrappedEncryptionKey) {
635
+ let encryptionCertDer: Uint8Array;
558
636
  try {
637
+ // The certificate is the server's: one that isn't this key's - what this device encrypts its own copies to, and what it
638
+ // hands out as its S/MIME certificate - makes the key unopenable, exactly as a wrap that won't open.
639
+ encryptionCertDer = fromBase64(encryptionPublicKey.publicKey);
559
640
  const aad = buildAad(mailboxUid, ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE);
560
- unlocked.encryptionPrivateKey = await openPrivateKey(masterKey, wrappedEncryptionKey, aad, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"]);
641
+ unlocked.encryptionPrivateKey = await openPrivateKey(
642
+ masterKey,
643
+ wrappedEncryptionKey,
644
+ aad,
645
+ { name: "ECDH", namedCurve: "P-256" },
646
+ ["deriveBits"],
647
+ false,
648
+ encryptionCertDer,
649
+ );
561
650
  } catch (err) {
562
651
  throw new UnopenableEncryptionKeyError(encryptionPublicKey.fingerprint, err);
563
652
  }
564
- unlocked.encryptionCertDer = fromBase64(encryptionPublicKey.publicKey);
653
+ unlocked.encryptionCertDer = encryptionCertDer;
565
654
  unlocked.encryptionFingerprint = encryptionPublicKey.fingerprint;
566
655
  }
567
656
 
568
657
  await openRetainedEncryptionKeys(mailboxUid, mailboxKeys, vault, unlocked, encryptionPublicKey?.fingerprint, unopenableKeys);
569
658
  await openZeroAccessKeys(mailboxUid, vault, unlocked, unopenableKeys);
659
+ await openOpenPgpKeys(mailboxUid, mailboxKeys, vault, unlocked, unopenableKeys);
570
660
  if (lockGeneration(mailboxUid) !== generation) {
571
661
  // Locked (e.g. logout) while this unlock was awaiting - never restore keys after that.
572
662
  throw new KeysLockedError();