@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
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { ApiClient, ApiRequestError } from "../util/api.js";
12
12
  import { ListParams } from "../util/apiQuery.js";
13
- import type { EncryptionPreference, PublicKey } from "../crypto/keyvaultApi.js";
13
+ import type { EncryptionPreference, PublicKey, ZeroAccessKey } from "../crypto/keyvaultApi.js";
14
14
  import type { ResolvedPrincipal } from "./mailboxAccessApi.js";
15
15
  export type { ListParams };
16
16
  export type { EncryptionPreference, PublicKey };
@@ -95,6 +95,12 @@ export interface Mailbox {
95
95
  * per the spec's own `PublicKey` doc comment. Absent/empty means no keys enrolled yet
96
96
  * (`KeyEnrollmentGate` handles that state). */
97
97
  keys?: PublicKey[];
98
+ /** The mailbox's zero-access certificates, managed by the server (never written by a client). Read them from
99
+ * `KeyVault.zeroAccessKeys` to unlock; here only so a settings page can tell whether one is enrolled. */
100
+ zeroAccessKeys?: ZeroAccessKey[];
101
+ /** "Enable zero-access encryption": unset means on. Only the mailbox's owner can change it - restapi drops anybody
102
+ * else's value. Has no effect while the server's `EncryptionPolicy.allowZeroAccess` is off. */
103
+ zeroAccessEnabled?: boolean;
98
104
  /** How the caller reaches this mailbox: `"owner"` (theirs) or `"delegate"` (shared with them). Set by the plain list and read of
99
105
  * mailboxes - not in an administration-scope answer - so a client can label the shared ones; see `isSharedWithMe()`. */
100
106
  accessRole?: "owner" | "delegate";
@@ -215,6 +221,8 @@ export interface UpdateMailboxInput {
215
221
  autoSendReceiptsInternal?: boolean;
216
222
  autoSendReceiptsFederated?: boolean;
217
223
  autoSendReceiptsExternal?: boolean;
224
+ /** Owner-only server-side (anybody else's value is dropped) - see `Mailbox.zeroAccessEnabled`. */
225
+ zeroAccessEnabled?: boolean;
218
226
  /** Trusted-caller-only server-side. `null` (or `""`) unassigns the mailbox's escrow scope; the
219
227
  * referenced `EscrowScope` must exist otherwise. See `Mailbox.escrowScopeId`. */
220
228
  escrowScopeId?: string | null;
@@ -424,6 +432,13 @@ export interface Message {
424
432
  * stored body, not from a flag this API exposes. Always present on a real `Message` (defaults
425
433
  * `false` server-side), same optional-for-old-fixtures caveat as `Mailbox.oofEnabled`. */
426
434
  encrypted?: boolean;
435
+ /** Set when the server sealed this message at rest on arrival (zero-access, restapi `specs/search.md` §3): its stored body is an
436
+ * S/MIME message only the mailbox's zero-access key opens, `subject` is `[...]` and `bodyPreview` empty - the real ones are in
437
+ * `sealedSummary`. */
438
+ zeroAccess?: {
439
+ keyFingerprint: string;
440
+ sealedSummary: string;
441
+ };
427
442
  /** The raw RFC 2369/8058 `List-Unsubscribe` header value, if present — computed server-side by
428
443
  * `@rapidmx/restapi`'s `ScanPipeline` from the stored MIME, the strongest single bulk-mail signal it
429
444
  * captures. Used client-side to detect list traffic when replying, per
@@ -560,7 +575,7 @@ export type MessageReportKind = "junk" | "phishing" | "not_junk";
560
575
  /** Why the server did not teach its spam filter from a report: `encrypted` (it has no plaintext to teach), `disabled` (learning is switched off),
561
576
  * `unsupported` (no spam engine, or one that cannot learn), `too_large` (over the size it reads) or `failed` (the engine could not be reached,
562
577
  * refused, or the message could not be read). The report itself still happened. */
563
- export type MessageLearnSkipped = "encrypted" | "disabled" | "unsupported" | "too_large" | "failed";
578
+ export type MessageLearnSkipped = "encrypted" | "zero_access" | "disabled" | "unsupported" | "too_large" | "failed";
564
579
  /** What `reportMessage()` answers. */
565
580
  export interface MessageReportResult {
566
581
  uid: string;
@@ -581,6 +596,9 @@ export interface ReportMessageOptions {
581
596
  /** 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,
582
597
  * before anything is done). */
583
598
  alwaysTrustSender?: boolean;
599
+ /** For a zero-access message only (else a 400): its original source, base64-encoded, decrypted here - the server holds only the sealed
600
+ * copy, so this is what its spam filter learns from (never stored). Without it the server skips learning (`learnSkipped: "zero_access"`). */
601
+ rawMessage?: string;
584
602
  }
585
603
  /**
586
604
  * Reports a message as junk, phishing or not junk - `POST /mail/messages/:uid/report`. The SERVER moves it (`junk` and `phishing` to the mailbox's Junk Email,
@@ -708,7 +726,15 @@ export interface AssembleDraftInput {
708
726
  bcc?: ComposeRecipientInput[];
709
727
  subject?: string;
710
728
  html: string;
729
+ /** See `AssembleIntent`. */
730
+ intent?: AssembleIntent;
711
731
  }
732
+ /**
733
+ * Why a draft is being assembled (server `ComposeIntent`). `"save"`, the default, keeps an edit: in a zero-access mailbox the server
734
+ * stores it sealed, which it can't send. `"send"` is the assemble right before `sendMessage()`/`queueMessageSend()`: stored readable
735
+ * until it is relayed, then sealed as the Sent Items copy.
736
+ */
737
+ export type AssembleIntent = "save" | "send";
712
738
  /** What a draft records about the thread it belongs to - see `createDraft()`. */
713
739
  export interface DraftThreading {
714
740
  /** The `messageId` of the message being replied to (`Message.inReplyTo`). */
@@ -774,6 +800,8 @@ export interface AssembleDraftRawInput {
774
800
  * `crypto/smimeMessage.ts`'s `buildSignedOnlyMessage()`/`buildEncryptedMessage()`, combined with
775
801
  * the outer envelope headers (`From`/`To`/`Cc`/`Date`/`Message-ID`/`MIME-Version`) by the caller. */
776
802
  rawMime: string;
803
+ /** See `AssembleIntent`. */
804
+ intent?: AssembleIntent;
777
805
  }
778
806
  /**
779
807
  * Stores an already-signed/encrypted draft's raw MIME source as its `bodyBlobKey`, completely
@@ -274,7 +274,11 @@ export function moveMessage(message, folderUid, client) {
274
274
  export function reportMessage(uid, kind, options = {}, client) {
275
275
  return withClient(client, `/mail/messages/${encodeURIComponent(uid)}/report`, {
276
276
  method: "POST",
277
- body: JSON.stringify({ kind, ...(options.alwaysTrustSender ? { alwaysTrustSender: true } : {}) }),
277
+ body: JSON.stringify({
278
+ kind,
279
+ ...(options.alwaysTrustSender ? { alwaysTrustSender: true } : {}),
280
+ ...(options.rawMessage ? { rawMessage: options.rawMessage } : {}),
281
+ }),
278
282
  });
279
283
  }
280
284
  /**
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Typed wrappers over `@rapidmx/restapi`'s zero-access search index (`/mail/mailboxes/:id/search-index`, `specs/search.md` §7-8): the
3
+ * sealed index entries the server writes at delivery and the weekly segments clients compact them into. Everything here is
4
+ * ciphertext encrypted to the mailbox's zero-access key - `apps/shared/search/zeroAccessIndexSync.ts` decrypts and imports it.
5
+ */
6
+ import { type ApiClient } from "../util/api.js";
7
+ export type SegmentOrigin = "zero-access" | "client";
8
+ /** A compacted segment as the manifest lists it. */
9
+ export interface IndexSegment {
10
+ weekId: string;
11
+ generation: number;
12
+ origin: SegmentOrigin;
13
+ bytes: number;
14
+ etag: string;
15
+ schemaVersion: number;
16
+ keyFingerprint: string;
17
+ entryCount: number;
18
+ }
19
+ /** A week's entries not yet compacted. */
20
+ export interface IndexWeekEntries {
21
+ weekId: string;
22
+ pendingEntries: number;
23
+ latestEntryAt: string;
24
+ /** The week has closed and its grace period has passed: a client may compact it. */
25
+ compactable: boolean;
26
+ }
27
+ export interface SegmentManifest {
28
+ mailboxUid: string;
29
+ segments: IndexSegment[];
30
+ weeks: IndexWeekEntries[];
31
+ }
32
+ /** One pending entry: `sealed` is base64 CMS `EnvelopedData` of a gzipped JSON `ZeroAccessIndexDocument`. */
33
+ export interface SealedIndexEntry {
34
+ uid: string;
35
+ weekId: string;
36
+ messageUid: string;
37
+ createdAt: string;
38
+ keyFingerprint: string;
39
+ sealed: string;
40
+ }
41
+ export interface SealedIndexEntryPage {
42
+ entries: SealedIndexEntry[];
43
+ next?: string;
44
+ }
45
+ /** What a sealed entry (and each document of a segment) decrypts to - restapi's `ZeroAccessIndexDocument`. */
46
+ export interface ZeroAccessIndexDocument {
47
+ v: 1;
48
+ messageUid: string;
49
+ subject: string;
50
+ body: string;
51
+ attachmentText: string[];
52
+ attachmentNames: string[];
53
+ deferredAttachments: string[];
54
+ from: string;
55
+ to: string[];
56
+ cc: string[];
57
+ participants: string[];
58
+ dateForSort: number;
59
+ }
60
+ /** `PUT .../segments/:weekId`. */
61
+ export interface SegmentUpload {
62
+ expectedGeneration: number;
63
+ /** Base64 of the sealed file. */
64
+ segment: string;
65
+ keyFingerprint: string;
66
+ schemaVersion: number;
67
+ origin?: SegmentOrigin;
68
+ foldedEntryUids?: string[];
69
+ }
70
+ export declare function getSearchIndexManifest(mailboxUid: string, client?: ApiClient): Promise<SegmentManifest>;
71
+ /** One page of pending entries: of `week` when given, written at or after `since` when given, after uid `after`. */
72
+ export declare function listSearchIndexEntries(mailboxUid: string, options?: {
73
+ week?: string;
74
+ since?: string;
75
+ after?: string;
76
+ limit?: number;
77
+ }, client?: ApiClient): Promise<SealedIndexEntryPage>;
78
+ /** The sealed file of a week's segment generation. */
79
+ export declare function getSearchIndexSegment(mailboxUid: string, segment: Pick<IndexSegment, "weekId" | "generation" | "origin">, client?: ApiClient): Promise<Uint8Array>;
80
+ /** Stores a compaction. Rejects with a `409` when the week's segment changed since `upload.expectedGeneration` was read. */
81
+ export declare function putSearchIndexSegment(mailboxUid: string, weekId: string, upload: SegmentUpload, client?: ApiClient): Promise<IndexSegment>;
@@ -0,0 +1,35 @@
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 { withClient, withClientBlob } from "../util/api.js";
11
+ const base = (mailboxUid) => `/mail/mailboxes/${encodeURIComponent(mailboxUid)}/search-index`;
12
+ export function getSearchIndexManifest(mailboxUid, client) {
13
+ return withClient(client, `${base(mailboxUid)}/manifest`);
14
+ }
15
+ /** One page of pending entries: of `week` when given, written at or after `since` when given, after uid `after`. */
16
+ export function listSearchIndexEntries(mailboxUid, options = {}, client) {
17
+ const query = new URLSearchParams();
18
+ for (const [key, value] of Object.entries(options)) {
19
+ if (value !== undefined) {
20
+ query.set(key, String(value));
21
+ }
22
+ }
23
+ const search = query.toString();
24
+ return withClient(client, `${base(mailboxUid)}/entries${search ? `?${search}` : ""}`);
25
+ }
26
+ /** The sealed file of a week's segment generation. */
27
+ export async function getSearchIndexSegment(mailboxUid, segment, client) {
28
+ const query = `generation=${segment.generation}${segment.origin === "client" ? "&origin=client" : ""}`;
29
+ const blob = await withClientBlob(client, `${base(mailboxUid)}/segments/${encodeURIComponent(segment.weekId)}?${query}`, "Could not download a search index segment.");
30
+ return new Uint8Array(await blob.arrayBuffer());
31
+ }
32
+ /** Stores a compaction. Rejects with a `409` when the week's segment changed since `upload.expectedGeneration` was read. */
33
+ export function putSearchIndexSegment(mailboxUid, weekId, upload, client) {
34
+ return withClient(client, `${base(mailboxUid)}/segments/${encodeURIComponent(weekId)}`, { method: "PUT", body: JSON.stringify(upload) });
35
+ }
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { ApiClient, withClient } from "../util/api.js";
16
16
  import { ListParams, RequestListParams, buildQuery } from "../util/apiQuery.js";
17
+ import type { PublicKey, WrappedPrivateKey } from "../crypto/keyvaultApi.js";
17
18
 
18
19
  export type { ListParams, RequestListParams };
19
20
 
@@ -65,6 +66,11 @@ export interface MasterKeyWrap {
65
66
  * tooling, per `specs/end-to-end_encryption.md`'s "this server never holds the scope's private key". */
66
67
  export interface EscrowAccessMaterial {
67
68
  masterKeyWraps: MasterKeyWrap[];
69
+ /** The mailbox's wrapped private keys that read mail (zero-access and encryption keys, never signing), sealed under its master key. Absent
70
+ * from a server that predates releasing them. */
71
+ wrappedKeys?: WrappedPrivateKey[];
72
+ /** The certificate of each of `wrappedKeys`, matched by fingerprint. */
73
+ certificates?: PublicKey[];
68
74
  }
69
75
 
70
76
  /** `client`, given by every function below, is an explicit `ApiClient` from `createApiClient()` (e.g. one
@@ -135,6 +135,9 @@ export interface CalendarEvent {
135
135
  * `mailto` only). Still to be rendered through `sanitizeEventDescriptionHtml()` (`eventDescription.ts`), never trusted as it stands. `null` or absent:
136
136
  * the description, if any, is plain text. */
137
137
  descriptionHtml?: string | null;
138
+ /** Response-only. An event made from an invitation that reached a zero-access mailbox: its title, location and description are only
139
+ * sealed to the mailbox's zero-access key (`title` is `[...]`). `apps/shared/calendar/sealedEvents.ts` opens them. */
140
+ sealedContent?: { keyFingerprint: string; sealed: string };
138
141
  /** Who may see the event's details - absent (or `null`, on a row stored before this existed) reads as `"default"`; see `visibilityOf()`. */
139
142
  visibility?: EventVisibility | null;
140
143
  /** Whether the guests may ask for the event to change (default `false`). Absent or `null` reads as the default; see `guestPermissionsOf()`. */
@@ -10,6 +10,14 @@
10
10
 
11
11
  import { ApiClient, ApiRequestError, withClient } from "../util/api.js";
12
12
  import type { EventVisibility, GuestPermissions } from "./calendarApi.js";
13
+ import { sealedInviteCalendar } from "./sealedInvites.js";
14
+
15
+ /** The calendar file to send for `messageUid` - a zero-access message this device decrypted (`sealedInvites.ts`) - as the request body's
16
+ * `ics`, or nothing for a message the server reads itself. */
17
+ function sealedSource(messageUid: string): { ics?: string } {
18
+ const ics = sealedInviteCalendar(messageUid);
19
+ return ics !== undefined ? { ics } : {};
20
+ }
13
21
 
14
22
  /** What the reader can answer an invitation with. */
15
23
  export type InviteResponse = "accepted" | "tentative" | "declined";
@@ -104,8 +112,15 @@ export interface MessageInvite {
104
112
  * common, expected answer for a message that merely has an attachment - and rejects for any other failure.
105
113
  */
106
114
  export async function getMessageInvite(messageUid: string, client?: ApiClient): Promise<MessageInvite | null> {
115
+ const source = sealedSource(messageUid);
107
116
  try {
108
- return await withClient<MessageInvite>(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}`);
117
+ // A zero-access message's invitation is read from the calendar file decrypted here (`POST .../view`).
118
+ return source.ics !== undefined
119
+ ? await withClient<MessageInvite>(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/view`, {
120
+ method: "POST",
121
+ body: JSON.stringify(source),
122
+ })
123
+ : await withClient<MessageInvite>(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}`);
109
124
  } catch (err) {
110
125
  if (err instanceof ApiRequestError && err.status === 404) {
111
126
  return null;
@@ -122,13 +137,17 @@ export async function getMessageInvite(messageUid: string, client?: ApiClient):
122
137
  export function respondToMessageInvite(messageUid: string, responseStatus: InviteResponse, client?: ApiClient): Promise<MessageInvite> {
123
138
  return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/respond`, {
124
139
  method: "POST",
125
- body: JSON.stringify({ responseStatus }),
140
+ body: JSON.stringify({ responseStatus, ...sealedSource(messageUid) }),
126
141
  });
127
142
  }
128
143
 
129
144
  /** Takes a canceled meeting off the reader's calendar. Resolves with the invitation as it now stands. */
130
145
  export function removeMessageInvite(messageUid: string, client?: ApiClient): Promise<MessageInvite> {
131
- return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/remove`, { method: "POST" });
146
+ const source = sealedSource(messageUid);
147
+ return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/remove`, {
148
+ method: "POST",
149
+ ...(source.ics !== undefined ? { body: JSON.stringify(source) } : {}),
150
+ });
132
151
  }
133
152
 
134
153
  /** A different time to suggest to the organizer. */
@@ -144,11 +163,15 @@ export interface ProposedTime {
144
163
  export function proposeNewTime(messageUid: string, proposal: ProposedTime, client?: ApiClient): Promise<MessageInvite> {
145
164
  return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/propose`, {
146
165
  method: "POST",
147
- body: JSON.stringify(proposal),
166
+ body: JSON.stringify({ ...proposal, ...sealedSource(messageUid) }),
148
167
  });
149
168
  }
150
169
 
151
170
  /** Accepts an attendee's proposed time (a `COUNTER`) for a meeting the reader organizes: the server moves the event and sends the attendees the update. Resolves with the invitation as it now stands. */
152
171
  export function acceptProposal(messageUid: string, client?: ApiClient): Promise<MessageInvite> {
153
- return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/accept-proposal`, { method: "POST" });
172
+ const source = sealedSource(messageUid);
173
+ return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/accept-proposal`, {
174
+ method: "POST",
175
+ ...(source.ics !== undefined ? { body: JSON.stringify(source) } : {}),
176
+ });
154
177
  }
@@ -0,0 +1,33 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The calendar files of zero-access messages, as decrypted on this device (`MessageSecurityResult.calendar`). The server holds only
7
+ * the sealed copy of such a message, so every invitation call for it (`inviteApi.ts`) sends the file along. Kept in memory for the
8
+ * messages read most recently, and cleared on sign-out (`clearSealedInvites()`).
9
+ */
10
+
11
+ /** How many messages' calendar files are kept. */
12
+ const MAX_SEALED_INVITES = 100;
13
+
14
+ const calendars: Map<string, string> = new Map();
15
+
16
+ /** Remembers the decrypted calendar file of zero-access message `messageUid`. */
17
+ export function rememberSealedInvite(messageUid: string, ics: string): void {
18
+ calendars.delete(messageUid);
19
+ calendars.set(messageUid, ics);
20
+ while (calendars.size > MAX_SEALED_INVITES) {
21
+ calendars.delete(calendars.keys().next().value!);
22
+ }
23
+ }
24
+
25
+ /** The decrypted calendar file of zero-access message `messageUid`, when this device has read it. */
26
+ export function sealedInviteCalendar(messageUid: string): string | undefined {
27
+ return calendars.get(messageUid);
28
+ }
29
+
30
+ /** Forgets every remembered calendar file (sign-out). */
31
+ export function clearSealedInvites(): void {
32
+ calendars.clear();
33
+ }
@@ -0,0 +1,229 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * "Export keys for Outlook/phone" (restapi `specs/search.md` §3, *Clients that don't run RapidMX*): a passphrase-protected
7
+ * PKCS#12 file holding the private keys a mail client needs to read this mailbox's mail on its own - every zero-access key
8
+ * (mail at rest is S/MIME `enveloped-data` to these) and every end-to-end encryption key (current and retained), plus, only
9
+ * when asked, the active signing key. Built entirely here from a fresh master-password unlock: the session's own private
10
+ * keys are non-extractable, and the server never sees the file or the passphrase. restapi only records that an export
11
+ * happened (`key_vault.export`), with the fingerprints exported and nothing else.
12
+ */
13
+ import "reflect-metadata";
14
+ import * as asn1js from "asn1js";
15
+ import * as pkijs from "pkijs";
16
+ import type { ApiClient } from "../util/api.js";
17
+ import { fromBase64 } from "./encoding.js";
18
+ import { type KeyVault, type PublicKey, findActivePublicKey, recordKeyExport, type WrappedPrivateKey } from "./keyvaultApi.js";
19
+ import {
20
+ ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE,
21
+ MAX_RETAINED_ENCRYPTION_KEYS,
22
+ openMasterKeyWithPassword,
23
+ SIGNING_PRIVATE_KEY_AAD_PURPOSE,
24
+ ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE,
25
+ } from "./keySession.js";
26
+ import { buildAad, openWithKey } from "./masterKey.js";
27
+
28
+ /** PBKDF2 iterations for the shrouded key bags and the PKCS#12 MAC. Windows, macOS and iOS all accept this. */
29
+ export const KEY_EXPORT_ITERATIONS = 100_000;
30
+
31
+ /** The shortest export passphrase accepted. The file grants read access to all of this mailbox's mail. */
32
+ export const MIN_EXPORT_PASSPHRASE_LENGTH = 12;
33
+
34
+ const PKCS8_SHROUDED_KEY_BAG = "1.2.840.113549.1.12.10.1.2";
35
+ const CERT_BAG = "1.2.840.113549.1.12.10.1.3";
36
+ const X509_CERTIFICATE = "1.2.840.113549.1.9.22.1";
37
+ const FRIENDLY_NAME = "1.2.840.113549.1.9.20";
38
+ const LOCAL_KEY_ID = "1.2.840.113549.1.9.21";
39
+
40
+ /** The shrouded key bags' cipher. pkijs's type wants an `iv`, but it generates a fresh one for every bag and ignores this one. */
41
+ const AES_256_CBC = { name: "AES-CBC", length: 256, iv: new Uint8Array(16) } as pkijs.ContentEncryptionAlgorithm;
42
+
43
+ /** Which key a bag holds, for its friendly name and the export's audit record. */
44
+ export type ExportedKeyKind = "zero-access" | "encrypt" | "sign";
45
+
46
+ /** What `exportKeysAsPkcs12()` takes. */
47
+ export interface KeyExportInput {
48
+ mailboxUid: string;
49
+ /** The mailbox's address, used in each key's friendly name. */
50
+ mailboxAddress: string;
51
+ /** The mailbox's published keys (`Mailbox.keys`): the encryption and signing certificates. */
52
+ mailboxKeys: PublicKey[];
53
+ /** The master password, for the fresh unlock every export needs. */
54
+ masterPassword: string;
55
+ /** Protects the PKCS#12 file. At least `MIN_EXPORT_PASSPHRASE_LENGTH` characters. */
56
+ exportPassphrase: string;
57
+ /** Whether to include the active signing key, for clients that will sign. Off unless the user opts in. */
58
+ includeSigningKey?: boolean;
59
+ }
60
+
61
+ /** One key in the export. */
62
+ export interface ExportedKey {
63
+ kind: ExportedKeyKind;
64
+ fingerprint: string;
65
+ }
66
+
67
+ /** What `exportKeysAsPkcs12()` resolves with. */
68
+ export interface KeyExport {
69
+ /** The DER-encoded PKCS#12 (`.p12`) file. */
70
+ pkcs12: Uint8Array;
71
+ /** The keys it holds, zero-access keys first. */
72
+ keys: ExportedKey[];
73
+ }
74
+
75
+ /** One private key opened from the vault, before it goes into a bag. Its `pkcs8` is zeroed once the file is built. */
76
+ interface OpenedKey extends ExportedKey {
77
+ certDer: Uint8Array;
78
+ pkcs8: Uint8Array;
79
+ }
80
+
81
+ /** The export has nothing to hold - the vault has no zero-access or encryption private key this password opens. */
82
+ export class NothingToExportError extends Error {
83
+ constructor() {
84
+ super("This mailbox has no keys to export yet.");
85
+ this.name = "NothingToExportError";
86
+ }
87
+ }
88
+
89
+ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
90
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
91
+ }
92
+
93
+ function hexBytes(hex: string): Uint8Array {
94
+ const out = new Uint8Array(hex.length >> 1);
95
+ for (let i = 0; i < out.length; i++) {
96
+ out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
97
+ }
98
+ return out;
99
+ }
100
+
101
+ function bagAttributes(friendlyName: string, localKeyId: Uint8Array): pkijs.Attribute[] {
102
+ return [
103
+ new pkijs.Attribute({ type: FRIENDLY_NAME, values: [new asn1js.BmpString({ value: friendlyName })] }),
104
+ new pkijs.Attribute({ type: LOCAL_KEY_ID, values: [new asn1js.OctetString({ valueHex: toArrayBuffer(localKeyId) })] }),
105
+ ];
106
+ }
107
+
108
+ function friendlyName(kind: ExportedKeyKind, address: string, index: number): string {
109
+ const label = kind === "zero-access" ? "zero-access key" : kind === "encrypt" ? "encryption key" : "signing key";
110
+ return `RapidMX ${label} (${address})${index > 0 ? ` ${index + 1}` : ""}`;
111
+ }
112
+
113
+ /** Opens one wrapped private key to its raw PKCS#8, or `undefined` when it won't open under this master key - an export
114
+ * carries what it can rather than failing on one stale wrap. */
115
+ async function openPkcs8(masterKey: Uint8Array, wrapped: WrappedPrivateKey, aad: Uint8Array): Promise<Uint8Array | undefined> {
116
+ try {
117
+ return await openWithKey(masterKey, wrapped, aad);
118
+ } catch {
119
+ return undefined;
120
+ }
121
+ }
122
+
123
+ /** Opens every key the export holds, in order: zero-access keys (unrevoked newest first), the encryption keys (active
124
+ * first, then retained newest first), then the active signing key if asked. Each list is capped like the session's. */
125
+ async function openExportedKeys(input: KeyExportInput, vault: KeyVault, masterKey: Uint8Array): Promise<OpenedKey[]> {
126
+ const opened: OpenedKey[] = [];
127
+ const wrappedFor = (fingerprint: string) => vault.wrappedKeys.find((k) => k.fingerprint === fingerprint);
128
+ const take = async (kind: ExportedKeyKind, keys: { fingerprint: string; publicKey: string }[], purpose: string, max: number) => {
129
+ const aad = buildAad(input.mailboxUid, purpose);
130
+ const seen = new Set<string>();
131
+ for (const key of keys) {
132
+ const wrapped = wrappedFor(key.fingerprint);
133
+ if (!wrapped || seen.has(key.fingerprint) || seen.size === max) {
134
+ continue;
135
+ }
136
+ const pkcs8 = await openPkcs8(masterKey, wrapped, aad);
137
+ if (pkcs8) {
138
+ seen.add(key.fingerprint);
139
+ opened.push({ kind, fingerprint: key.fingerprint, certDer: fromBase64(key.publicKey), pkcs8 });
140
+ }
141
+ }
142
+ };
143
+
144
+ const zeroAccess = [...(vault.zeroAccessKeys ?? [])].sort((a, b) => Number(!!a.revokedAt) - Number(!!b.revokedAt) || b.notBefore - a.notBefore);
145
+ await take("zero-access", zeroAccess, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE, MAX_RETAINED_ENCRYPTION_KEYS + 1);
146
+
147
+ const active = findActivePublicKey(input.mailboxKeys, "encrypt");
148
+ const encryption = [
149
+ ...(active ? [active] : []),
150
+ ...input.mailboxKeys.filter((k) => k.useType === "encrypt" && k !== active).sort((a, b) => b.notBefore - a.notBefore),
151
+ ];
152
+ await take("encrypt", encryption, ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, MAX_RETAINED_ENCRYPTION_KEYS + 1);
153
+
154
+ if (input.includeSigningKey) {
155
+ const signing = findActivePublicKey(input.mailboxKeys, "sign");
156
+ await take("sign", signing ? [signing] : [], SIGNING_PRIVATE_KEY_AAD_PURPOSE, 1);
157
+ }
158
+ return opened;
159
+ }
160
+
161
+ /** Builds the PKCS#12 file: one PBES2 (PBKDF2-SHA256, AES-256-CBC) shrouded key bag and one certificate bag per key,
162
+ * paired by `localKeyID`, in an unencrypted SafeContents, under a SHA-256 MAC. */
163
+ async function buildPkcs12(keys: OpenedKey[], address: string, passphrase: string): Promise<Uint8Array> {
164
+ const crypto = pkijs.getCrypto(true);
165
+ // UTF-8 for both: pkijs re-encodes it as the BMPString the PKCS#12 MAC derivation (RFC 7292 Appendix B) takes.
166
+ const utf8 = toArrayBuffer(new TextEncoder().encode(passphrase));
167
+ const bags: pkijs.SafeBag[] = [];
168
+ const shrouded: pkijs.PKCS8ShroudedKeyBag[] = [];
169
+ const counts: Record<ExportedKeyKind, number> = { "zero-access": 0, encrypt: 0, sign: 0 };
170
+
171
+ for (const key of keys) {
172
+ const name = friendlyName(key.kind, address, counts[key.kind]++);
173
+ const localKeyId = hexBytes(key.fingerprint).slice(0, 20);
174
+ const keyBag = new pkijs.PKCS8ShroudedKeyBag({ parsedValue: pkijs.PrivateKeyInfo.fromBER(toArrayBuffer(key.pkcs8)) });
175
+ shrouded.push(keyBag);
176
+ bags.push(new pkijs.SafeBag({ bagId: PKCS8_SHROUDED_KEY_BAG, bagValue: keyBag, bagAttributes: bagAttributes(name, localKeyId) }));
177
+ const certBag = new pkijs.CertBag({ certId: X509_CERTIFICATE, parsedValue: pkijs.Certificate.fromBER(toArrayBuffer(key.certDer)) });
178
+ bags.push(new pkijs.SafeBag({ bagId: CERT_BAG, bagValue: certBag, bagAttributes: bagAttributes(name, localKeyId) }));
179
+ }
180
+
181
+ for (const keyBag of shrouded) {
182
+ await keyBag.makeInternalValues(
183
+ { password: utf8, contentEncryptionAlgorithm: AES_256_CBC, hmacHashAlgorithm: "SHA-256", iterationCount: KEY_EXPORT_ITERATIONS },
184
+ crypto,
185
+ );
186
+ }
187
+
188
+ const pfx = new pkijs.PFX({
189
+ parsedValue: {
190
+ integrityMode: 0,
191
+ authenticatedSafe: new pkijs.AuthenticatedSafe({ parsedValue: { safeContents: [{ privacyMode: 0, value: new pkijs.SafeContents({ safeBags: bags }) }] } }),
192
+ },
193
+ });
194
+ await pfx.parsedValue!.authenticatedSafe!.makeInternalValues({ safeContents: [{}] }, crypto);
195
+ await pfx.makeInternalValues(
196
+ { password: utf8, iterations: KEY_EXPORT_ITERATIONS, pbkdf2HashAlgorithm: { name: "SHA-256" }, hmacHashAlgorithm: "SHA-256" },
197
+ crypto,
198
+ );
199
+ return new Uint8Array(pfx.toSchema().toBER(false));
200
+ }
201
+
202
+ /**
203
+ * Builds the "Export keys for Outlook/phone" PKCS#12 for `input.mailboxUid` (see this module's comment) and records the
204
+ * export with restapi. Re-opens the vault with `masterPassword` - rejecting as `unlockWithPassword()` does on a wrong
205
+ * one - and never touches the in-memory session. Every opened private key and the master key are zeroed before this
206
+ * returns, whether or not it succeeds. Throws `NothingToExportError` when no key opens.
207
+ */
208
+ export async function exportKeysAsPkcs12(input: KeyExportInput, client?: ApiClient): Promise<KeyExport> {
209
+ if (input.exportPassphrase.length < MIN_EXPORT_PASSPHRASE_LENGTH) {
210
+ throw new Error(`The export passphrase must be at least ${MIN_EXPORT_PASSPHRASE_LENGTH} characters.`);
211
+ }
212
+ const { vault, masterKey } = await openMasterKeyWithPassword(input.mailboxUid, input.masterPassword, client);
213
+ let opened: OpenedKey[] = [];
214
+ try {
215
+ opened = await openExportedKeys(input, vault, masterKey);
216
+ if (!opened.some((k) => k.kind !== "sign")) {
217
+ throw new NothingToExportError();
218
+ }
219
+ const pkcs12 = await buildPkcs12(opened, input.mailboxAddress, input.exportPassphrase);
220
+ const keys = opened.map(({ kind, fingerprint }) => ({ kind, fingerprint }));
221
+ await recordKeyExport(input.mailboxUid, keys, client);
222
+ return { pkcs12, keys };
223
+ } finally {
224
+ masterKey.fill(0);
225
+ for (const key of opened) {
226
+ key.pkcs8.fill(0);
227
+ }
228
+ }
229
+ }