@rapidmx/web-client 0.35.0 → 0.36.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 (119) hide show
  1. package/apps/escrow/matters/[uid].tsx +4 -3
  2. package/apps/shared/calendar/sealedEvents.ts +80 -0
  3. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +61 -3
  4. package/apps/shared/components/contacts/contactCardData.ts +2 -1
  5. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +23 -1
  6. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +91 -0
  7. package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
  8. package/apps/shared/components/mail/ConversationList.tsx +1 -1
  9. package/apps/shared/components/mail/MessageDetailPane.tsx +35 -13
  10. package/apps/shared/components/mail/compose/draftResume.ts +28 -3
  11. package/apps/shared/components/mail/compose/quotedBody.ts +4 -3
  12. package/apps/shared/components/mail/invite/inviteStore.ts +7 -1
  13. package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
  14. package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
  15. package/apps/shared/components/settings/KeyExportSetting.tsx +170 -0
  16. package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
  17. package/apps/shared/components/settings/ZeroAccessSetting.tsx +117 -0
  18. package/apps/shared/mail/decryptedMessages.ts +11 -3
  19. package/apps/shared/mail/outbox/sendJob.ts +2 -2
  20. package/apps/shared/mail/reportNotices.ts +3 -1
  21. package/apps/shared/mail/zeroAccessSource.ts +27 -0
  22. package/apps/shared/search/LocalIndexLifecycle.tsx +31 -0
  23. package/apps/shared/search/localIndexBuilder.ts +27 -1
  24. package/apps/shared/search/localIndexRpcClient.ts +8 -4
  25. package/apps/shared/search/localIndexSchema.ts +2 -2
  26. package/apps/shared/search/localIndexTransport.ts +2 -1
  27. package/apps/shared/search/localIndexWorker.ts +6 -4
  28. package/apps/shared/search/zeroAccessIndexSync.ts +487 -0
  29. package/apps/www/calendar/index.tsx +176 -56
  30. package/apps/www/index.tsx +10 -3
  31. package/apps/www/settings/encryption/index.tsx +5 -0
  32. package/apps/www/settings/privacy/index.tsx +2 -1
  33. package/dist/apps/escrow/matters/[uid].js +2 -2
  34. package/dist/apps/shared/calendar/sealedEvents.d.ts +13 -0
  35. package/dist/apps/shared/calendar/sealedEvents.js +54 -0
  36. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +18 -2
  37. package/dist/apps/shared/components/contacts/contactCardData.js +2 -1
  38. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +21 -2
  39. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +15 -0
  40. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +72 -0
  41. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
  42. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
  43. package/dist/apps/shared/components/mail/ConversationList.js +1 -1
  44. package/dist/apps/shared/components/mail/MessageDetailPane.js +34 -13
  45. package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
  46. package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
  47. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +1 -1
  48. package/dist/apps/shared/components/mail/compose/quotedBody.js +4 -3
  49. package/dist/apps/shared/components/mail/invite/inviteStore.js +6 -1
  50. package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
  51. package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
  52. package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
  53. package/dist/apps/shared/components/settings/KeyExportSetting.js +88 -0
  54. package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
  55. package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
  56. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +14 -0
  57. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +71 -0
  58. package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
  59. package/dist/apps/shared/mail/decryptedMessages.js +11 -3
  60. package/dist/apps/shared/mail/outbox/sendJob.js +2 -2
  61. package/dist/apps/shared/mail/reportNotices.js +3 -1
  62. package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
  63. package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
  64. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
  65. package/dist/apps/shared/search/LocalIndexLifecycle.js +28 -0
  66. package/dist/apps/shared/search/localIndexBuilder.d.ts +5 -0
  67. package/dist/apps/shared/search/localIndexBuilder.js +24 -2
  68. package/dist/apps/shared/search/localIndexRpcClient.d.ts +1 -1
  69. package/dist/apps/shared/search/localIndexRpcClient.js +6 -4
  70. package/dist/apps/shared/search/localIndexSchema.d.ts +1 -1
  71. package/dist/apps/shared/search/localIndexSchema.js +2 -2
  72. package/dist/apps/shared/search/localIndexTransport.d.ts +2 -1
  73. package/dist/apps/shared/search/localIndexWorker.d.ts +2 -0
  74. package/dist/apps/shared/search/localIndexWorker.js +4 -4
  75. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +71 -0
  76. package/dist/apps/shared/search/zeroAccessIndexSync.js +382 -0
  77. package/dist/apps/www/calendar/index.js +41 -15
  78. package/dist/apps/www/index.js +10 -3
  79. package/dist/apps/www/settings/encryption/index.js +3 -1
  80. package/dist/apps/www/settings/privacy/index.js +1 -1
  81. package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
  82. package/dist/lib/calendar/calendarApi.d.ts +6 -0
  83. package/dist/lib/calendar/inviteApi.js +27 -5
  84. package/dist/lib/calendar/sealedInvites.d.ts +11 -0
  85. package/dist/lib/calendar/sealedInvites.js +28 -0
  86. package/dist/lib/crypto/keyExport.d.ts +54 -0
  87. package/dist/lib/crypto/keyExport.js +161 -0
  88. package/dist/lib/crypto/keySession.d.ts +45 -1
  89. package/dist/lib/crypto/keySession.js +93 -2
  90. package/dist/lib/crypto/keyvaultApi.d.ts +88 -3
  91. package/dist/lib/crypto/keyvaultApi.js +32 -0
  92. package/dist/lib/crypto/messageSecurity.d.ts +30 -1
  93. package/dist/lib/crypto/messageSecurity.js +80 -4
  94. package/dist/lib/crypto/mime.d.ts +5 -0
  95. package/dist/lib/crypto/mime.js +25 -0
  96. package/dist/lib/crypto/sharedMailboxKeys.d.ts +50 -0
  97. package/dist/lib/crypto/sharedMailboxKeys.js +264 -0
  98. package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
  99. package/dist/lib/crypto/zeroAccessKey.js +66 -0
  100. package/dist/lib/mail/conversationsApi.d.ts +6 -0
  101. package/dist/lib/mail/mailApi.d.ts +30 -2
  102. package/dist/lib/mail/mailApi.js +5 -1
  103. package/dist/lib/search/zeroAccessIndexApi.d.ts +81 -0
  104. package/dist/lib/search/zeroAccessIndexApi.js +35 -0
  105. package/lib/admin/escrowAccessRequestsApi.ts +6 -0
  106. package/lib/calendar/calendarApi.ts +3 -0
  107. package/lib/calendar/inviteApi.ts +28 -5
  108. package/lib/calendar/sealedInvites.ts +33 -0
  109. package/lib/crypto/keyExport.ts +229 -0
  110. package/lib/crypto/keySession.ts +117 -3
  111. package/lib/crypto/keyvaultApi.ts +125 -3
  112. package/lib/crypto/messageSecurity.ts +110 -3
  113. package/lib/crypto/mime.ts +26 -0
  114. package/lib/crypto/sharedMailboxKeys.ts +324 -0
  115. package/lib/crypto/zeroAccessKey.ts +97 -0
  116. package/lib/mail/conversationsApi.ts +3 -0
  117. package/lib/mail/mailApi.ts +33 -3
  118. package/lib/search/zeroAccessIndexApi.ts +119 -0
  119. package/package.json +3 -3
@@ -0,0 +1,324 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Shared mailboxes' zero-access keys (restapi `specs/search.md` §3 *Shared Mailboxes*). A mailbox someone else owns, or that has no owner,
7
+ * has no master key its members know: its zero-access private key is instead wrapped to each member's own zero-access certificate
8
+ * (member key wraps), and a member opens it with the zero-access key of a mailbox they own - so a member unlocks it without a password,
9
+ * once their own mailbox is unlocked. Members get the ability to read, never the master key, signing key or encryption keys.
10
+ *
11
+ * `unlockSharedMailbox()` opens a shared mailbox for its member. `maintainSharedMailbox()` is what the client of the owner, or of a member
12
+ * with full access, does in the background: provisions a key for a mailbox without an owner, wraps the keys for members still waiting,
13
+ * and - once someone who held the key has left - rotates it, so new mail is encrypted to a key they never had. A mailbox without an owner
14
+ * has no master key for escrow to open, so when it is assigned to an escrow scope each of its keys is also wrapped to the scope's
15
+ * certificate: with a new key in the same write, and for keys the scope is still missing (`escrowPending`) in the background.
16
+ */
17
+ import { type ApiClient, ApiRequestError } from "../util/api.js";
18
+ import { fromBase64, toBase64 } from "./encoding.js";
19
+ import {
20
+ enrollSharedZeroAccessKey,
21
+ type EscrowInfo,
22
+ getEncryptionPolicy,
23
+ getEscrowInfo,
24
+ getKeyVault,
25
+ getMyMemberKeyWraps,
26
+ getSharedKeyStatus,
27
+ type MemberKeyWrap,
28
+ putMemberKeyWraps,
29
+ putZeroAccessEscrowWraps,
30
+ removeMemberKeyWraps,
31
+ type SharedKeyStatus,
32
+ type ZeroAccessEscrowWrapInput,
33
+ type ZeroAccessKey,
34
+ } from "./keyvaultApi.js";
35
+ import { exportPrivateKeyPkcs8, generateKeyPairWithCsr, importPrivateKeyPkcs8 } from "./keys.js";
36
+ import {
37
+ getUnlockedKeys,
38
+ installSharedSession,
39
+ personalSessions,
40
+ type RetainedEncryptionKey,
41
+ sharedUnlockGeneration,
42
+ type UnlockedKeys,
43
+ ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE,
44
+ } from "./keySession.js";
45
+ import { buildAad, openWithKey } from "./masterKey.js";
46
+ import { decryptEnvelopedDataWithKeys, encryptForRecipients } from "./smime.js";
47
+ import { provisionZeroAccessKey } from "./zeroAccessKey.js";
48
+
49
+ /** What `unlockSharedMailbox()` found. */
50
+ export type SharedUnlockOutcome =
51
+ /** Its keys are open in this session. */
52
+ | "unlocked"
53
+ /** The caller is a member but holds no wrap yet: another member's client shares the key with them shortly. */
54
+ | "pending"
55
+ /** The caller is a member, but none of their own mailboxes is unlocked to open their wraps with. */
56
+ | "own_keys_locked"
57
+ /** Not a shared mailbox of the caller's (they own it, or have no member access): its own unlock applies. */
58
+ | "not_shared";
59
+
60
+ /** Domain-separation prefix of the secret a shared session's `masterKey` stands for. */
61
+ const SESSION_SECRET_LABEL = new TextEncoder().encode("rapidmx-shared-mailbox-session-v1:");
62
+
63
+ async function sessionSecret(pkcs8: Uint8Array): Promise<Uint8Array> {
64
+ const input = new Uint8Array(SESSION_SECRET_LABEL.length + pkcs8.length);
65
+ input.set(SESSION_SECRET_LABEL);
66
+ input.set(pkcs8, SESSION_SECRET_LABEL.length);
67
+ try {
68
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", input));
69
+ } finally {
70
+ input.fill(0);
71
+ }
72
+ }
73
+
74
+ /** One of the user's own zero-access keys, with the mailbox it belongs to. */
75
+ type OwnKey = RetainedEncryptionKey & { mailboxUid: string };
76
+
77
+ /** The zero-access keys of the mailboxes this user has unlocked themselves - what their member wraps are addressed to. */
78
+ function ownZeroAccessKeys(): OwnKey[] {
79
+ return personalSessions().flatMap(({ mailboxUid, unlocked }) => (unlocked.zeroAccessKeys ?? []).map((key) => ({ ...key, mailboxUid })));
80
+ }
81
+
82
+ /** Opens `wrap` with the user's own keys: the wrapped PKCS#8 (the caller zeroes it) and the own mailbox whose key opened it, or `undefined`
83
+ * when no own key opens it. */
84
+ async function openWrap(wrap: MemberKeyWrap, own: OwnKey[]): Promise<{ pkcs8: Uint8Array; openedWith: string } | undefined> {
85
+ const recipient = own.find((key) => key.fingerprint === wrap.recipientFingerprint);
86
+ if (!recipient) {
87
+ return undefined;
88
+ }
89
+ try {
90
+ return { pkcs8: await decryptEnvelopedDataWithKeys(fromBase64(wrap.cms), [recipient]), openedWith: recipient.mailboxUid };
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ }
95
+
96
+ /** The active key first, then the others newest first - the order `UnlockedKeys.zeroAccessKeys` keeps. */
97
+ function keyOrder(status: Pick<SharedKeyStatus, "activeKeyFingerprint" | "zeroAccessKeys">): (fingerprint: string) => number {
98
+ const rank = new Map<string, number>(
99
+ [...status.zeroAccessKeys]
100
+ .sort((a, b) => Number(a.fingerprint !== status.activeKeyFingerprint) - Number(b.fingerprint !== status.activeKeyFingerprint) || b.notBefore - a.notBefore)
101
+ .map((key, index) => [key.fingerprint, index]),
102
+ );
103
+ return (fingerprint) => rank.get(fingerprint) ?? Number.MAX_SAFE_INTEGER;
104
+ }
105
+
106
+ /**
107
+ * Opens shared mailbox `mailboxUid` for its member, with no password: their member wraps, opened with the zero-access keys of a mailbox they
108
+ * own and have unlocked, become this session's keys for it (`installSharedSession()`). See `SharedUnlockOutcome` for when it can't.
109
+ */
110
+ export async function unlockSharedMailbox(mailboxUid: string, client?: ApiClient): Promise<SharedUnlockOutcome> {
111
+ if (getUnlockedKeys(mailboxUid)) {
112
+ return "unlocked";
113
+ }
114
+ const generation: string = sharedUnlockGeneration(mailboxUid);
115
+ let status: SharedKeyStatus;
116
+ try {
117
+ status = await getSharedKeyStatus(mailboxUid, client);
118
+ } catch (err) {
119
+ if (err instanceof ApiRequestError && (err.status === 403 || err.status === 404)) {
120
+ return "not_shared";
121
+ }
122
+ throw err;
123
+ }
124
+ if (status.role !== "member") {
125
+ return "not_shared";
126
+ }
127
+ const wraps: MemberKeyWrap[] = await getMyMemberKeyWraps(mailboxUid, client);
128
+ if (wraps.length === 0) {
129
+ return "pending";
130
+ }
131
+ const own = ownZeroAccessKeys();
132
+ const rank = keyOrder(status);
133
+ const opened: RetainedEncryptionKey[] = [];
134
+ const openedWith: Set<string> = new Set();
135
+ let secret: Uint8Array | undefined;
136
+ for (const wrap of [...wraps].sort((a, b) => rank(a.zeroAccessKeyFingerprint) - rank(b.zeroAccessKeyFingerprint))) {
137
+ const certificate: ZeroAccessKey | undefined = status.zeroAccessKeys.find((key) => key.fingerprint === wrap.zeroAccessKeyFingerprint);
138
+ const unwrapped = certificate ? await openWrap(wrap, own) : undefined;
139
+ if (!certificate || !unwrapped) {
140
+ continue;
141
+ }
142
+ const pkcs8 = unwrapped.pkcs8;
143
+ try {
144
+ secret ??= await sessionSecret(pkcs8);
145
+ const privateKey = await importPrivateKeyPkcs8(pkcs8, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"]);
146
+ opened.push({ fingerprint: wrap.zeroAccessKeyFingerprint, certDer: fromBase64(certificate.publicKey), privateKey });
147
+ openedWith.add(unwrapped.openedWith);
148
+ } catch {
149
+ // A wrap that doesn't hold a usable key is skipped; the others still open the mailbox.
150
+ } finally {
151
+ pkcs8.fill(0);
152
+ }
153
+ }
154
+ if (!secret || opened.length === 0) {
155
+ return "own_keys_locked";
156
+ }
157
+ const unlocked: UnlockedKeys = { masterKey: secret, zeroAccessKeys: opened, openedWith: [...openedWith] };
158
+ installSharedSession(mailboxUid, unlocked, generation);
159
+ return "unlocked";
160
+ }
161
+
162
+ /** 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
163
+ * key, or from the caller's own member wraps. The caller zeroes them. */
164
+ async function openPrivateKeys(
165
+ mailboxUid: string,
166
+ status: SharedKeyStatus,
167
+ ownerUnlocked: UnlockedKeys | undefined,
168
+ client?: ApiClient,
169
+ ): Promise<Map<string, Uint8Array>> {
170
+ const keys = new Map<string, Uint8Array>();
171
+ if (ownerUnlocked) {
172
+ const vault = await getKeyVault(mailboxUid, client);
173
+ const aad = buildAad(mailboxUid, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE);
174
+ for (const key of status.zeroAccessKeys) {
175
+ const wrapped = vault.wrappedKeys.find((w) => w.fingerprint === key.fingerprint && w.useType === "zero-access");
176
+ if (wrapped) {
177
+ try {
178
+ keys.set(key.fingerprint, await openWithKey(ownerUnlocked.masterKey, wrapped, aad));
179
+ } catch {
180
+ // Sealed under a master key since replaced: not this device's to share.
181
+ }
182
+ }
183
+ }
184
+ return keys;
185
+ }
186
+ const own = ownZeroAccessKeys();
187
+ for (const wrap of await getMyMemberKeyWraps(mailboxUid, client)) {
188
+ const unwrapped = await openWrap(wrap, own);
189
+ if (unwrapped) {
190
+ keys.set(wrap.zeroAccessKeyFingerprint, unwrapped.pkcs8);
191
+ }
192
+ }
193
+ return keys;
194
+ }
195
+
196
+ async function wrapFor(member: { userUid: string; certificate: ZeroAccessKey }, pkcs8: Uint8Array): Promise<Omit<MemberKeyWrap, "zeroAccessKeyFingerprint">> {
197
+ return {
198
+ memberUserUid: member.userUid,
199
+ recipientFingerprint: member.certificate.fingerprint,
200
+ cms: toBase64(await encryptForRecipients(pkcs8, [fromBase64(member.certificate.publicKey)])),
201
+ };
202
+ }
203
+
204
+ /** The members with a certificate of their own - the ones a key can be wrapped for. */
205
+ function wrappable(status: SharedKeyStatus): { userUid: string; certificate: ZeroAccessKey; wrapped: string[] }[] {
206
+ return status.members.filter((member): member is typeof member & { certificate: ZeroAccessKey } => !!member.certificate);
207
+ }
208
+
209
+ /** `pkcs8` wrapped to the certificate of the escrow scope `escrow` describes. */
210
+ async function escrowWrapFor(escrow: EscrowInfo, pkcs8: Uint8Array): Promise<Omit<ZeroAccessEscrowWrapInput, "zeroAccessKeyFingerprint">> {
211
+ return { escrowScopeId: escrow.escrowScopeId, cms: toBase64(await encryptForRecipients(pkcs8, [fromBase64(escrow.publicKey.publicKey)])) };
212
+ }
213
+
214
+ /** A new keypair for a mailbox without an owner, enrolled together with a wrap of it for every member who can receive one, and for its
215
+ * escrow scope when it has one. */
216
+ async function enrollNewSharedKey(mailboxUid: string, mailboxAddress: string, status: SharedKeyStatus, client?: ApiClient): Promise<SharedKeyStatus> {
217
+ const escrow: EscrowInfo | undefined = status.escrowScopeId ? await getEscrowInfo(mailboxUid, client) : undefined;
218
+ const { keyPair, csrPem } = await generateKeyPairWithCsr(mailboxAddress, "encrypt");
219
+ const pkcs8 = await exportPrivateKeyPkcs8(keyPair.privateKey);
220
+ try {
221
+ const wraps = await Promise.all(wrappable(status).map((member) => wrapFor(member, pkcs8)));
222
+ const escrowWrap = escrow ? await escrowWrapFor(escrow, pkcs8) : undefined;
223
+ return await enrollSharedZeroAccessKey(mailboxUid, { csr: csrPem, wraps, ...(escrowWrap ? { escrowWrap } : {}) }, client);
224
+ } finally {
225
+ pkcs8.fill(0);
226
+ }
227
+ }
228
+
229
+ /** What `maintainSharedMailbox()` did. */
230
+ export interface SharedMaintenance {
231
+ provisioned: boolean;
232
+ rotated: boolean;
233
+ /** Wraps added for waiting members. */
234
+ wrapped: number;
235
+ /** Departed users whose wraps were removed. */
236
+ removed: number;
237
+ /** Keys wrapped for the mailbox's escrow scope. */
238
+ escrowed: number;
239
+ }
240
+
241
+ /**
242
+ * 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
243
+ * anyone else, or while the keys it needs are locked:
244
+ *
245
+ * A mailbox without an owner and without a key gets one (unless the administrator disallows zero-access), wrapped for every member who has a
246
+ * certificate. Once a user who held the active key has left (`rotationPending`), a new key replaces it - enrolled by the owner with their
247
+ * master key (`provisionZeroAccessKey()`), or for a mailbox without an owner with the members' wraps - and then the departed users' wraps
248
+ * are removed. Members still waiting (`pending`) get wraps of every key this client can open, and the escrow scope of a mailbox without an
249
+ * owner gets copies of the keys it is missing (`escrowPending`).
250
+ */
251
+ export async function maintainSharedMailbox(mailboxUid: string, mailboxAddress: string, client?: ApiClient): Promise<SharedMaintenance> {
252
+ const result: SharedMaintenance = { provisioned: false, rotated: false, wrapped: 0, removed: 0, escrowed: 0 };
253
+ let status: SharedKeyStatus = await getSharedKeyStatus(mailboxUid, client);
254
+ if (!status.manager || (status.members.length === 0 && status.departed.length === 0)) {
255
+ return result;
256
+ }
257
+ const ownerUnlocked = status.role === "owner" ? getUnlockedKeys(mailboxUid) : undefined;
258
+ if (status.role === "owner" && (!ownerUnlocked || ownerUnlocked.shared)) {
259
+ return result;
260
+ }
261
+
262
+ if (status.ownerless && !status.activeKeyFingerprint) {
263
+ const policy = await getEncryptionPolicy(client).catch(() => undefined);
264
+ if (policy?.allowZeroAccess === false || ownZeroAccessKeys().length === 0) {
265
+ return result;
266
+ }
267
+ status = await enrollNewSharedKey(mailboxUid, mailboxAddress, status, client);
268
+ result.provisioned = true;
269
+ } else if (status.rotationPending) {
270
+ if (status.ownerless) {
271
+ status = await enrollNewSharedKey(mailboxUid, mailboxAddress, status, client);
272
+ } else {
273
+ const vault = await getKeyVault(mailboxUid, client);
274
+ await provisionZeroAccessKey(mailboxUid, mailboxAddress, ownerUnlocked!.masterKey, vault.masterKeyGeneration, client);
275
+ status = await getSharedKeyStatus(mailboxUid, client);
276
+ }
277
+ result.rotated = true;
278
+ }
279
+ // The departed hold no wrap of the active key any more (or never did): their old wraps go.
280
+ if (!status.rotationPending) {
281
+ for (const departed of status.departed) {
282
+ status = await removeMemberKeyWraps(mailboxUid, departed, client);
283
+ result.removed++;
284
+ }
285
+ }
286
+
287
+ const missing = wrappable(status).filter((member) => status.zeroAccessKeys.some((key) => !member.wrapped.includes(key.fingerprint)));
288
+ const escrowPending: string[] = status.ownerless && status.escrowScopeId ? (status.escrowPending ?? []) : [];
289
+ if (missing.length > 0 || escrowPending.length > 0) {
290
+ const keys = await openPrivateKeys(mailboxUid, status, ownerUnlocked, client);
291
+ try {
292
+ const wraps: MemberKeyWrap[] = [];
293
+ for (const member of missing) {
294
+ for (const [fingerprint, pkcs8] of keys) {
295
+ if (!member.wrapped.includes(fingerprint)) {
296
+ wraps.push({ ...(await wrapFor(member, pkcs8)), zeroAccessKeyFingerprint: fingerprint });
297
+ }
298
+ }
299
+ }
300
+ if (wraps.length > 0) {
301
+ await putMemberKeyWraps(mailboxUid, wraps, client);
302
+ result.wrapped = wraps.length;
303
+ }
304
+ const held: string[] = escrowPending.filter((fingerprint) => keys.has(fingerprint));
305
+ if (held.length > 0) {
306
+ const escrow: EscrowInfo = await getEscrowInfo(mailboxUid, client);
307
+ // Assigned to another scope since the status was read: the next pass sees it.
308
+ if (escrow.escrowScopeId === status.escrowScopeId) {
309
+ const escrowWraps: ZeroAccessEscrowWrapInput[] = [];
310
+ for (const fingerprint of held) {
311
+ escrowWraps.push({ ...(await escrowWrapFor(escrow, keys.get(fingerprint)!)), zeroAccessKeyFingerprint: fingerprint });
312
+ }
313
+ await putZeroAccessEscrowWraps(mailboxUid, escrowWraps, client);
314
+ result.escrowed = escrowWraps.length;
315
+ }
316
+ }
317
+ } finally {
318
+ for (const pkcs8 of keys.values()) {
319
+ pkcs8.fill(0);
320
+ }
321
+ }
322
+ }
323
+ return result;
324
+ }
@@ -0,0 +1,97 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Zero-access keys (restapi `specs/search.md` §3): the per-mailbox keypair the server encrypts mail at rest to on arrival.
7
+ * Generated here like the encryption key - P-256, its certificate issued by restapi's internal CA from a CSR, its PKCS#8
8
+ * private key sealed under the mailbox master key - but never published: restapi keeps the certificate on
9
+ * `Mailbox.zeroAccessKeys` and returns it only with the vault.
10
+ */
11
+ import type { ApiClient } from "../util/api.js";
12
+ import { fromBase64 } from "./encoding.js";
13
+ import { type KeyVault, enrollKey, getKeyVault } from "./keyvaultApi.js";
14
+ import { exportPrivateKeyPkcs8, generateKeyPairWithCsr, importPrivateKeyPkcs8 } from "./keys.js";
15
+ import { addZeroAccessKey, getUnlockedKeys, type RetainedEncryptionKey, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE } from "./keySession.js";
16
+ import { buildAad, sealWithKey } from "./masterKey.js";
17
+
18
+ /** What `provisionZeroAccessKey()` resolves with. */
19
+ export interface ProvisionedZeroAccessKey {
20
+ /** The vault as restapi returned it after the enrollment. */
21
+ vault: KeyVault;
22
+ /** The new key, ready to decrypt with (its private key imported non-extractable for ECDH). */
23
+ key: RetainedEncryptionKey;
24
+ }
25
+
26
+ /**
27
+ * Generates a zero-access keypair for `mailboxUid`, seals its private key under `masterKey` and enrolls it. The key becomes
28
+ * the mailbox's active zero-access key (restapi supersedes any earlier one, whose private key stays in the vault). Pass the
29
+ * vault's `masterKeyGeneration` read alongside `masterKey` as `expectedMasterKeyGeneration`, so restapi refuses (`409`)
30
+ * the key if another device rotated the master key meanwhile.
31
+ */
32
+ export async function provisionZeroAccessKey(
33
+ mailboxUid: string,
34
+ mailboxAddress: string,
35
+ masterKey: Uint8Array,
36
+ expectedMasterKeyGeneration?: number,
37
+ client?: ApiClient,
38
+ ): Promise<ProvisionedZeroAccessKey> {
39
+ const { keyPair, csrPem } = await generateKeyPairWithCsr(mailboxAddress, "encrypt");
40
+ const raw = await exportPrivateKeyPkcs8(keyPair.privateKey);
41
+ try {
42
+ const sealed = await sealWithKey(masterKey, raw, buildAad(mailboxUid, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE));
43
+ const vault = await enrollKey(
44
+ mailboxUid,
45
+ {
46
+ useType: "zero-access",
47
+ csr: csrPem,
48
+ wrappedKey: { ciphertext: sealed.ciphertext, nonce: sealed.nonce, algorithm: "AES-256-GCM" },
49
+ ...(expectedMasterKeyGeneration !== undefined ? { expectedMasterKeyGeneration } : {}),
50
+ },
51
+ client,
52
+ );
53
+ // restapi appends the new wrapped key last.
54
+ const enrolled = [...vault.wrappedKeys].reverse().find((k) => k.useType === "zero-access");
55
+ const certificate = enrolled && vault.zeroAccessKeys?.find((k) => k.fingerprint === enrolled.fingerprint);
56
+ if (!enrolled || !certificate) {
57
+ throw new Error("The server did not return the enrolled zero-access key.");
58
+ }
59
+ const privateKey = await importPrivateKeyPkcs8(raw, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
60
+ return { vault, key: { fingerprint: enrolled.fingerprint, certDer: fromBase64(certificate.publicKey), privateKey } };
61
+ } finally {
62
+ raw.fill(0);
63
+ }
64
+ }
65
+
66
+ /** Provisions already in flight, per mailbox - a second caller (another effect, a re-render) joins the first. */
67
+ const inFlight = new Map<string, Promise<boolean>>();
68
+
69
+ /**
70
+ * Gives an unlocked mailbox a zero-access key when it has none yet - how a mailbox set up before zero-access encryption
71
+ * existed gets one, on its owner's next unlock. Resolves `true` when it enrolled a key (and added it to the session), `false`
72
+ * when there was nothing to do: the mailbox isn't unlocked, or its vault already holds an unrevoked zero-access key.
73
+ *
74
+ * Only the mailbox's owner may call this (restapi refuses anybody else's enrollment); a caller decides that, e.g. from
75
+ * `KeyEnrollmentGate`'s `canProvision`.
76
+ */
77
+ export function ensureZeroAccessKey(mailboxUid: string, mailboxAddress: string, client?: ApiClient): Promise<boolean> {
78
+ const pending = inFlight.get(mailboxUid);
79
+ if (pending) {
80
+ return pending;
81
+ }
82
+ const run = (async (): Promise<boolean> => {
83
+ const unlocked = getUnlockedKeys(mailboxUid);
84
+ if (!unlocked || unlocked.zeroAccessKeys?.length) {
85
+ return false;
86
+ }
87
+ const vault = await getKeyVault(mailboxUid, client);
88
+ if ((vault.zeroAccessKeys ?? []).some((k) => !k.revokedAt) || vault.wrappedKeys.length === 0) {
89
+ return false;
90
+ }
91
+ const { key } = await provisionZeroAccessKey(mailboxUid, mailboxAddress, unlocked.masterKey, vault.masterKeyGeneration, client);
92
+ addZeroAccessKey(mailboxUid, unlocked, key);
93
+ return true;
94
+ })().finally(() => inFlight.delete(mailboxUid));
95
+ inFlight.set(mailboxUid, run);
96
+ return run;
97
+ }
@@ -37,6 +37,9 @@ export interface ConversationSummary {
37
37
  latestFrom: Recipient;
38
38
  /** The most recent message's `bodyPreview` — the snippet a collapsed row shows. */
39
39
  latestPreview: string;
40
+ /** Set when the latest message is sealed at rest (zero-access): its subject and preview are only in `sealedSummary`, which
41
+ * `messageSecurity.ts`'s `openZeroAccessSummary()` opens with the mailbox's zero-access keys. */
42
+ latestZeroAccess?: { keyFingerprint: string; sealedSummary: string };
40
43
  /** The most recent message's folder. */
41
44
  latestFolderUid: string;
42
45
  /** The iTIP `METHOD` of the calendar file the most recent message carries (`Message.meetingMethod`) - `"REQUEST"`, `"REPLY"`,
@@ -26,7 +26,7 @@ import {
26
26
  } from "../util/api.js";
27
27
  import { ListParams, buildQuery, sortQuery } from "../util/apiQuery.js";
28
28
  import { deviceTimeZone } from "../util/timeZone.js";
29
- import type { EncryptionPreference, PublicKey } from "../crypto/keyvaultApi.js";
29
+ import type { EncryptionPreference, PublicKey, ZeroAccessKey } from "../crypto/keyvaultApi.js";
30
30
  import { bytesToBinaryString } from "../crypto/mime.js";
31
31
  import { EMBEDDED_IMAGE_TYPES, newContentId, parseDataImage } from "./inlineImages.js";
32
32
  import { MAX_QUOTED_IMAGE_BYTES } from "./compose/quotedImages.js";
@@ -129,6 +129,12 @@ export interface Mailbox {
129
129
  * per the spec's own `PublicKey` doc comment. Absent/empty means no keys enrolled yet
130
130
  * (`KeyEnrollmentGate` handles that state). */
131
131
  keys?: PublicKey[];
132
+ /** The mailbox's zero-access certificates, managed by the server (never written by a client). Read them from
133
+ * `KeyVault.zeroAccessKeys` to unlock; here only so a settings page can tell whether one is enrolled. */
134
+ zeroAccessKeys?: ZeroAccessKey[];
135
+ /** "Enable zero-access encryption": unset means on. Only the mailbox's owner can change it - restapi drops anybody
136
+ * else's value. Has no effect while the server's `EncryptionPolicy.allowZeroAccess` is off. */
137
+ zeroAccessEnabled?: boolean;
132
138
  /** How the caller reaches this mailbox: `"owner"` (theirs) or `"delegate"` (shared with them). Set by the plain list and read of
133
139
  * mailboxes - not in an administration-scope answer - so a client can label the shared ones; see `isSharedWithMe()`. */
134
140
  accessRole?: "owner" | "delegate";
@@ -281,6 +287,8 @@ export interface UpdateMailboxInput {
281
287
  autoSendReceiptsInternal?: boolean;
282
288
  autoSendReceiptsFederated?: boolean;
283
289
  autoSendReceiptsExternal?: boolean;
290
+ /** Owner-only server-side (anybody else's value is dropped) - see `Mailbox.zeroAccessEnabled`. */
291
+ zeroAccessEnabled?: boolean;
284
292
  /** Trusted-caller-only server-side. `null` (or `""`) unassigns the mailbox's escrow scope; the
285
293
  * referenced `EscrowScope` must exist otherwise. See `Mailbox.escrowScopeId`. */
286
294
  escrowScopeId?: string | null;
@@ -594,6 +602,10 @@ export interface Message {
594
602
  * stored body, not from a flag this API exposes. Always present on a real `Message` (defaults
595
603
  * `false` server-side), same optional-for-old-fixtures caveat as `Mailbox.oofEnabled`. */
596
604
  encrypted?: boolean;
605
+ /** Set when the server sealed this message at rest on arrival (zero-access, restapi `specs/search.md` §3): its stored body is an
606
+ * S/MIME message only the mailbox's zero-access key opens, `subject` is `[...]` and `bodyPreview` empty - the real ones are in
607
+ * `sealedSummary`. */
608
+ zeroAccess?: { keyFingerprint: string; sealedSummary: string };
597
609
  /** The raw RFC 2369/8058 `List-Unsubscribe` header value, if present — computed server-side by
598
610
  * `@rapidmx/restapi`'s `ScanPipeline` from the stored MIME, the strongest single bulk-mail signal it
599
611
  * captures. Used client-side to detect list traffic when replying, per
@@ -797,7 +809,7 @@ export type MessageReportKind = "junk" | "phishing" | "not_junk";
797
809
  /** Why the server did not teach its spam filter from a report: `encrypted` (it has no plaintext to teach), `disabled` (learning is switched off),
798
810
  * `unsupported` (no spam engine, or one that cannot learn), `too_large` (over the size it reads) or `failed` (the engine could not be reached,
799
811
  * refused, or the message could not be read). The report itself still happened. */
800
- export type MessageLearnSkipped = "encrypted" | "disabled" | "unsupported" | "too_large" | "failed";
812
+ export type MessageLearnSkipped = "encrypted" | "zero_access" | "disabled" | "unsupported" | "too_large" | "failed";
801
813
 
802
814
  /** What `reportMessage()` answers. */
803
815
  export interface MessageReportResult {
@@ -820,6 +832,9 @@ export interface ReportMessageOptions {
820
832
  /** With `not_junk` only (else a 400): also add the message's From address to the mailbox's safe senders. Needs full access to the mailbox (403 otherwise,
821
833
  * before anything is done). */
822
834
  alwaysTrustSender?: boolean;
835
+ /** For a zero-access message only (else a 400): its original source, base64-encoded, decrypted here - the server holds only the sealed
836
+ * copy, so this is what its spam filter learns from (never stored). Without it the server skips learning (`learnSkipped: "zero_access"`). */
837
+ rawMessage?: string;
823
838
  }
824
839
 
825
840
  /**
@@ -837,7 +852,11 @@ export function reportMessage(
837
852
  ): Promise<MessageReportResult> {
838
853
  return withClient(client, `/mail/messages/${encodeURIComponent(uid)}/report`, {
839
854
  method: "POST",
840
- body: JSON.stringify({ kind, ...(options.alwaysTrustSender ? { alwaysTrustSender: true } : {}) }),
855
+ body: JSON.stringify({
856
+ kind,
857
+ ...(options.alwaysTrustSender ? { alwaysTrustSender: true } : {}),
858
+ ...(options.rawMessage ? { rawMessage: options.rawMessage } : {}),
859
+ }),
841
860
  });
842
861
  }
843
862
 
@@ -1073,8 +1092,17 @@ export interface AssembleDraftInput {
1073
1092
  bcc?: ComposeRecipientInput[];
1074
1093
  subject?: string;
1075
1094
  html: string;
1095
+ /** See `AssembleIntent`. */
1096
+ intent?: AssembleIntent;
1076
1097
  }
1077
1098
 
1099
+ /**
1100
+ * Why a draft is being assembled (server `ComposeIntent`). `"save"`, the default, keeps an edit: in a zero-access mailbox the server
1101
+ * stores it sealed, which it can't send. `"send"` is the assemble right before `sendMessage()`/`queueMessageSend()`: stored readable
1102
+ * until it is relayed, then sealed as the Sent Items copy.
1103
+ */
1104
+ export type AssembleIntent = "save" | "send";
1105
+
1078
1106
  /** What a draft records about the thread it belongs to - see `createDraft()`. */
1079
1107
  export interface DraftThreading {
1080
1108
  /** The `messageId` of the message being replied to (`Message.inReplyTo`). */
@@ -1266,6 +1294,8 @@ export interface AssembleDraftRawInput {
1266
1294
  * `crypto/smimeMessage.ts`'s `buildSignedOnlyMessage()`/`buildEncryptedMessage()`, combined with
1267
1295
  * the outer envelope headers (`From`/`To`/`Cc`/`Date`/`Message-ID`/`MIME-Version`) by the caller. */
1268
1296
  rawMime: string;
1297
+ /** See `AssembleIntent`. */
1298
+ intent?: AssembleIntent;
1269
1299
  }
1270
1300
 
1271
1301
  /**
@@ -0,0 +1,119 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Typed wrappers over `@rapidmx/restapi`'s zero-access search index (`/mail/mailboxes/:id/search-index`, `specs/search.md` §7-8): the
7
+ * sealed index entries the server writes at delivery and the weekly segments clients compact them into. Everything here is
8
+ * ciphertext encrypted to the mailbox's zero-access key - `apps/shared/search/zeroAccessIndexSync.ts` decrypts and imports it.
9
+ */
10
+ import { type ApiClient, withClient, withClientBlob } from "../util/api.js";
11
+
12
+ export type SegmentOrigin = "zero-access" | "client";
13
+
14
+ /** A compacted segment as the manifest lists it. */
15
+ export interface IndexSegment {
16
+ weekId: string;
17
+ generation: number;
18
+ origin: SegmentOrigin;
19
+ bytes: number;
20
+ etag: string;
21
+ schemaVersion: number;
22
+ keyFingerprint: string;
23
+ entryCount: number;
24
+ }
25
+
26
+ /** A week's entries not yet compacted. */
27
+ export interface IndexWeekEntries {
28
+ weekId: string;
29
+ pendingEntries: number;
30
+ latestEntryAt: string;
31
+ /** The week has closed and its grace period has passed: a client may compact it. */
32
+ compactable: boolean;
33
+ }
34
+
35
+ export interface SegmentManifest {
36
+ mailboxUid: string;
37
+ segments: IndexSegment[];
38
+ weeks: IndexWeekEntries[];
39
+ }
40
+
41
+ /** One pending entry: `sealed` is base64 CMS `EnvelopedData` of a gzipped JSON `ZeroAccessIndexDocument`. */
42
+ export interface SealedIndexEntry {
43
+ uid: string;
44
+ weekId: string;
45
+ messageUid: string;
46
+ createdAt: string;
47
+ keyFingerprint: string;
48
+ sealed: string;
49
+ }
50
+
51
+ export interface SealedIndexEntryPage {
52
+ entries: SealedIndexEntry[];
53
+ next?: string;
54
+ }
55
+
56
+ /** What a sealed entry (and each document of a segment) decrypts to - restapi's `ZeroAccessIndexDocument`. */
57
+ export interface ZeroAccessIndexDocument {
58
+ v: 1;
59
+ messageUid: string;
60
+ subject: string;
61
+ body: string;
62
+ attachmentText: string[];
63
+ attachmentNames: string[];
64
+ deferredAttachments: string[];
65
+ from: string;
66
+ to: string[];
67
+ cc: string[];
68
+ participants: string[];
69
+ dateForSort: number;
70
+ }
71
+
72
+ /** `PUT .../segments/:weekId`. */
73
+ export interface SegmentUpload {
74
+ expectedGeneration: number;
75
+ /** Base64 of the sealed file. */
76
+ segment: string;
77
+ keyFingerprint: string;
78
+ schemaVersion: number;
79
+ origin?: SegmentOrigin;
80
+ foldedEntryUids?: string[];
81
+ }
82
+
83
+ const base = (mailboxUid: string): string => `/mail/mailboxes/${encodeURIComponent(mailboxUid)}/search-index`;
84
+
85
+ export function getSearchIndexManifest(mailboxUid: string, client?: ApiClient): Promise<SegmentManifest> {
86
+ return withClient(client, `${base(mailboxUid)}/manifest`);
87
+ }
88
+
89
+ /** One page of pending entries: of `week` when given, written at or after `since` when given, after uid `after`. */
90
+ export function listSearchIndexEntries(
91
+ mailboxUid: string,
92
+ options: { week?: string; since?: string; after?: string; limit?: number } = {},
93
+ client?: ApiClient,
94
+ ): Promise<SealedIndexEntryPage> {
95
+ const query = new URLSearchParams();
96
+ for (const [key, value] of Object.entries(options)) {
97
+ if (value !== undefined) {
98
+ query.set(key, String(value));
99
+ }
100
+ }
101
+ const search = query.toString();
102
+ return withClient(client, `${base(mailboxUid)}/entries${search ? `?${search}` : ""}`);
103
+ }
104
+
105
+ /** The sealed file of a week's segment generation. */
106
+ export async function getSearchIndexSegment(
107
+ mailboxUid: string,
108
+ segment: Pick<IndexSegment, "weekId" | "generation" | "origin">,
109
+ client?: ApiClient,
110
+ ): Promise<Uint8Array> {
111
+ const query = `generation=${segment.generation}${segment.origin === "client" ? "&origin=client" : ""}`;
112
+ const blob = await withClientBlob(client, `${base(mailboxUid)}/segments/${encodeURIComponent(segment.weekId)}?${query}`, "Could not download a search index segment.");
113
+ return new Uint8Array(await blob.arrayBuffer());
114
+ }
115
+
116
+ /** Stores a compaction. Rejects with a `409` when the week's segment changed since `upload.expectedGeneration` was read. */
117
+ export function putSearchIndexSegment(mailboxUid: string, weekId: string, upload: SegmentUpload, client?: ApiClient): Promise<IndexSegment> {
118
+ return withClient(client, `${base(mailboxUid)}/segments/${encodeURIComponent(weekId)}`, { method: "PUT", body: JSON.stringify(upload) });
119
+ }