@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
@@ -0,0 +1,51 @@
1
+ import { type OpenPgpPrivateKey, type OpenPgpPublicKey } from "./openpgp.js";
2
+ import { type MimePart, type ProtectedHeaders } from "./smimeMessage.js";
3
+ /**
4
+ * An RFC 3156 `multipart/encrypted` message: the inner entity (protected headers, the body and - with `attachKey` - our public
5
+ * key) signed with `signingKey` when given and encrypted to every key in `recipientKeys` in one OpenPGP message. Per the spec's
6
+ * "Encrypt to Self", `recipientKeys` MUST include the sender's own key.
7
+ */
8
+ export declare function buildPgpEncryptedMessage(bodyContentType: string, bodyText: string, protectedHeaders: ProtectedHeaders, outerHeaders: ProtectedHeaders, recipientKeys: OpenPgpPublicKey[], signingKey?: OpenPgpPrivateKey, attachKey?: string): Promise<MimePart>;
9
+ /** An RFC 3156 detached `multipart/signed` message: the inner entity (protected headers, the body and - with `attachKey` - our
10
+ * public key), signed with `signingKey`. */
11
+ export declare function buildPgpSignedOnlyMessage(bodyContentType: string, bodyText: string, protectedHeaders: ProtectedHeaders, signingKey: OpenPgpPrivateKey, attachKey?: string): Promise<MimePart>;
12
+ /** A key that may have made a signature: a pinned one is trusted, one only attached to the message is not. */
13
+ export interface PgpSignerCandidate {
14
+ key: OpenPgpPublicKey;
15
+ trusted: boolean;
16
+ }
17
+ /** What a signature proved. `"unknown_key"`: no candidate key has the signing key's ID, so it can't be checked at all. */
18
+ export type PgpSignatureOutcome = {
19
+ status: "verified";
20
+ candidate: PgpSignerCandidate;
21
+ fingerprint: string;
22
+ emails: string[];
23
+ } | {
24
+ status: "invalid";
25
+ } | {
26
+ status: "unknown_key";
27
+ };
28
+ /** A recovered OpenPGP message: the inner entity (a binary string, as `mime.ts` parses it) and what its signature proved. */
29
+ export interface ParsedPgpMessage {
30
+ /** For an encrypted message: whether one of the keys opened it. Always `true` for a signed-only one. */
31
+ decrypted: boolean;
32
+ /** The recovered MIME entity (PGP/MIME), or for an inline message a `text/plain` entity built around its text. */
33
+ entityText?: string;
34
+ /** `undefined` when the message carried no signature. */
35
+ signature?: PgpSignatureOutcome;
36
+ }
37
+ /** Verifies an RFC 3156 `multipart/signed` message (`protocol="application/pgp-signature"`). Never throws. */
38
+ export declare function parsePgpSignedMessage(contentType: string, body: string, candidates: PgpSignerCandidate[]): Promise<ParsedPgpMessage>;
39
+ /**
40
+ * Decrypts an RFC 3156 `multipart/encrypted` message with `decryptionKeys`, verifying the signature it carries (a combined signed
41
+ * and encrypted message, RFC 3156 §6.2, or an inner `multipart/signed`, §6.1) against `candidates` and the keys attached inside.
42
+ * Never throws.
43
+ */
44
+ export declare function parsePgpEncryptedMessage(contentType: string, body: string, decryptionKeys: OpenPgpPrivateKey[], candidates: PgpSignerCandidate[]): Promise<ParsedPgpMessage>;
45
+ /** Decrypts an inline OpenPGP message (an armored `PGP MESSAGE` as the whole text body), verifying a signature it carries. Never
46
+ * throws. */
47
+ export declare function parsePgpInlineEncrypted(armored: string, decryptionKeys: OpenPgpPrivateKey[], candidates: PgpSignerCandidate[]): Promise<ParsedPgpMessage>;
48
+ /** Verifies an inline cleartext-signed message (`BEGIN PGP SIGNED MESSAGE`). Never throws. */
49
+ export declare function parsePgpCleartextSigned(text: string, candidates: PgpSignerCandidate[]): Promise<ParsedPgpMessage>;
50
+ /** The certificates in a list of base64 keys (`PublicKey.publicKey` of OpenPGP entries) - unreadable ones dropped. */
51
+ export declare function readKeysBase64(keys: string[]): Promise<OpenPgpPublicKey[]>;
@@ -0,0 +1,271 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * OpenPGP messages (RFC 3156 PGP/MIME, and the inline forms some clients still send) - the OpenPGP counterpart of
7
+ * `smimeMessage.ts`, for mail with OpenPGP users (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*). Only ever
8
+ * imported dynamically: OpenPGP.js is loaded when an OpenPGP message is read or sent.
9
+ *
10
+ * Sending writes PGP/MIME only: `multipart/encrypted` (signed and encrypted in one OpenPGP message, RFC 3156 §6.2) and
11
+ * detached `multipart/signed`. The inner entity carries the protected headers exactly as an S/MIME message does
12
+ * (`smimeMessage.ts`'s `protectedHeaderLines()`, RFC 9788), marked both `hp=` and `protected-headers="v1"` (the form OpenPGP
13
+ * mail clients recognise), and its body is base64 so the signed bytes survive transport.
14
+ *
15
+ * Reading recovers the inner entity and reports what the signature proved; the trust decision (pins, identity binding,
16
+ * header checks) is `messageSecurity.ts`'s, the same as for S/MIME.
17
+ */
18
+ import * as openpgp from "openpgp";
19
+ import { fromBase64 } from "./encoding.js";
20
+ import { bytesToBinaryString, binaryStringToBytes, decodeBodyText, parseMimeEntity, parseParameterizedHeader, splitMultipart } from "./mime.js";
21
+ import { keyEmails, readPublicKeys } from "./openpgp.js";
22
+ import { base64Wrapped, flattenHeaderValue, generateBoundary, protectedHeaderLines, } from "./smimeMessage.js";
23
+ const CRLF = "\r\n";
24
+ /** Builds the inner entity: protected headers, then the body (base64) - wrapped with `attachKey` (an armored public key) in a
25
+ * `multipart/mixed` when one is given, so a correspondent without our key can import it. */
26
+ function innerEntity(bodyContentType, bodyText, headerBlock, hp, attachKey) {
27
+ const marks = `hp="${hp}"; protected-headers="v1"`;
28
+ const bodyPart = [
29
+ `Content-Type: ${flattenHeaderValue(bodyContentType)}`,
30
+ `Content-Transfer-Encoding: base64`,
31
+ "",
32
+ base64Wrapped(new TextEncoder().encode(bodyText)),
33
+ ].join(CRLF);
34
+ if (!attachKey) {
35
+ return [headerBlock, bodyPart.replace(/^Content-Type: ([^\r\n]*)/, `Content-Type: $1; ${marks}`)].join(CRLF);
36
+ }
37
+ const boundary = generateBoundary();
38
+ return [
39
+ headerBlock,
40
+ `Content-Type: multipart/mixed; boundary="${boundary}"; ${marks}`,
41
+ "",
42
+ `--${boundary}`,
43
+ bodyPart,
44
+ `--${boundary}`,
45
+ `Content-Type: application/pgp-keys; name="OpenPGP_public_key.asc"`,
46
+ `Content-Disposition: attachment; filename="OpenPGP_public_key.asc"`,
47
+ "",
48
+ attachKey.replace(/\r?\n/g, CRLF).trim(),
49
+ `--${boundary}--`,
50
+ ].join(CRLF);
51
+ }
52
+ /**
53
+ * An RFC 3156 `multipart/encrypted` message: the inner entity (protected headers, the body and - with `attachKey` - our public
54
+ * key) signed with `signingKey` when given and encrypted to every key in `recipientKeys` in one OpenPGP message. Per the spec's
55
+ * "Encrypt to Self", `recipientKeys` MUST include the sender's own key.
56
+ */
57
+ export async function buildPgpEncryptedMessage(bodyContentType, bodyText, protectedHeaders, outerHeaders, recipientKeys, signingKey, attachKey) {
58
+ const entity = innerEntity(bodyContentType, bodyText, protectedHeaderLines(protectedHeaders, outerHeaders), "cipher", attachKey);
59
+ const armored = await openpgp.encrypt({
60
+ message: await openpgp.createMessage({ binary: new TextEncoder().encode(entity) }),
61
+ encryptionKeys: recipientKeys,
62
+ ...(signingKey ? { signingKeys: signingKey } : {}),
63
+ format: "armored",
64
+ });
65
+ const boundary = generateBoundary();
66
+ const body = [
67
+ `--${boundary}`,
68
+ `Content-Type: application/pgp-encrypted`,
69
+ `Content-Description: PGP/MIME version identification`,
70
+ "",
71
+ "Version: 1",
72
+ "",
73
+ `--${boundary}`,
74
+ `Content-Type: application/octet-stream; name="encrypted.asc"`,
75
+ `Content-Description: OpenPGP encrypted message`,
76
+ `Content-Disposition: inline; filename="encrypted.asc"`,
77
+ "",
78
+ armored.replace(/\r?\n/g, CRLF).trim(),
79
+ `--${boundary}--`,
80
+ ].join(CRLF);
81
+ return { contentType: `multipart/encrypted; protocol="application/pgp-encrypted"; boundary="${boundary}"`, body };
82
+ }
83
+ /** The `micalg` of a detached signature: `pgp-` and its hash algorithm's name (RFC 3156 §5). */
84
+ async function micalg(armoredSignature) {
85
+ const signature = await openpgp.readSignature({ armoredSignature });
86
+ const id = signature.packets[0]?.hashAlgorithm;
87
+ const name = id !== undefined && id !== null ? openpgp.enums.read(openpgp.enums.hash, id) : "sha512";
88
+ return `pgp-${String(name).toLowerCase()}`;
89
+ }
90
+ /** An RFC 3156 detached `multipart/signed` message: the inner entity (protected headers, the body and - with `attachKey` - our
91
+ * public key), signed with `signingKey`. */
92
+ export async function buildPgpSignedOnlyMessage(bodyContentType, bodyText, protectedHeaders, signingKey, attachKey) {
93
+ const entity = innerEntity(bodyContentType, bodyText, protectedHeaderLines(protectedHeaders), "clear", attachKey);
94
+ const signature = await openpgp.sign({
95
+ message: await openpgp.createMessage({ binary: new TextEncoder().encode(entity) }),
96
+ signingKeys: signingKey,
97
+ detached: true,
98
+ format: "armored",
99
+ });
100
+ const boundary = generateBoundary();
101
+ const body = [
102
+ `--${boundary}`,
103
+ entity,
104
+ `--${boundary}`,
105
+ `Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"`,
106
+ `Content-Description: OpenPGP digital signature`,
107
+ `Content-Disposition: attachment; filename="OpenPGP_signature.asc"`,
108
+ "",
109
+ signature.replace(/\r?\n/g, CRLF).trim(),
110
+ `--${boundary}--`,
111
+ ].join(CRLF);
112
+ return {
113
+ contentType: `multipart/signed; protocol="application/pgp-signature"; micalg=${await micalg(signature)}; boundary="${boundary}"`,
114
+ body,
115
+ };
116
+ }
117
+ async function signatureOutcome(signatures, candidates) {
118
+ let known = false;
119
+ for (const signature of signatures) {
120
+ const candidate = candidates.find((c) => c.key.getKeyIDs().some((id) => id.equals(signature.keyID)));
121
+ if (!candidate) {
122
+ continue;
123
+ }
124
+ known = true;
125
+ const valid = await signature.verified.then(() => true, () => false);
126
+ if (valid) {
127
+ return { status: "verified", candidate, fingerprint: candidate.key.getFingerprint().toLowerCase(), emails: await keyEmails(candidate.key) };
128
+ }
129
+ }
130
+ return known ? { status: "invalid" } : { status: "unknown_key" };
131
+ }
132
+ /** The certificates attached to `entityText` (`application/pgp-keys` parts) - untrusted signer candidates. */
133
+ async function attachedKeys(entityText) {
134
+ const keys = [];
135
+ const visit = async (text, depth) => {
136
+ if (depth > 4) {
137
+ return;
138
+ }
139
+ const entity = parseMimeEntity(text);
140
+ const type = parseParameterizedHeader(entity.headers["content-type"]);
141
+ if (type.value === "application/pgp-keys") {
142
+ keys.push(...(await readPublicKeys(decodeBodyText(entity))));
143
+ }
144
+ else if (type.value.startsWith("multipart/") && type.params["boundary"]) {
145
+ for (const part of splitMultipart(entity.body, type.params["boundary"]).slice(0, 16)) {
146
+ await visit(part, depth + 1);
147
+ }
148
+ }
149
+ };
150
+ await visit(entityText, 0);
151
+ return keys;
152
+ }
153
+ /** Candidates for `entityText`: the given ones, then any key attached to the message (untrusted). */
154
+ async function withAttached(candidates, entityText) {
155
+ return [...candidates, ...(await attachedKeys(entityText)).map((key) => ({ key, trusted: false }))];
156
+ }
157
+ /** Verifies an RFC 3156 `multipart/signed` message (`protocol="application/pgp-signature"`). Never throws. */
158
+ export async function parsePgpSignedMessage(contentType, body, candidates) {
159
+ const boundary = parseParameterizedHeader(contentType).params["boundary"];
160
+ const parts = boundary ? splitMultipart(body, boundary) : [];
161
+ if (parts.length !== 2) {
162
+ return { decrypted: true, signature: { status: "invalid" } };
163
+ }
164
+ const [signed, signaturePart] = parts;
165
+ try {
166
+ const signature = await openpgp.readSignature({ armoredSignature: decodeBodyText(parseMimeEntity(signaturePart)) });
167
+ const all = await withAttached(candidates, signed);
168
+ // What was signed is the part in canonical CRLF form (RFC 3156 §5).
169
+ const canonical = signed.replace(/\r?\n/g, CRLF);
170
+ const verification = await openpgp.verify({
171
+ message: await openpgp.createMessage({ binary: binaryStringToBytes(canonical) }),
172
+ signature,
173
+ verificationKeys: all.map((c) => c.key),
174
+ expectSigned: false,
175
+ });
176
+ return { decrypted: true, entityText: signed, signature: await signatureOutcome(verification.signatures, all) };
177
+ }
178
+ catch {
179
+ return { decrypted: true, signature: { status: "invalid" } };
180
+ }
181
+ }
182
+ /** The armored OpenPGP message in an RFC 3156 `multipart/encrypted` body (its second part), or `undefined`. */
183
+ function encryptedPayload(contentType, body) {
184
+ const boundary = parseParameterizedHeader(contentType).params["boundary"];
185
+ const parts = boundary ? splitMultipart(body, boundary) : [];
186
+ return parts.length === 2 ? decodeBodyText(parseMimeEntity(parts[1])) : undefined;
187
+ }
188
+ /**
189
+ * Decrypts an RFC 3156 `multipart/encrypted` message with `decryptionKeys`, verifying the signature it carries (a combined signed
190
+ * and encrypted message, RFC 3156 §6.2, or an inner `multipart/signed`, §6.1) against `candidates` and the keys attached inside.
191
+ * Never throws.
192
+ */
193
+ export async function parsePgpEncryptedMessage(contentType, body, decryptionKeys, candidates) {
194
+ const payload = encryptedPayload(contentType, body);
195
+ if (!payload || decryptionKeys.length === 0) {
196
+ return { decrypted: false };
197
+ }
198
+ let data;
199
+ let signed;
200
+ try {
201
+ const result = await openpgp.decrypt({ message: await openpgp.readMessage({ armoredMessage: payload }), decryptionKeys, format: "binary", expectSigned: false });
202
+ data = result.data;
203
+ signed = result.signatures.length > 0;
204
+ }
205
+ catch {
206
+ return { decrypted: false };
207
+ }
208
+ const entityText = bytesToBinaryString(data);
209
+ const inner = parseMimeEntity(entityText);
210
+ const innerType = parseParameterizedHeader(inner.headers["content-type"]);
211
+ if (!signed && innerType.value === "multipart/signed" && innerType.params["protocol"]?.toLowerCase() === "application/pgp-signature") {
212
+ const innerSigned = await parsePgpSignedMessage(inner.headers["content-type"], inner.body, candidates);
213
+ return { decrypted: true, entityText: innerSigned.entityText ?? entityText, signature: innerSigned.signature };
214
+ }
215
+ if (!signed) {
216
+ return { decrypted: true, entityText };
217
+ }
218
+ // The keys attached inside are only known now: verify the signature against them and the given ones together.
219
+ const all = await withAttached(candidates, entityText);
220
+ if (all.length === 0) {
221
+ return { decrypted: true, entityText, signature: { status: "unknown_key" } };
222
+ }
223
+ const verified = await openpgp.decrypt({
224
+ message: await openpgp.readMessage({ armoredMessage: payload }),
225
+ decryptionKeys,
226
+ verificationKeys: all.map((c) => c.key),
227
+ format: "binary",
228
+ expectSigned: false,
229
+ });
230
+ return { decrypted: true, entityText, signature: await signatureOutcome(verified.signatures, all) };
231
+ }
232
+ /** A `text/plain` entity for an inline message's recovered text. */
233
+ function plainEntity(text) {
234
+ return bytesToBinaryString(new TextEncoder().encode(`Content-Type: text/plain; charset=utf-8${CRLF}${CRLF}${text}`));
235
+ }
236
+ /** Decrypts an inline OpenPGP message (an armored `PGP MESSAGE` as the whole text body), verifying a signature it carries. Never
237
+ * throws. */
238
+ export async function parsePgpInlineEncrypted(armored, decryptionKeys, candidates) {
239
+ if (decryptionKeys.length === 0) {
240
+ return { decrypted: false };
241
+ }
242
+ try {
243
+ const result = await openpgp.decrypt({
244
+ message: await openpgp.readMessage({ armoredMessage: armored }),
245
+ decryptionKeys,
246
+ verificationKeys: candidates.map((c) => c.key),
247
+ expectSigned: false,
248
+ });
249
+ const signature = result.signatures.length > 0 ? await signatureOutcome(result.signatures, candidates) : undefined;
250
+ return { decrypted: true, entityText: plainEntity(String(result.data)), ...(signature ? { signature } : {}) };
251
+ }
252
+ catch {
253
+ return { decrypted: false };
254
+ }
255
+ }
256
+ /** Verifies an inline cleartext-signed message (`BEGIN PGP SIGNED MESSAGE`). Never throws. */
257
+ export async function parsePgpCleartextSigned(text, candidates) {
258
+ try {
259
+ const message = await openpgp.readCleartextMessage({ cleartextMessage: text });
260
+ const verification = await openpgp.verify({ message, verificationKeys: candidates.map((c) => c.key), expectSigned: false });
261
+ return { decrypted: true, entityText: plainEntity(message.getText()), signature: await signatureOutcome(verification.signatures, candidates) };
262
+ }
263
+ catch {
264
+ return { decrypted: true, signature: { status: "invalid" } };
265
+ }
266
+ }
267
+ /** The certificates in a list of base64 keys (`PublicKey.publicKey` of OpenPGP entries) - unreadable ones dropped. */
268
+ export async function readKeysBase64(keys) {
269
+ const read = await Promise.all(keys.map((key) => readPublicKeys(fromBase64(key))));
270
+ return read.flat();
271
+ }
@@ -24,18 +24,28 @@ export type SharedUnlockOutcome =
24
24
  /**
25
25
  * Opens shared mailbox `mailboxUid` for its member, with no password: their member wraps, opened with the zero-access keys of a mailbox they
26
26
  * own and have unlocked, become this session's keys for it (`installSharedSession()`). See `SharedUnlockOutcome` for when it can't.
27
+ *
28
+ * `refresh`: a shared session already open is opened again when it lacks the mailbox's active key and the member now holds a wrap of it -
29
+ * after a rotation, new mail is sealed to a key the session doesn't have. Its `masterKey` secret is kept, so the local index stays readable.
27
30
  */
28
- export declare function unlockSharedMailbox(mailboxUid: string, client?: ApiClient): Promise<SharedUnlockOutcome>;
31
+ export declare function unlockSharedMailbox(mailboxUid: string, client?: ApiClient, refresh?: boolean): Promise<SharedUnlockOutcome>;
29
32
  /** What `maintainSharedMailbox()` did. */
30
33
  export interface SharedMaintenance {
31
34
  provisioned: boolean;
32
35
  rotated: boolean;
33
36
  /** Wraps added for waiting members. */
34
37
  wrapped: number;
38
+ /** Wraps of older keys the server refused for waiting members (tried again on the next pass). */
39
+ refused: number;
35
40
  /** Departed users whose wraps were removed. */
36
41
  removed: number;
37
42
  /** Keys wrapped for the mailbox's escrow scope. */
38
43
  escrowed: number;
44
+ /**
45
+ * The server's reason when it refused (400) the new key of a pending rotation - e.g. the vault couldn't hold the new key's member wraps
46
+ * next to those it holds - so the current key stays active and members keep reading; the rotation is tried again on the next pass.
47
+ */
48
+ rotationRefused?: string;
39
49
  }
40
50
  /**
41
51
  * The background care of a shared mailbox, run by the client of its owner or of a member with full access (`status.manager`); nothing for
@@ -44,7 +54,9 @@ export interface SharedMaintenance {
44
54
  * A mailbox without an owner and without a key gets one (unless the administrator disallows zero-access), wrapped for every member who has a
45
55
  * certificate. Once a user who held the active key has left (`rotationPending`), a new key replaces it - enrolled by the owner with their
46
56
  * master key (`provisionZeroAccessKey()`), or for a mailbox without an owner with the members' wraps - and then the departed users' wraps
47
- * are removed. Members still waiting (`pending`) get wraps of every key this client can open, and the escrow scope of a mailbox without an
48
- * owner gets copies of the keys it is missing (`escrowPending`).
57
+ * are removed. Members still waiting (`pending`) get wraps of every key this client can open, one write per key with the active key's
58
+ * first (a refused older key doesn't stop the rest), and the escrow scope of a mailbox without an owner gets copies of the keys it is
59
+ * missing (`escrowPending`). A rotation the server refuses (400) is reported in `rotationRefused`, not thrown: the current key stays active
60
+ * and the rest of the pass goes ahead.
49
61
  */
50
62
  export declare function maintainSharedMailbox(mailboxUid: string, mailboxAddress: string, client?: ApiClient): Promise<SharedMaintenance>;
@@ -17,8 +17,9 @@
17
17
  import { ApiRequestError } from "../util/api.js";
18
18
  import { fromBase64, toBase64 } from "./encoding.js";
19
19
  import { enrollSharedZeroAccessKey, getEncryptionPolicy, getEscrowInfo, getKeyVault, getMyMemberKeyWraps, getSharedKeyStatus, putMemberKeyWraps, putZeroAccessEscrowWraps, removeMemberKeyWraps, } from "./keyvaultApi.js";
20
+ import { certificateHoldsKey } from "./keyPairMatch.js";
20
21
  import { exportPrivateKeyPkcs8, generateKeyPairWithCsr, importPrivateKeyPkcs8 } from "./keys.js";
21
- import { getUnlockedKeys, installSharedSession, personalSessions, sharedUnlockGeneration, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE, } from "./keySession.js";
22
+ import { addZeroAccessKey, getUnlockedKeys, installSharedSession, personalSessions, sharedUnlockGeneration, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE, } from "./keySession.js";
22
23
  import { buildAad, openWithKey } from "./masterKey.js";
23
24
  import { decryptEnvelopedDataWithKeys, encryptForRecipients } from "./smime.js";
24
25
  import { provisionZeroAccessKey } from "./zeroAccessKey.js";
@@ -63,9 +64,13 @@ function keyOrder(status) {
63
64
  /**
64
65
  * Opens shared mailbox `mailboxUid` for its member, with no password: their member wraps, opened with the zero-access keys of a mailbox they
65
66
  * own and have unlocked, become this session's keys for it (`installSharedSession()`). See `SharedUnlockOutcome` for when it can't.
67
+ *
68
+ * `refresh`: a shared session already open is opened again when it lacks the mailbox's active key and the member now holds a wrap of it -
69
+ * after a rotation, new mail is sealed to a key the session doesn't have. Its `masterKey` secret is kept, so the local index stays readable.
66
70
  */
67
- export async function unlockSharedMailbox(mailboxUid, client) {
68
- if (getUnlockedKeys(mailboxUid)) {
71
+ export async function unlockSharedMailbox(mailboxUid, client, refresh = false) {
72
+ const existing = getUnlockedKeys(mailboxUid);
73
+ if (existing && !(refresh && existing.shared)) {
69
74
  return "unlocked";
70
75
  }
71
76
  const generation = sharedUnlockGeneration(mailboxUid);
@@ -75,14 +80,22 @@ export async function unlockSharedMailbox(mailboxUid, client) {
75
80
  }
76
81
  catch (err) {
77
82
  if (err instanceof ApiRequestError && (err.status === 403 || err.status === 404)) {
78
- return "not_shared";
83
+ return existing ? "unlocked" : "not_shared";
79
84
  }
80
85
  throw err;
81
86
  }
87
+ const holdsActive = (keys) => !!keys?.some((key) => key.fingerprint === status.activeKeyFingerprint);
88
+ if (existing && (!status.activeKeyFingerprint || holdsActive(existing.zeroAccessKeys))) {
89
+ return "unlocked";
90
+ }
82
91
  if (status.role !== "member") {
83
- return "not_shared";
92
+ return existing ? "unlocked" : "not_shared";
84
93
  }
85
94
  const wraps = await getMyMemberKeyWraps(mailboxUid, client);
95
+ // Nothing new to open yet: the active key's wrap is still on its way.
96
+ if (existing && !holdsActive(wraps.map((wrap) => ({ fingerprint: wrap.zeroAccessKeyFingerprint })))) {
97
+ return "unlocked";
98
+ }
86
99
  if (wraps.length === 0) {
87
100
  return "pending";
88
101
  }
@@ -99,29 +112,48 @@ export async function unlockSharedMailbox(mailboxUid, client) {
99
112
  }
100
113
  const pkcs8 = unwrapped.pkcs8;
101
114
  try {
115
+ // The certificate is the server's: one that isn't this key's must never be what this session seals to.
116
+ if (!(await certificateHoldsKey(fromBase64(certificate.publicKey), pkcs8))) {
117
+ continue;
118
+ }
102
119
  secret ?? (secret = await sessionSecret(pkcs8));
103
120
  const privateKey = await importPrivateKeyPkcs8(pkcs8, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"]);
104
121
  opened.push({ fingerprint: wrap.zeroAccessKeyFingerprint, certDer: fromBase64(certificate.publicKey), privateKey });
105
122
  openedWith.add(unwrapped.openedWith);
106
123
  }
107
124
  catch {
108
- // A wrap that doesn't hold a usable key is skipped; the others still open the mailbox.
125
+ // A wrap that doesn't hold a usable key (or one its certificate doesn't match) is skipped; the others still open the mailbox.
109
126
  }
110
127
  finally {
111
128
  pkcs8.fill(0);
112
129
  }
113
130
  }
114
131
  if (!secret || opened.length === 0) {
115
- return "own_keys_locked";
132
+ return existing ? "unlocked" : "own_keys_locked";
133
+ }
134
+ if (existing) {
135
+ // Opened again: the secret the local index key is derived from stays the one the session started with.
136
+ secret.fill(0);
137
+ secret = new Uint8Array(existing.masterKey);
116
138
  }
117
139
  const unlocked = { masterKey: secret, zeroAccessKeys: opened, openedWith: [...openedWith] };
118
140
  installSharedSession(mailboxUid, unlocked, generation);
119
141
  return "unlocked";
120
142
  }
121
143
  /** The PKCS#8 of every zero-access key of `mailboxUid` the caller can open, by fingerprint - from the vault with the owner's unlocked master
122
- * key, or from the caller's own member wraps. The caller zeroes them. */
144
+ * key, or from the caller's own member wraps. Only keys whose certificate in `status` (the server's) is really theirs: a key is wrapped
145
+ * for members and escrow under that certificate's fingerprint, so a mismatched one is left out. The caller zeroes them. */
123
146
  async function openPrivateKeys(mailboxUid, status, ownerUnlocked, client) {
124
147
  const keys = new Map();
148
+ const keep = async (fingerprint, pkcs8) => {
149
+ const certificate = status.zeroAccessKeys.find((key) => key.fingerprint === fingerprint);
150
+ if (certificate && (await certificateHoldsKey(fromBase64(certificate.publicKey), pkcs8))) {
151
+ keys.set(fingerprint, pkcs8);
152
+ }
153
+ else {
154
+ pkcs8.fill(0);
155
+ }
156
+ };
125
157
  if (ownerUnlocked) {
126
158
  const vault = await getKeyVault(mailboxUid, client);
127
159
  const aad = buildAad(mailboxUid, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE);
@@ -129,7 +161,7 @@ async function openPrivateKeys(mailboxUid, status, ownerUnlocked, client) {
129
161
  const wrapped = vault.wrappedKeys.find((w) => w.fingerprint === key.fingerprint && w.useType === "zero-access");
130
162
  if (wrapped) {
131
163
  try {
132
- keys.set(key.fingerprint, await openWithKey(ownerUnlocked.masterKey, wrapped, aad));
164
+ await keep(key.fingerprint, await openWithKey(ownerUnlocked.masterKey, wrapped, aad));
133
165
  }
134
166
  catch {
135
167
  // Sealed under a master key since replaced: not this device's to share.
@@ -142,7 +174,7 @@ async function openPrivateKeys(mailboxUid, status, ownerUnlocked, client) {
142
174
  for (const wrap of await getMyMemberKeyWraps(mailboxUid, client)) {
143
175
  const unwrapped = await openWrap(wrap, own);
144
176
  if (unwrapped) {
145
- keys.set(wrap.zeroAccessKeyFingerprint, unwrapped.pkcs8);
177
+ await keep(wrap.zeroAccessKeyFingerprint, unwrapped.pkcs8);
146
178
  }
147
179
  }
148
180
  return keys;
@@ -154,9 +186,21 @@ async function wrapFor(member, pkcs8) {
154
186
  cms: toBase64(await encryptForRecipients(pkcs8, [fromBase64(member.certificate.publicKey)])),
155
187
  };
156
188
  }
157
- /** The members with a certificate of their own - the ones a key can be wrapped for. */
189
+ /** The members with a certificate of their own - the ones a key can be wrapped for. A certificate listed under the fingerprint of one of
190
+ * this user's own opened zero-access keys must be that key's certificate (checked against its private key at unlock): one the server
191
+ * swapped is left out, so a wrap meant for this user never goes to a key that isn't theirs. */
158
192
  function wrappable(status) {
159
- return status.members.filter((member) => !!member.certificate);
193
+ const own = ownZeroAccessKeys();
194
+ return status.members.filter((member) => {
195
+ if (!member.certificate) {
196
+ return false;
197
+ }
198
+ const mine = own.find((key) => key.fingerprint === member.certificate.fingerprint);
199
+ return !mine || sameBytes(mine.certDer, fromBase64(member.certificate.publicKey));
200
+ });
201
+ }
202
+ function sameBytes(a, b) {
203
+ return a.length === b.length && a.every((byte, index) => byte === b[index]);
160
204
  }
161
205
  /** `pkcs8` wrapped to the certificate of the escrow scope `escrow` describes. */
162
206
  async function escrowWrapFor(escrow, pkcs8) {
@@ -184,11 +228,13 @@ async function enrollNewSharedKey(mailboxUid, mailboxAddress, status, client) {
184
228
  * A mailbox without an owner and without a key gets one (unless the administrator disallows zero-access), wrapped for every member who has a
185
229
  * certificate. Once a user who held the active key has left (`rotationPending`), a new key replaces it - enrolled by the owner with their
186
230
  * master key (`provisionZeroAccessKey()`), or for a mailbox without an owner with the members' wraps - and then the departed users' wraps
187
- * are removed. Members still waiting (`pending`) get wraps of every key this client can open, and the escrow scope of a mailbox without an
188
- * owner gets copies of the keys it is missing (`escrowPending`).
231
+ * are removed. Members still waiting (`pending`) get wraps of every key this client can open, one write per key with the active key's
232
+ * first (a refused older key doesn't stop the rest), and the escrow scope of a mailbox without an owner gets copies of the keys it is
233
+ * missing (`escrowPending`). A rotation the server refuses (400) is reported in `rotationRefused`, not thrown: the current key stays active
234
+ * and the rest of the pass goes ahead.
189
235
  */
190
236
  export async function maintainSharedMailbox(mailboxUid, mailboxAddress, client) {
191
- const result = { provisioned: false, rotated: false, wrapped: 0, removed: 0, escrowed: 0 };
237
+ const result = { provisioned: false, rotated: false, wrapped: 0, refused: 0, removed: 0, escrowed: 0 };
192
238
  let status = await getSharedKeyStatus(mailboxUid, client);
193
239
  if (!status.manager || (status.members.length === 0 && status.departed.length === 0)) {
194
240
  return result;
@@ -206,15 +252,27 @@ export async function maintainSharedMailbox(mailboxUid, mailboxAddress, client)
206
252
  result.provisioned = true;
207
253
  }
208
254
  else if (status.rotationPending) {
209
- if (status.ownerless) {
210
- status = await enrollNewSharedKey(mailboxUid, mailboxAddress, status, client);
255
+ try {
256
+ if (status.ownerless) {
257
+ status = await enrollNewSharedKey(mailboxUid, mailboxAddress, status, client);
258
+ }
259
+ else {
260
+ const vault = await getKeyVault(mailboxUid, client);
261
+ const { key } = await provisionZeroAccessKey(mailboxUid, mailboxAddress, ownerUnlocked.masterKey, vault.masterKeyGeneration, client);
262
+ // The owner's open session gets the new key as its active one, as `ensureZeroAccessKey()` does: new mail is sealed to it.
263
+ addZeroAccessKey(mailboxUid, ownerUnlocked, key);
264
+ status = await getSharedKeyStatus(mailboxUid, client);
265
+ }
266
+ result.rotated = true;
211
267
  }
212
- else {
213
- const vault = await getKeyVault(mailboxUid, client);
214
- await provisionZeroAccessKey(mailboxUid, mailboxAddress, ownerUnlocked.masterKey, vault.masterKeyGeneration, client);
215
- status = await getSharedKeyStatus(mailboxUid, client);
268
+ catch (err) {
269
+ // Refused before any key was issued (restapi: the vault can't hold the new key's member wraps next to those it holds): the
270
+ // current key stays active, so the rest of the pass - waiting members' wraps of it - still goes ahead. Anything else throws.
271
+ if (!(err instanceof ApiRequestError) || err.status !== 400) {
272
+ throw err;
273
+ }
274
+ result.rotationRefused = err.message;
216
275
  }
217
- result.rotated = true;
218
276
  }
219
277
  // The departed hold no wrap of the active key any more (or never did): their old wraps go.
220
278
  if (!status.rotationPending) {
@@ -228,17 +286,32 @@ export async function maintainSharedMailbox(mailboxUid, mailboxAddress, client)
228
286
  if (missing.length > 0 || escrowPending.length > 0) {
229
287
  const keys = await openPrivateKeys(mailboxUid, status, ownerUnlocked, client);
230
288
  try {
231
- const wraps = [];
232
- for (const member of missing) {
233
- for (const [fingerprint, pkcs8] of keys) {
289
+ // One write per key, the active key's first: it alone opens new mail, so a refused backfill of an older key (the server bounds
290
+ // each key's wraps and the vault's total) never keeps a member from it. A failed older key is left for the next pass.
291
+ const order = keyOrder(status);
292
+ for (const [fingerprint, pkcs8] of [...keys].sort(([a], [b]) => order(a) - order(b))) {
293
+ const wraps = [];
294
+ for (const member of missing) {
234
295
  if (!member.wrapped.includes(fingerprint)) {
235
296
  wraps.push({ ...(await wrapFor(member, pkcs8)), zeroAccessKeyFingerprint: fingerprint });
236
297
  }
237
298
  }
238
- }
239
- if (wraps.length > 0) {
240
- await putMemberKeyWraps(mailboxUid, wraps, client);
241
- result.wrapped = wraps.length;
299
+ if (wraps.length === 0) {
300
+ continue;
301
+ }
302
+ if (fingerprint === status.activeKeyFingerprint) {
303
+ await putMemberKeyWraps(mailboxUid, wraps, client);
304
+ result.wrapped += wraps.length;
305
+ }
306
+ else {
307
+ try {
308
+ await putMemberKeyWraps(mailboxUid, wraps, client);
309
+ result.wrapped += wraps.length;
310
+ }
311
+ catch {
312
+ result.refused += wraps.length;
313
+ }
314
+ }
242
315
  }
243
316
  const held = escrowPending.filter((fingerprint) => keys.has(fingerprint));
244
317
  if (held.length > 0) {
@@ -13,6 +13,14 @@ export interface ProtectedHeaders {
13
13
  * (Section 3.3). Applied to produce the OUTER, unprotected envelope's headers from the real
14
14
  * (protected) ones; `From`/`To`/`Cc`/`Date` pass through unchanged, `Subject` is obscured. */
15
15
  export declare function applyBaselineOuterHeaders(headers: ProtectedHeaders): ProtectedHeaders;
16
+ /** @internal Shared with `pgpMessage.ts`. */
17
+ export declare function base64Wrapped(bytes: Uint8Array): string;
18
+ /** @internal Shared with `pgpMessage.ts`. */
19
+ export declare function generateBoundary(): string;
20
+ /** Strips CR/LF/NUL from a header value that has no further encoding (Date, Message-ID, Content-Type). */
21
+ export declare function flattenHeaderValue(value: string): string;
22
+ /** @internal Shared with `pgpMessage.ts`: the protected header block of an inner entity (with `HP-Outer:` copies of `hpOuter`). */
23
+ export declare function protectedHeaderLines(headers: ProtectedHeaders, hpOuter?: ProtectedHeaders): string;
16
24
  /** One MIME entity's worth of headers-beyond-Content-Type plus body, ready for a caller (the actual
17
25
  * outbound-message compose step, built on top of this module) to combine with the outer envelope's
18
26
  * own `From`/`To`/`Subject`/`Date`/`Message-ID`. */
@@ -106,6 +114,17 @@ export interface ParsedEncryptedMessage {
106
114
  * (`messageId` has no `HP-Outer` counterpart; only the fields the outer envelope itself carries do).
107
115
  * Exported so `messageSecurity.ts` can build one from a received message's real outer envelope. */
108
116
  export type ComparableOuterHeaders = Partial<Pick<ProtectedHeaders, "from" | "to" | "cc" | "date" | "subject">>;
117
+ /** Extracts the outer-envelope header values RFC 9788's `HP-Outer: <Field>: <value>` mechanism embeds
118
+ * inside an encrypted message's protected content - one line per outer header, all sharing the literal
119
+ * field name `HP-Outer`, which `splitHeadersAndBody()`'s own flat header map can hold only the last of
120
+ * (a real bug if reused for this - JSON/`Record` keys aren't multi-valued). Scans the raw header block
121
+ * text directly instead. Returns `undefined` when no `HP-Outer:` lines are present at all, so a caller
122
+ * can distinguish "nothing to compare" from "compared and everything matched". */
123
+ export declare function extractHpOuterHeaders(fields: MimeHeaderField[]): ComparableOuterHeaders | undefined;
124
+ /** `true` when every field `hpOuter` actually carries matches `actualOuterHeaders`' value for that same
125
+ * field - a field `hpOuter` doesn't carry (e.g. no `Cc` on this message) is not compared, since RFC
126
+ * 9788 never claims coverage for it either way. */
127
+ export declare function outerHeadersMatch(hpOuter: ComparableOuterHeaders, actualOuterHeaders: ComparableOuterHeaders): boolean;
109
128
  /** Decrypts (and, if present, verifies the inner signature of) a message built by
110
129
  * `buildEncryptedMessage()`. Returns `{ decrypted: false }` (never throws) for anything malformed or
111
130
  * undecryptable with the given key — the caller's own concern is distinguishing "wrong key" from
@@ -130,3 +149,5 @@ export declare function splitHeadersAndBody(entity: string): {
130
149
  body: string;
131
150
  rawHeaderBlock: string;
132
151
  };
152
+ /** @internal Shared with `pgpMessage.ts`. */
153
+ export declare function headersToProtectedHeaders(headers: Record<string, string>): ProtectedHeaders;