@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
@@ -22,6 +22,7 @@
22
22
  * module today.
23
23
  */
24
24
  import { type KeyVault, type PublicKey, type WrappedPrivateKey, findActivePublicKey, getKeyVault } from "./keyvaultApi.js";
25
+ import type { ApiClient } from "../util/api.js";
25
26
  import { fromBase64 } from "./encoding.js";
26
27
  import { importPrivateKeyPkcs8 } from "./keys.js";
27
28
  import { KeysLockedError, buildAad, openWithKey } from "./masterKey.js";
@@ -34,6 +35,7 @@ import { RECOVERY_KDF_LABEL, deriveFromRecoveryCode } from "./recoveryCode.js";
34
35
  export const MASTER_KEY_AAD_PURPOSE = "master-key";
35
36
  export const SIGNING_PRIVATE_KEY_AAD_PURPOSE = "sign-private-key";
36
37
  export const ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE = "encrypt-private-key";
38
+ export const ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE = "zero-access-private-key";
37
39
 
38
40
  export { KeysLockedError };
39
41
 
@@ -73,6 +75,17 @@ export interface UnlockedKeys {
73
75
  * that). A compromised key is kept because reading one's own mail encrypted to it is still needed. Absent when there
74
76
  * are none. */
75
77
  retainedEncryptionKeys?: RetainedEncryptionKey[];
78
+ /** The mailbox's zero-access private keys (restapi `specs/search.md` §3) - the keys the server encrypts mail at rest,
79
+ * index entries and sealed summaries to - the active one first, then retained ones newest first, at most
80
+ * `MAX_RETAINED_ENCRYPTION_KEYS + 1`. Decryption only. Absent when the mailbox has none (or none opened). */
81
+ zeroAccessKeys?: RetainedEncryptionKey[];
82
+ /** A shared mailbox opened through this user's member key wraps (`sharedMailboxKeys.ts`), not with its own master key: it holds
83
+ * only `zeroAccessKeys`, and `masterKey` is a per-key secret derived from its zero-access private key (what the local search
84
+ * index's key is derived from), never a key that opens the mailbox's vault. */
85
+ shared?: boolean;
86
+ /** A shared session only: the user's own mailboxes whose zero-access keys opened its member key wraps. Locking any of them locks it too -
87
+ * it was opened with their keys, so it doesn't outlive them. */
88
+ openedWith?: string[];
76
89
  }
77
90
 
78
91
  const sessions = new Map<string, UnlockedKeys>();
@@ -146,6 +159,52 @@ export function subscribeKeySession(listener: KeySessionListener): () => void {
146
159
  };
147
160
  }
148
161
 
162
+ /**
163
+ * Makes a zero-access key this device has just enrolled (`zeroAccessKey.ts`'s `provisionZeroAccessKey()`) the active one
164
+ * of `unlocked` - the session object the key's private half was sealed under - so zero-access mail encrypted to it reads
165
+ * without another unlock. Does nothing when `unlocked` is no longer this mailbox's session (it was locked or replaced
166
+ * meanwhile; the next unlock opens the key from the vault anyway).
167
+ */
168
+ export function addZeroAccessKey(mailboxUid: string, unlocked: UnlockedKeys, key: RetainedEncryptionKey): void {
169
+ if (unlocked.destroyed || sessions.get(mailboxUid) !== unlocked) {
170
+ return;
171
+ }
172
+ unlocked.zeroAccessKeys = [key, ...(unlocked.zeroAccessKeys ?? []).filter((k) => k.fingerprint !== key.fingerprint)];
173
+ }
174
+
175
+ /**
176
+ * Stores the keys of a shared mailbox opened through member key wraps (`sharedMailboxKeys.ts`'s `unlockSharedMailbox()`) as that mailbox's
177
+ * session, exactly as an unlock would - tracked for `destroyUnlockedKeys()`, and notified - unless the mailbox was locked since `generation`
178
+ * was taken (`sharedUnlockGeneration()`), when they are destroyed instead and `KeysLockedError` is thrown.
179
+ */
180
+ export function installSharedSession(mailboxUid: string, unlocked: UnlockedKeys, generation: string): void {
181
+ if (lockGeneration(mailboxUid) !== generation) {
182
+ destroyObject(unlocked);
183
+ throw new KeysLockedError();
184
+ }
185
+ unlocked.shared = true;
186
+ sessions.set(mailboxUid, unlocked);
187
+ const objects = issued.get(mailboxUid) ?? new Set<WeakHandle<UnlockedKeys>>();
188
+ objects.add(weakHandle(unlocked));
189
+ issued.set(mailboxUid, objects);
190
+ notify({ mailboxUid, state: "unlocked" });
191
+ }
192
+
193
+ /** The lock generation to pass `installSharedSession()`, taken before a shared unlock starts its requests. */
194
+ export function sharedUnlockGeneration(mailboxUid: string): string {
195
+ return lockGeneration(mailboxUid);
196
+ }
197
+
198
+ /** Every mailbox unlocked this session with its own keys (not a shared one opened through member key wraps). */
199
+ export function personallyUnlockedKeys(): UnlockedKeys[] {
200
+ return personalSessions().map((session) => session.unlocked);
201
+ }
202
+
203
+ /** `personallyUnlockedKeys()` with each mailbox's uid. */
204
+ export function personalSessions(): { mailboxUid: string; unlocked: UnlockedKeys }[] {
205
+ return [...sessions].filter(([, unlocked]) => !unlocked.shared).map(([mailboxUid, unlocked]) => ({ mailboxUid, unlocked }));
206
+ }
207
+
149
208
  /** Reads back a mailbox's already-unlocked keys this session, or `undefined` if it hasn't been
150
209
  * unlocked yet (or was destroyed). Never triggers an unlock itself. */
151
210
  export function getUnlockedKeys(mailboxUid: string): UnlockedKeys | undefined {
@@ -189,6 +248,14 @@ export function destroyUnlockedKeys(mailboxUid?: string): void {
189
248
  sessions.delete(uid);
190
249
  notify({ mailboxUid: uid, state: "locked" });
191
250
  }
251
+ // The shared mailboxes opened with this mailbox's keys go with it.
252
+ if (mailboxUid) {
253
+ for (const [uid, unlocked] of [...sessions]) {
254
+ if (unlocked.shared && unlocked.openedWith?.includes(mailboxUid)) {
255
+ destroyUnlockedKeys(uid);
256
+ }
257
+ }
258
+ }
192
259
  }
193
260
 
194
261
  function destroyObject(unlocked: UnlockedKeys): void {
@@ -201,6 +268,10 @@ function destroyObject(unlocked: UnlockedKeys): void {
201
268
  unlocked.retainedEncryptionKeys.length = 0;
202
269
  delete unlocked.retainedEncryptionKeys;
203
270
  }
271
+ if (unlocked.zeroAccessKeys) {
272
+ unlocked.zeroAccessKeys.length = 0;
273
+ delete unlocked.zeroAccessKeys;
274
+ }
204
275
  }
205
276
 
206
277
  /** Finds the wrapped private key whose fingerprint matches a given published public key. */
@@ -297,6 +368,35 @@ async function openRetainedEncryptionKeys(
297
368
  }
298
369
  }
299
370
 
371
+ /**
372
+ * Opens the mailbox's zero-access private keys into `unlocked.zeroAccessKeys`: the vault's `zeroAccessKeys` that have a
373
+ * wrapped private key, the unrevoked one with the latest `notBefore` first, then the rest newest first, at most
374
+ * `MAX_RETAINED_ENCRYPTION_KEYS + 1`. A key that won't open is listed in `unopenableKeys` and skipped - zero-access mail
375
+ * encrypted to it doesn't decrypt on this device, but nothing else about the unlock depends on it.
376
+ */
377
+ async function openZeroAccessKeys(mailboxUid: string, vault: KeyVault, unlocked: UnlockedKeys, unopenableKeys: string[]): Promise<void> {
378
+ const ordered = [...(vault.zeroAccessKeys ?? [])].sort(
379
+ (a, b) => Number(!!a.revokedAt) - Number(!!b.revokedAt) || b.notBefore - a.notBefore,
380
+ );
381
+ const aad = buildAad(mailboxUid, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE);
382
+ let opened = 0;
383
+ for (const publicKey of ordered) {
384
+ const wrapped = findWrappedPrivateKey(vault, publicKey.fingerprint);
385
+ if (!wrapped || wrapped.useType !== "zero-access") {
386
+ continue;
387
+ }
388
+ try {
389
+ const privateKey = await openPrivateKey(unlocked.masterKey, wrapped, aad, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
390
+ (unlocked.zeroAccessKeys ??= []).push({ fingerprint: publicKey.fingerprint, certDer: fromBase64(publicKey.publicKey), privateKey });
391
+ } catch {
392
+ unopenableKeys.push(publicKey.fingerprint);
393
+ }
394
+ if (++opened === MAX_RETAINED_ENCRYPTION_KEYS + 1) {
395
+ break;
396
+ }
397
+ }
398
+ }
399
+
300
400
  /**
301
401
  * Unlocks a mailbox's key vault with its password unlock method: fetches the vault, derives the
302
402
  * wrapping key from `password` using the *exact* KDF parameters that wrap was created with, unwraps
@@ -315,7 +415,21 @@ async function openRetainedEncryptionKeys(
315
415
  */
316
416
  export async function unlockWithPassword(mailboxUid: string, mailboxKeys: PublicKey[], password: string): Promise<UnlockResult> {
317
417
  const generation = lockGeneration(mailboxUid);
318
- const vault = await getKeyVault(mailboxUid);
418
+ const { vault, masterKey } = await openMasterKeyWithPassword(mailboxUid, password);
419
+ return openSession(mailboxUid, mailboxKeys, vault, masterKey, generation);
420
+ }
421
+
422
+ /**
423
+ * Fetches `mailboxUid`'s vault and opens its master key with the password wrap, touching no session - for an operation that
424
+ * must re-prove the password even while the mailbox is unlocked (`keyExport.ts`). Rejects exactly as `unlockWithPassword()`
425
+ * does on a missing wrap, an unknown KDF or a wrong password. The caller owns the returned master key and must zero it.
426
+ */
427
+ export async function openMasterKeyWithPassword(
428
+ mailboxUid: string,
429
+ password: string,
430
+ client?: ApiClient,
431
+ ): Promise<{ vault: KeyVault; masterKey: Uint8Array }> {
432
+ const vault = await getKeyVault(mailboxUid, client);
319
433
  const passwordWrap = vault.masterKeyWraps.find((w) => w.method === "password");
320
434
  if (!passwordWrap) {
321
435
  throw new Error("This mailbox has no password unlock method enrolled.");
@@ -332,8 +446,7 @@ export async function unlockWithPassword(mailboxUid: string, mailboxKeys: Public
332
446
  { ciphertext: passwordWrap.ciphertext, nonce: passwordWrap.nonce },
333
447
  buildAad(mailboxUid, MASTER_KEY_AAD_PURPOSE),
334
448
  );
335
-
336
- return openSession(mailboxUid, mailboxKeys, vault, masterKey, generation);
449
+ return { vault, masterKey };
337
450
  }
338
451
 
339
452
  /** What `unlockWithRecoveryCode()` resolves with. */
@@ -453,6 +566,7 @@ async function openSession(
453
566
  }
454
567
 
455
568
  await openRetainedEncryptionKeys(mailboxUid, mailboxKeys, vault, unlocked, encryptionPublicKey?.fingerprint, unopenableKeys);
569
+ await openZeroAccessKeys(mailboxUid, vault, unlocked, unopenableKeys);
456
570
  if (lockGeneration(mailboxUid) !== generation) {
457
571
  // Locked (e.g. logout) while this unlock was awaiting - never restore keys after that.
458
572
  throw new KeysLockedError();
@@ -47,7 +47,18 @@ export interface WrappedPrivateKey {
47
47
  /** AEAD algorithm identifier (e.g. `AES-256-GCM`). */
48
48
  algorithm: string;
49
49
  fingerprint: string;
50
- useType: "sign" | "encrypt";
50
+ useType: KeyUseType;
51
+ }
52
+
53
+ /** What a mailbox's own key is used for. `sign`/`encrypt` keys are published (`Mailbox.keys`); `zero-access` keys never are.
54
+ * Mirrors `@rapidmx/restapi`'s `KeyUseType`. */
55
+ export type KeyUseType = "sign" | "encrypt" | "zero-access";
56
+
57
+ /** A mailbox's zero-access key (restapi `specs/search.md` §3): the certificate the server encrypts mail at rest to on
58
+ * arrival. Same shape as `PublicKey`, but never published - only the vault's reader gets it (`KeyVault.zeroAccessKeys`).
59
+ * Mirrors `@rapidmx/restapi`'s `ZeroAccessKey`. */
60
+ export interface ZeroAccessKey extends Omit<PublicKey, "useType" | "issuerCertificate"> {
61
+ useType: "zero-access";
51
62
  }
52
63
 
53
64
  /** One wrapped copy of the mailbox master key (MK), per unlock method. Mirrors `@rapidmx/restapi`'s
@@ -114,6 +125,9 @@ export interface KeyVault {
114
125
  * (`enrollKey()`, `startSignEnrollment()`, `addMasterKeyWrap()`, `rekey()`), so restapi refuses it with `409` if
115
126
  * another device rotated the master key meanwhile. Absent from servers that don't track it yet. */
116
127
  masterKeyGeneration?: number;
128
+ /** The mailbox's zero-access certificates, newest last - returned with the vault because they are never published and
129
+ * an unlock needs them to decrypt zero-access mail. Absent from servers without zero-access encryption. */
130
+ zeroAccessKeys?: ZeroAccessKey[];
117
131
  }
118
132
 
119
133
  /** The optional optimistic check restapi applies to vault writes carrying key material sealed under the master key. */
@@ -179,8 +193,8 @@ export function signingKeyFingerprints(keys: PublicKey[] | undefined, previousKe
179
193
  }
180
194
 
181
195
  export interface EnrollKeyInput extends ExpectedMasterKeyGeneration {
182
- useType: "sign" | "encrypt";
183
- /** PEM-encoded PKCS#10 CSR — required (and only meaningful) for `useType: "encrypt"`; the server
196
+ useType: KeyUseType;
197
+ /** PEM-encoded PKCS#10 CSR — required (and only meaningful) for `useType: "encrypt"` and `"zero-access"`; the server
184
198
  * calls its own internal CA against this CSR. */
185
199
  csr?: string;
186
200
  /** An already-issued PEM certificate — required (and only meaningful) for `useType: "sign"`. */
@@ -508,6 +522,110 @@ export function removeMasterKeyWrap(mailboxUid: string, method: string, methodId
508
522
  });
509
523
  }
510
524
 
525
+ /** One member's copy of a shared mailbox's zero-access private key: base64 CMS of its PKCS#8, to the member's own zero-access certificate. */
526
+ export interface MemberKeyWrap {
527
+ memberUserUid: string;
528
+ recipientFingerprint: string;
529
+ zeroAccessKeyFingerprint: string;
530
+ cms: string;
531
+ createdAt?: number;
532
+ }
533
+
534
+ /** One member in `SharedKeyStatus`. */
535
+ export interface SharedKeyMember {
536
+ userUid: string;
537
+ full: boolean;
538
+ /** Their own zero-access certificate - absent until they first sign in and get one. */
539
+ certificate?: ZeroAccessKey;
540
+ /** The mailbox's zero-access keys they hold a wrap of. */
541
+ wrapped: string[];
542
+ }
543
+
544
+ /** Where a shared mailbox's zero-access key stands with its members - restapi `GET /mail/mailboxes/:id/keyvault/members`. */
545
+ export interface SharedKeyStatus {
546
+ ownerless: boolean;
547
+ /** The caller: the mailbox's owner, one of its members, or neither (an administrator acting through a role). */
548
+ role?: "owner" | "member" | "other";
549
+ activeKeyFingerprint?: string;
550
+ zeroAccessKeys: ZeroAccessKey[];
551
+ members: SharedKeyMember[];
552
+ /** Members with a certificate but no wrap of the active key. */
553
+ pending: string[];
554
+ /** Users holding wraps who are no longer members. */
555
+ departed: string[];
556
+ /** A departed user holds the active key: a new key must be provisioned. */
557
+ rotationPending: boolean;
558
+ /** The caller may change the wraps. */
559
+ manager: boolean;
560
+ /** A mailbox without an owner assigned to an escrow scope: that scope. */
561
+ escrowScopeId?: string;
562
+ /** Its zero-access keys (fingerprints) with no escrow copy for `escrowScopeId` yet. Absent from servers that predate it. */
563
+ escrowPending?: string[];
564
+ }
565
+
566
+ /** An escrow copy of a mailbox-without-an-owner's zero-access private key, as `putZeroAccessEscrowWraps()` sends it: CMS of its PKCS#8 to
567
+ * the escrow scope's certificate (`getEscrowInfo()`). */
568
+ export interface ZeroAccessEscrowWrapInput {
569
+ escrowScopeId: string;
570
+ zeroAccessKeyFingerprint: string;
571
+ /** Base64 DER CMS `EnvelopedData`. */
572
+ cms: string;
573
+ }
574
+
575
+ const vaultPath = (mailboxUid: string): string => `/mail/mailboxes/${encodeURIComponent(mailboxUid)}/keyvault`;
576
+
577
+ export function getSharedKeyStatus(mailboxUid: string, client?: ApiClient): Promise<SharedKeyStatus> {
578
+ return withClient(client, `${vaultPath(mailboxUid)}/members`);
579
+ }
580
+
581
+ /** The caller's own member key wraps of `mailboxUid` (`[]` for its owner and for a member still waiting). */
582
+ export function getMyMemberKeyWraps(mailboxUid: string, client?: ApiClient): Promise<MemberKeyWrap[]> {
583
+ return withClient(client, `${vaultPath(mailboxUid)}/member-wraps`);
584
+ }
585
+
586
+ export function putMemberKeyWraps(mailboxUid: string, wraps: MemberKeyWrap[], client?: ApiClient): Promise<SharedKeyStatus> {
587
+ return withClient(client, `${vaultPath(mailboxUid)}/member-wraps`, { method: "PUT", body: JSON.stringify({ wraps }) });
588
+ }
589
+
590
+ /** Removes every wrap of a user who is no longer a member - the last step of a rotation. */
591
+ export function removeMemberKeyWraps(mailboxUid: string, memberUserUid: string, client?: ApiClient): Promise<SharedKeyStatus> {
592
+ return withClient(client, `${vaultPath(mailboxUid)}/member-wraps/${encodeURIComponent(memberUserUid)}`, { method: "DELETE" });
593
+ }
594
+
595
+ /** Provisions (or rotates) a mailbox-without-an-owner's zero-access key with its member wraps, in one write - and, for a mailbox assigned to
596
+ * an escrow scope, its escrow copy (required then). */
597
+ export function enrollSharedZeroAccessKey(
598
+ mailboxUid: string,
599
+ input: {
600
+ csr: string;
601
+ wraps: Omit<MemberKeyWrap, "zeroAccessKeyFingerprint">[];
602
+ escrowWrap?: Omit<ZeroAccessEscrowWrapInput, "zeroAccessKeyFingerprint">;
603
+ },
604
+ client?: ApiClient,
605
+ ): Promise<SharedKeyStatus> {
606
+ return withClient(client, `${vaultPath(mailboxUid)}/shared-zero-access-key`, { method: "POST", body: JSON.stringify(input) });
607
+ }
608
+
609
+ /** Adds the escrow copies of a mailbox-without-an-owner's zero-access keys its escrow scope is missing (`SharedKeyStatus.escrowPending`). */
610
+ export function putZeroAccessEscrowWraps(mailboxUid: string, wraps: ZeroAccessEscrowWrapInput[], client?: ApiClient): Promise<SharedKeyStatus> {
611
+ return withClient(client, `${vaultPath(mailboxUid)}/escrow-wraps`, { method: "PUT", body: JSON.stringify({ wraps }) });
612
+ }
613
+
614
+ /** One key a "Export keys for Outlook/phone" file held, as `recordKeyExport()` reports it. */
615
+ export interface ExportedKeyRecord {
616
+ kind: "zero-access" | "encrypt" | "sign";
617
+ fingerprint: string;
618
+ }
619
+
620
+ /** Records with restapi that the owner exported these private keys (`key_vault.export` in the audit log). `keyExport.ts`
621
+ * calls it before handing over the file, so an export the server refuses to record isn't handed over. Owner-only. */
622
+ export async function recordKeyExport(mailboxUid: string, keys: ExportedKeyRecord[], client?: ApiClient): Promise<void> {
623
+ await withClient(client, `/mail/mailboxes/${encodeURIComponent(mailboxUid)}/keyvault/exports`, {
624
+ method: "POST",
625
+ body: JSON.stringify({ keys }),
626
+ });
627
+ }
628
+
511
629
  export interface RekeyInput extends ExpectedMasterKeyGeneration {
512
630
  wrappedKeys: WrappedPrivateKey[];
513
631
  /** Every wrap of the new master key. For a mailbox assigned an escrow scope, this must include a fresh escrow wrap
@@ -657,6 +775,10 @@ export interface EncryptionPolicy {
657
775
  encryptSameOrg: PolicyState;
658
776
  encryptFederated: PolicyState;
659
777
  encryptExternal: PolicyState;
778
+ /** "Allow zero-access encryption": whether mailboxes may store mail encrypted at rest. Always present from a server that
779
+ * supports zero-access encryption (its stored value or the server default); absent from one that doesn't, which a
780
+ * caller treats as not allowed. */
781
+ allowZeroAccess?: boolean;
660
782
  }
661
783
 
662
784
  /** The system-wide encryption policy (readable by any authenticated user, used to decide what encryption
@@ -37,9 +37,23 @@
37
37
  * `untrusted_signer`. Previously pinned keys count as pins (see `signingKeyFingerprints()`), so mail signed before an
38
38
  * accepted or automatic rotation still verifies.
39
39
  */
40
- import { DisplayBody, MimeAttachment, MimeHeaderField, decodeHeaderText, extractAddresses, parseMimeEntity, parseParameterizedHeader, plainTextToHtml } from "./mime.js";
41
- import { toBase64 } from "./encoding.js";
42
- import { computeCertFingerprint, extractCertificateEmails } from "./smime.js";
40
+ import {
41
+ DisplayBody,
42
+ MimeAttachment,
43
+ MimeHeaderField,
44
+ bytesToBinaryString,
45
+ decodeBase64Text,
46
+ decodeHeaderText,
47
+ extractAddresses,
48
+ extractAttachments,
49
+ extractCalendarText,
50
+ extractDisplayBody,
51
+ parseMimeEntity,
52
+ parseParameterizedHeader,
53
+ plainTextToHtml,
54
+ } from "./mime.js";
55
+ import { fromBase64, toBase64 } from "./encoding.js";
56
+ import { type DecryptionKey, computeCertFingerprint, decryptEnvelopedDataWithKeys, extractCertificateEmails } from "./smime.js";
43
57
  import { ComparableOuterHeaders, ProtectedHeaders, parseEncryptedMessageWithKeys, parseSignedOnlyMessage } from "./smimeMessage.js";
44
58
  import { type PublicKey, isTrustedForVerification } from "./keyvaultApi.js";
45
59
  import { KeysLockedError } from "./masterKey.js";
@@ -67,6 +81,7 @@ export type MessageSecurityState =
67
81
  | "encrypted_unverified_signer"
68
82
  | "signature_failed"
69
83
  | "verified_at_first_open"
84
+ | "protected_at_rest"
70
85
  | "unprotected";
71
86
 
72
87
  /** Why a message is `"signature_failed"`. `invalid_signature`: the CMS signature itself is malformed or
@@ -113,6 +128,13 @@ export interface MessageSecurityResult {
113
128
  * since-rotated key, non-AEAD content encryption, or corrupt/foreign data) - the caller should keep
114
129
  * showing whatever `GET /:id/content` already produced alongside this explanation. */
115
130
  decryptError?: string;
131
+ /** Set when the stored message was sealed at rest by the server (restapi `specs/search.md` §3) and opened here with the mailbox's
132
+ * zero-access key. `state` then describes the original message inside: `protected_at_rest` when it arrived unprotected, or its own
133
+ * signed/encrypted state otherwise. */
134
+ zeroAccess?: boolean;
135
+ /** With `zeroAccess`: the original's calendar file (`extractCalendarText()`), which the server can't read from the sealed copy - the
136
+ * client sends it with the invitation endpoints. Absent when the message carries none. */
137
+ calendar?: string;
116
138
  /** See `ParsedEncryptedMessage.headerTamperDetected`'s own doc comment - a deliberately separate,
117
139
  * orthogonal signal from `state`, since RFC 9788's `HP-Outer` is written on every encrypted message
118
140
  * regardless of whether it's also signed. `undefined` for anything that isn't an encrypted message
@@ -347,6 +369,61 @@ export interface MessageSecurityKeys {
347
369
  encryptionCertDer?: Uint8Array;
348
370
  retainedEncryptionKeys?: { certDer: Uint8Array; privateKey: CryptoKey }[];
349
371
  signingFingerprint?: string;
372
+ /** The mailbox's zero-access keys (`keySession.ts`'s `UnlockedKeys.zeroAccessKeys`) - what opens a message the server sealed at
373
+ * rest on arrival. */
374
+ zeroAccessKeys?: { certDer: Uint8Array; privateKey: CryptoKey }[];
375
+ }
376
+
377
+ /**
378
+ * The subject and preview of a zero-access message (`Message.zeroAccess.sealedSummary`, base64 CMS of JSON `{ subject, preview }`),
379
+ * decrypted with the mailbox's zero-access keys - a few hundred bytes, so a message list shows real subjects without downloading
380
+ * each message. `undefined` when no key opens it or it isn't a summary.
381
+ */
382
+ export async function openZeroAccessSummary(
383
+ sealedSummary: string,
384
+ keys: DecryptionKey[] | undefined,
385
+ ): Promise<{ subject: string; preview: string } | undefined> {
386
+ if (!keys?.length) {
387
+ return undefined;
388
+ }
389
+ try {
390
+ const json: unknown = JSON.parse(new TextDecoder().decode(await decryptEnvelopedDataWithKeys(fromBase64(sealedSummary), keys)));
391
+ const { subject, preview } = (json ?? {}) as Record<string, unknown>;
392
+ return typeof subject === "string" && typeof preview === "string" ? { subject, preview } : undefined;
393
+ } catch {
394
+ return undefined;
395
+ }
396
+ }
397
+
398
+ /**
399
+ * The original MIME (as a binary string) of a stored message the server sealed at rest, opened with `unlocked.zeroAccessKeys` -
400
+ * `undefined` when it isn't a sealed message or no key opens it. For what needs the original itself, e.g. reporting it to the spam
401
+ * filter (`reportMessage()`'s `rawMessage`).
402
+ */
403
+ export async function openZeroAccessMessage(rawMime: string, unlocked: MessageSecurityKeys | undefined): Promise<string | undefined> {
404
+ const { headers, body } = parseMimeEntity(rawMime);
405
+ const rawContentType = headers["content-type"];
406
+ if (!unlocked?.zeroAccessKeys?.length || !rawContentType || !isEncryptedContentType(parseParameterizedHeader(rawContentType))) {
407
+ return undefined;
408
+ }
409
+ return openZeroAccess(body, unlocked.zeroAccessKeys);
410
+ }
411
+
412
+ /**
413
+ * The original MIME of a message the server sealed at rest (restapi `specs/search.md` §3) - the stored S/MIME `enveloped-data`
414
+ * message's content, decrypted with one of `keys` - or `undefined` when none of them opens it. Only the server ever encrypts to a
415
+ * zero-access key (it is never published), so a message that opens with one was sealed at rest, never sent end-to-end.
416
+ */
417
+ async function openZeroAccess(body: string, keys: DecryptionKey[]): Promise<string | undefined> {
418
+ const der = decodeBase64Text(body);
419
+ if (!der) {
420
+ return undefined;
421
+ }
422
+ try {
423
+ return bytesToBinaryString(await decryptEnvelopedDataWithKeys(der, keys));
424
+ } catch {
425
+ return undefined;
426
+ }
350
427
  }
351
428
 
352
429
  /** `evaluateMessageSecurity()`'s result plus what the seal-aware wrapper needs: which kind of protected message it was,
@@ -362,6 +439,7 @@ async function evaluateLive(
362
439
  unlocked: MessageSecurityKeys | undefined,
363
440
  pinnedSignerFingerprints: string | string[] | undefined,
364
441
  readerAddress: string | undefined,
442
+ sealedAtRest = false,
365
443
  ): Promise<LiveEvaluation> {
366
444
  const { headers, body, fields } = parseMimeEntity(rawMime);
367
445
  const rawContentType = headers["content-type"];
@@ -452,6 +530,35 @@ async function evaluateLive(
452
530
  };
453
531
  }
454
532
 
533
+ // A message the server sealed at rest: evaluate the original it holds - which may itself be signed or end-to-end encrypted -
534
+ // and report one that was unprotected when it arrived as `protected_at_rest`, with its content.
535
+ if (isEncryptedContentType(contentType) && !sealedAtRest && unlocked?.zeroAccessKeys?.length) {
536
+ const original = await openZeroAccess(body, unlocked.zeroAccessKeys);
537
+ if (original !== undefined) {
538
+ const inner = await evaluateLive(original, unlocked, pinnedSignerFingerprints, readerAddress, true);
539
+ const entity = parseMimeEntity(original);
540
+ const subject = entity.headers["subject"];
541
+ const calendarText = extractCalendarText(entity);
542
+ const calendar = calendarText !== undefined ? { calendar: calendarText } : {};
543
+ if (inner.result.state !== "unprotected") {
544
+ // The stored message's own Subject is the placeholder: the original's stands in when the inner result has none.
545
+ const fallbackSubject = inner.result.subject === undefined && subject !== undefined ? { subject: decodeHeaderText(subject) } : {};
546
+ return { ...inner, result: { ...inner.result, ...fallbackSubject, ...calendar, zeroAccess: true } };
547
+ }
548
+ return {
549
+ result: {
550
+ state: "protected_at_rest",
551
+ zeroAccess: true,
552
+ ...calendar,
553
+ ...renderDisplayBody(extractDisplayBody(entity)),
554
+ ...(subject !== undefined ? { subject: decodeHeaderText(subject) } : {}),
555
+ attachments: extractAttachments(entity),
556
+ },
557
+ kind: "other",
558
+ };
559
+ }
560
+ }
561
+
455
562
  if (isEncryptedContentType(contentType)) {
456
563
  // The active key first, then the retained older ones (see `keySession.ts`'s `retainedEncryptionKeys`), so mail
457
564
  // encrypted to a key since replaced still opens. Only ever used to decrypt here - never to encrypt or sign.
@@ -541,6 +541,32 @@ export function extractAttachments(entity: MimeEntity, depth = 0): MimeAttachmen
541
541
  ];
542
542
  }
543
543
 
544
+ /**
545
+ * The text of the first calendar file in `entity` (descending into multipart children, bounded depth): a `text/calendar` or
546
+ * `application/ics` leaf, whether a body alternative (as Outlook sends an invitation) or an attached `.ics`. `undefined` when there is none.
547
+ */
548
+ export function extractCalendarText(entity: MimeEntity, depth = 0): string | undefined {
549
+ const contentType = parseParameterizedHeader(entity.headers["content-type"] ?? "text/plain");
550
+ if (contentType.value.startsWith("multipart/")) {
551
+ const boundary = contentType.params["boundary"];
552
+ if (!boundary || depth >= MAX_MULTIPART_DEPTH) {
553
+ return undefined;
554
+ }
555
+ for (const part of splitMultipart(entity.body, boundary)) {
556
+ const found = extractCalendarText(parseMimeEntity(part), depth + 1);
557
+ if (found !== undefined) {
558
+ return found;
559
+ }
560
+ }
561
+ return undefined;
562
+ }
563
+ if (contentType.value !== "text/calendar" && contentType.value !== "application/ics") {
564
+ return undefined;
565
+ }
566
+ const bytes = decodeBodyBytes(entity);
567
+ return bytes ? new TextDecoder().decode(bytes) : undefined;
568
+ }
569
+
544
570
  /** HTML-escapes plain text and wraps it in a whitespace-preserving `<pre>`, so a `text/plain` body can be
545
571
  * rendered through the same HTML path without ever being interpreted as markup. */
546
572
  export function plainTextToHtml(text: string): string {