@rapidmx/web-client 0.36.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/shared/calendar/calendarReminders.ts +35 -0
- package/apps/shared/calendar/sealedEvents.ts +42 -9
- package/apps/shared/calendar/useCalendarReminders.ts +28 -9
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +29 -1
- package/apps/shared/components/calendar/EventDetails.tsx +13 -2
- package/apps/shared/components/calendar/EventEditor.tsx +124 -17
- package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
- package/apps/shared/components/calendar/EventModal.tsx +19 -2
- package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
- package/apps/shared/components/calendar/eventForm.ts +2 -0
- package/apps/shared/components/contacts/ContactCard.tsx +13 -1
- package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
- package/apps/shared/components/contacts/ContactForm.tsx +56 -7
- package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
- package/apps/shared/components/contacts/contactCardData.ts +6 -3
- package/apps/shared/components/contacts/contactKeys.ts +3 -3
- package/apps/shared/components/layout/AppShell.tsx +34 -5
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +11 -4
- package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +37 -6
- package/apps/shared/components/mail/InviteCard.tsx +11 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +25 -14
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
- package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
- package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
- package/apps/shared/components/mail/invite/inviteStore.ts +11 -1
- package/apps/shared/components/mail/pinnedSigners.ts +14 -0
- package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
- package/apps/shared/components/settings/KeyExportSetting.tsx +4 -1
- package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
- package/apps/shared/components/settings/ZeroAccessSetting.tsx +13 -2
- package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
- package/apps/shared/mail/outbox/sendJob.ts +349 -50
- package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
- package/apps/shared/search/LocalIndexLifecycle.tsx +62 -33
- package/apps/shared/search/localIndexBuilder.ts +35 -24
- package/apps/shared/search/localIndexRpcClient.ts +91 -11
- package/apps/shared/search/localIndexSchema.ts +18 -2
- package/apps/shared/search/localIndexTransport.ts +6 -3
- package/apps/shared/search/localIndexWorker.ts +35 -18
- package/apps/shared/search/searchTier2.ts +5 -0
- package/apps/shared/search/zeroAccessIndexSync.ts +232 -40
- package/apps/shared/search/zeroAccessSyncState.ts +70 -0
- package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
- package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
- package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
- package/apps/www/_shell.tsx +2 -0
- package/apps/www/calendar/index.tsx +49 -5
- package/apps/www/contacts/index.tsx +67 -11
- package/apps/www/index.tsx +10 -1
- package/apps/www/settings/encryption/index.tsx +16 -1
- package/apps/www/settings/privacy/index.tsx +87 -38
- package/apps/www/tasks/index.tsx +24 -6
- package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
- package/dist/apps/shared/calendar/calendarReminders.js +26 -0
- package/dist/apps/shared/calendar/sealedEvents.d.ts +3 -2
- package/dist/apps/shared/calendar/sealedEvents.js +40 -9
- package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
- package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +9 -2
- package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
- package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
- package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
- package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
- package/dist/apps/shared/components/calendar/EventModal.js +15 -2
- package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
- package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
- package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
- package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
- package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
- package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
- package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
- package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
- package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
- package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
- package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
- package/dist/apps/shared/components/layout/AppShell.js +17 -6
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -4
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +3 -1
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +30 -4
- package/dist/apps/shared/components/mail/InviteCard.js +11 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +15 -11
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
- package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.js +11 -1
- package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
- package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
- package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
- package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
- package/dist/apps/shared/components/settings/KeyExportSetting.js +3 -1
- package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
- package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +2 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.js +12 -3
- package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
- package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
- package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
- package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
- package/dist/apps/shared/search/LocalIndexLifecycle.js +55 -33
- package/dist/apps/shared/search/localIndexBuilder.d.ts +4 -1
- package/dist/apps/shared/search/localIndexBuilder.js +31 -20
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
- package/dist/apps/shared/search/localIndexRpcClient.js +126 -10
- package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
- package/dist/apps/shared/search/localIndexSchema.js +16 -2
- package/dist/apps/shared/search/localIndexTransport.d.ts +6 -3
- package/dist/apps/shared/search/localIndexWorker.d.ts +9 -0
- package/dist/apps/shared/search/localIndexWorker.js +22 -15
- package/dist/apps/shared/search/searchTier2.js +5 -0
- package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +37 -6
- package/dist/apps/shared/search/zeroAccessIndexSync.js +199 -37
- package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
- package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
- package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
- package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
- package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
- package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
- package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
- package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
- package/dist/apps/www/_shell.d.ts +1 -1
- package/dist/apps/www/_shell.js +2 -2
- package/dist/apps/www/calendar/index.js +47 -5
- package/dist/apps/www/contacts/index.js +64 -11
- package/dist/apps/www/index.js +10 -1
- package/dist/apps/www/settings/encryption/index.js +14 -4
- package/dist/apps/www/settings/privacy/index.js +46 -5
- package/dist/apps/www/tasks/index.js +21 -7
- package/dist/lib/admin/domainsApi.d.ts +1 -1
- package/dist/lib/calendar/calendarApi.d.ts +46 -6
- package/dist/lib/calendar/calendarApi.js +110 -17
- package/dist/lib/calendar/calendarMutations.d.ts +20 -7
- package/dist/lib/calendar/calendarMutations.js +73 -14
- package/dist/lib/calendar/inviteApi.d.ts +17 -3
- package/dist/lib/calendar/inviteApi.js +61 -4
- package/dist/lib/calendar/protectedEvents.d.ts +31 -0
- package/dist/lib/calendar/protectedEvents.js +113 -0
- package/dist/lib/calendar/recurrence.d.ts +2 -0
- package/dist/lib/calendar/recurrence.js +16 -0
- package/dist/lib/calendar/sealedInvites.d.ts +6 -8
- package/dist/lib/calendar/sealedInvites.js +43 -6
- package/dist/lib/contacts/contactsApi.d.ts +29 -3
- package/dist/lib/contacts/contactsApi.js +23 -7
- package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
- package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
- package/dist/lib/crypto/composeSecurity.d.ts +11 -3
- package/dist/lib/crypto/composeSecurity.js +13 -5
- package/dist/lib/crypto/keyExport.js +2 -2
- package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
- package/dist/lib/crypto/keyPairMatch.js +65 -0
- package/dist/lib/crypto/keySession.d.ts +19 -2
- package/dist/lib/crypto/keySession.js +89 -13
- package/dist/lib/crypto/keyvaultApi.d.ts +41 -5
- package/dist/lib/crypto/keyvaultApi.js +13 -3
- package/dist/lib/crypto/meetingKeys.d.ts +25 -0
- package/dist/lib/crypto/meetingKeys.js +87 -0
- package/dist/lib/crypto/messageSecurity.d.ts +26 -2
- package/dist/lib/crypto/messageSecurity.js +122 -10
- package/dist/lib/crypto/openPgpKey.d.ts +39 -0
- package/dist/lib/crypto/openPgpKey.js +89 -0
- package/dist/lib/crypto/openpgp.d.ts +47 -0
- package/dist/lib/crypto/openpgp.js +88 -0
- package/dist/lib/crypto/pgpMessage.d.ts +51 -0
- package/dist/lib/crypto/pgpMessage.js +271 -0
- package/dist/lib/crypto/sharedMailboxKeys.d.ts +15 -3
- package/dist/lib/crypto/sharedMailboxKeys.js +102 -29
- package/dist/lib/crypto/smimeMessage.d.ts +21 -0
- package/dist/lib/crypto/smimeMessage.js +11 -7
- package/dist/lib/crypto/zeroAccessKey.js +5 -0
- package/dist/lib/mail/mailApi.d.ts +33 -0
- package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
- package/dist/lib/mail/mailboxImportApi.js +15 -0
- package/dist/lib/search/zeroAccessIndexApi.d.ts +4 -0
- package/dist/lib/tasks/tasksApi.d.ts +11 -1
- package/dist/lib/tasks/tasksApi.js +10 -4
- package/dist/lib/util/clientPlatform.d.ts +14 -0
- package/dist/lib/util/clientPlatform.js +26 -0
- package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
- package/dist/lib/videoconf/protectedMeetings.js +181 -0
- package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
- package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
- package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
- package/dist/lib/zeroAccess/sealedMoves.js +63 -0
- package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
- package/dist/lib/zeroAccess/sealedRecords.js +136 -0
- package/lib/admin/domainsApi.ts +1 -1
- package/lib/calendar/calendarApi.ts +140 -17
- package/lib/calendar/calendarMutations.ts +79 -12
- package/lib/calendar/inviteApi.ts +75 -5
- package/lib/calendar/protectedEvents.ts +143 -0
- package/lib/calendar/recurrence.ts +17 -0
- package/lib/calendar/sealedInvites.ts +47 -7
- package/lib/contacts/contactsApi.ts +43 -17
- package/lib/contacts/useContactPhotoSrc.ts +103 -13
- package/lib/crypto/composeSecurity.ts +20 -5
- package/lib/crypto/keyExport.ts +2 -2
- package/lib/crypto/keyPairMatch.ts +69 -0
- package/lib/crypto/keySession.ts +102 -12
- package/lib/crypto/keyvaultApi.ts +49 -7
- package/lib/crypto/meetingKeys.ts +118 -0
- package/lib/crypto/messageSecurity.ts +174 -13
- package/lib/crypto/openPgpKey.ts +118 -0
- package/lib/crypto/openpgp.ts +115 -0
- package/lib/crypto/pgpMessage.ts +336 -0
- package/lib/crypto/sharedMailboxKeys.ts +106 -28
- package/lib/crypto/smimeMessage.ts +11 -7
- package/lib/crypto/zeroAccessKey.ts +5 -0
- package/lib/mail/mailApi.ts +27 -0
- package/lib/mail/mailboxImportApi.ts +24 -0
- package/lib/search/zeroAccessIndexApi.ts +4 -0
- package/lib/tasks/tasksApi.ts +19 -5
- package/lib/util/clientPlatform.ts +50 -0
- package/lib/videoconf/protectedMeetings.ts +221 -0
- package/lib/videoconf/videoMeetingsApi.ts +15 -4
- package/lib/zeroAccess/sealedMoves.ts +79 -0
- package/lib/zeroAccess/sealedRecords.ts +153 -0
- package/package.json +12 -3
|
@@ -38,7 +38,8 @@ const BASE64_LINE_WIDTH = 76;
|
|
|
38
38
|
export function applyBaselineOuterHeaders(headers) {
|
|
39
39
|
return { ...headers, subject: "[...]" };
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
/** @internal Shared with `pgpMessage.ts`. */
|
|
42
|
+
export function base64Wrapped(bytes) {
|
|
42
43
|
const encoded = toBase64(bytes);
|
|
43
44
|
const lines = [];
|
|
44
45
|
for (let i = 0; i < encoded.length; i += BASE64_LINE_WIDTH) {
|
|
@@ -46,14 +47,15 @@ function base64Wrapped(bytes) {
|
|
|
46
47
|
}
|
|
47
48
|
return lines.join(CRLF);
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
+
/** @internal Shared with `pgpMessage.ts`. */
|
|
51
|
+
export function generateBoundary() {
|
|
50
52
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
51
53
|
return `----=_RapidMX_${Array.from(bytes)
|
|
52
54
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
53
55
|
.join("")}`;
|
|
54
56
|
}
|
|
55
57
|
/** Strips CR/LF/NUL from a header value that has no further encoding (Date, Message-ID, Content-Type). */
|
|
56
|
-
function flattenHeaderValue(value) {
|
|
58
|
+
export function flattenHeaderValue(value) {
|
|
57
59
|
return value.replace(/[\r\n\0]+/g, " ");
|
|
58
60
|
}
|
|
59
61
|
/**
|
|
@@ -73,7 +75,8 @@ function headerLines(headers, prefix = "") {
|
|
|
73
75
|
...(prefix ? [] : [`Message-ID: ${flattenHeaderValue(headers.messageId)}`]),
|
|
74
76
|
];
|
|
75
77
|
}
|
|
76
|
-
|
|
78
|
+
/** @internal Shared with `pgpMessage.ts`: the protected header block of an inner entity (with `HP-Outer:` copies of `hpOuter`). */
|
|
79
|
+
export function protectedHeaderLines(headers, hpOuter) {
|
|
77
80
|
const lines = headerLines(headers);
|
|
78
81
|
if (hpOuter) {
|
|
79
82
|
// RFC 9788 Section 2.2.1's `hp-outer` field: the literal string "HP-Outer:" followed by the
|
|
@@ -237,7 +240,7 @@ export function assembleOutboundMime(outerHeaders, part) {
|
|
|
237
240
|
* (a real bug if reused for this - JSON/`Record` keys aren't multi-valued). Scans the raw header block
|
|
238
241
|
* text directly instead. Returns `undefined` when no `HP-Outer:` lines are present at all, so a caller
|
|
239
242
|
* can distinguish "nothing to compare" from "compared and everything matched". */
|
|
240
|
-
function extractHpOuterHeaders(fields) {
|
|
243
|
+
export function extractHpOuterHeaders(fields) {
|
|
241
244
|
const result = {};
|
|
242
245
|
let found = false;
|
|
243
246
|
for (const { name, value } of fields) {
|
|
@@ -253,7 +256,7 @@ function extractHpOuterHeaders(fields) {
|
|
|
253
256
|
/** `true` when every field `hpOuter` actually carries matches `actualOuterHeaders`' value for that same
|
|
254
257
|
* field - a field `hpOuter` doesn't carry (e.g. no `Cc` on this message) is not compared, since RFC
|
|
255
258
|
* 9788 never claims coverage for it either way. */
|
|
256
|
-
function outerHeadersMatch(hpOuter, actualOuterHeaders) {
|
|
259
|
+
export function outerHeadersMatch(hpOuter, actualOuterHeaders) {
|
|
257
260
|
return Object.keys(hpOuter).every((field) => hpOuter[field] === actualOuterHeaders[field]);
|
|
258
261
|
}
|
|
259
262
|
/** Decrypts (and, if present, verifies the inner signature of) a message built by
|
|
@@ -327,7 +330,8 @@ export function splitHeadersAndBody(entity) {
|
|
|
327
330
|
const { headers, body, rawHeaderBlock } = parseMimeEntity(entity);
|
|
328
331
|
return { headers, contentType: headers["content-type"], body, rawHeaderBlock };
|
|
329
332
|
}
|
|
330
|
-
|
|
333
|
+
/** @internal Shared with `pgpMessage.ts`. */
|
|
334
|
+
export function headersToProtectedHeaders(headers) {
|
|
331
335
|
return {
|
|
332
336
|
from: headers["from"] ?? "",
|
|
333
337
|
to: headers["to"] ?? "",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { fromBase64 } from "./encoding.js";
|
|
2
2
|
import { enrollKey, getKeyVault } from "./keyvaultApi.js";
|
|
3
|
+
import { certificateHoldsKey } from "./keyPairMatch.js";
|
|
3
4
|
import { exportPrivateKeyPkcs8, generateKeyPairWithCsr, importPrivateKeyPkcs8 } from "./keys.js";
|
|
4
5
|
import { addZeroAccessKey, getUnlockedKeys, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE } from "./keySession.js";
|
|
5
6
|
import { buildAad, sealWithKey } from "./masterKey.js";
|
|
@@ -26,6 +27,10 @@ export async function provisionZeroAccessKey(mailboxUid, mailboxAddress, masterK
|
|
|
26
27
|
if (!enrolled || !certificate) {
|
|
27
28
|
throw new Error("The server did not return the enrolled zero-access key.");
|
|
28
29
|
}
|
|
30
|
+
// The certificate is the server's: this device seals to it (index segments, protected meetings' keys), so it must be the new key's.
|
|
31
|
+
if (!(await certificateHoldsKey(fromBase64(certificate.publicKey), raw))) {
|
|
32
|
+
throw new Error("The server returned a zero-access certificate that isn't the enrolled key's.");
|
|
33
|
+
}
|
|
29
34
|
const privateKey = await importPrivateKeyPkcs8(raw, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
|
|
30
35
|
return { vault, key: { fingerprint: enrolled.fingerprint, certDer: fromBase64(certificate.publicKey), privateKey } };
|
|
31
36
|
}
|
|
@@ -101,6 +101,12 @@ export interface Mailbox {
|
|
|
101
101
|
/** "Enable zero-access encryption": unset means on. Only the mailbox's owner can change it - restapi drops anybody
|
|
102
102
|
* else's value. Has no effect while the server's `EncryptionPolicy.allowZeroAccess` is off. */
|
|
103
103
|
zeroAccessEnabled?: boolean;
|
|
104
|
+
/** Which data types zero-access encryption covers - see `ZeroAccessTypes`. Owner only. */
|
|
105
|
+
zeroAccessTypes?: ZeroAccessTypes;
|
|
106
|
+
/** The ActiveSync and Outlook clients that sync this mailbox, as the server last saw them. Server-written. */
|
|
107
|
+
syncClients?: SyncClients;
|
|
108
|
+
/** When the owner last exported a zero-access key for a phone or Outlook. Server-written. */
|
|
109
|
+
zeroAccessKeysExportedAt?: number;
|
|
104
110
|
/** How the caller reaches this mailbox: `"owner"` (theirs) or `"delegate"` (shared with them). Set by the plain list and read of
|
|
105
111
|
* mailboxes - not in an administration-scope answer - so a client can label the shared ones; see `isSharedWithMe()`. */
|
|
106
112
|
accessRole?: "owner" | "delegate";
|
|
@@ -197,6 +203,25 @@ export declare function autoProvisionMailbox(selection?: {
|
|
|
197
203
|
* `null` clears them - `JSON.stringify()` drops an `undefined` value entirely, which would instead leave
|
|
198
204
|
* the old value in place.
|
|
199
205
|
*/
|
|
206
|
+
/** The data types zero-access encryption can cover (restapi `specs/search.md` §3 *What It Covers*). */
|
|
207
|
+
export type ZeroAccessDataType = "mail" | "calendar" | "contacts" | "tasks";
|
|
208
|
+
/** `Mailbox.zeroAccessTypes`: per data type, whether zero-access covers it. A type left out is undecided: mail counts as on, the others
|
|
209
|
+
* as off, until the server decides them from the owner's devices. */
|
|
210
|
+
export type ZeroAccessTypes = Partial<Record<ZeroAccessDataType, boolean>>;
|
|
211
|
+
/** `Mailbox.syncClients`: the ActiveSync devices (each with the data types it syncs) and Outlook. */
|
|
212
|
+
export interface SyncClients {
|
|
213
|
+
activesync?: {
|
|
214
|
+
devices: {
|
|
215
|
+
id: string;
|
|
216
|
+
model?: string;
|
|
217
|
+
types: ZeroAccessDataType[];
|
|
218
|
+
lastSeenAt: number;
|
|
219
|
+
}[];
|
|
220
|
+
};
|
|
221
|
+
mapi?: {
|
|
222
|
+
lastSeenAt: number;
|
|
223
|
+
};
|
|
224
|
+
}
|
|
200
225
|
export interface UpdateMailboxInput {
|
|
201
226
|
uid: string;
|
|
202
227
|
version: number;
|
|
@@ -223,6 +248,10 @@ export interface UpdateMailboxInput {
|
|
|
223
248
|
autoSendReceiptsExternal?: boolean;
|
|
224
249
|
/** Owner-only server-side (anybody else's value is dropped) - see `Mailbox.zeroAccessEnabled`. */
|
|
225
250
|
zeroAccessEnabled?: boolean;
|
|
251
|
+
/** Owner-only server-side; a partial choice is merged into the stored one - see `Mailbox.zeroAccessTypes`. */
|
|
252
|
+
zeroAccessTypes?: ZeroAccessTypes;
|
|
253
|
+
/** The mailbox's own encryption preference, replaced whole - see `Mailbox.encryptPreference`. */
|
|
254
|
+
encryptPreference?: EncryptionPreference;
|
|
226
255
|
/** Trusted-caller-only server-side. `null` (or `""`) unassigns the mailbox's escrow scope; the
|
|
227
256
|
* referenced `EscrowScope` must exist otherwise. See `Mailbox.escrowScopeId`. */
|
|
228
257
|
escrowScopeId?: string | null;
|
|
@@ -728,6 +757,10 @@ export interface AssembleDraftInput {
|
|
|
728
757
|
html: string;
|
|
729
758
|
/** See `AssembleIntent`. */
|
|
730
759
|
intent?: AssembleIntent;
|
|
760
|
+
/** The addresses this copy is delivered to, when not all of `to`/`cc`/`bcc`: one copy of a message sent in two end-to-end
|
|
761
|
+
* formats (an S/MIME and an OpenPGP copy, each listing every recipient) goes only to the recipients of its own format. Each
|
|
762
|
+
* must be one of the recipients. */
|
|
763
|
+
deliverTo?: string[];
|
|
731
764
|
}
|
|
732
765
|
/**
|
|
733
766
|
* Why a draft is being assembled (server `ComposeIntent`). `"save"`, the default, keeps an edit: in a zero-access mailbox the server
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { ApiClient } from "../util/api.js";
|
|
9
9
|
import { RequestListParams } from "../util/apiQuery.js";
|
|
10
|
+
import type { Mailbox } from "./mailApi.js";
|
|
10
11
|
export type { RequestListParams };
|
|
11
12
|
export type MailboxImportFormat = "mbox" | "pst";
|
|
12
13
|
export type MailboxImportStatus = "pending" | "processing" | "completed" | "failed";
|
|
@@ -51,3 +52,12 @@ export declare function uploadMailboxImport(file: File, input: UploadMailboxImpo
|
|
|
51
52
|
* first; `params` pages through them (`limit` capped at 500 server-side). */
|
|
52
53
|
export declare function listImportRequests(params?: RequestListParams, client?: ApiClient): Promise<MailboxImportRequest[]>;
|
|
53
54
|
export declare function getImportRequest(uid: string, client?: ApiClient): Promise<MailboxImportRequest>;
|
|
55
|
+
/** Why server-side import is refused for a mailbox whose mail is zero-access (restapi answers such an upload 409). */
|
|
56
|
+
export declare const ZERO_ACCESS_IMPORT_MESSAGE: string;
|
|
57
|
+
/**
|
|
58
|
+
* Whether `mailbox`'s mail is stored zero-access encrypted, so restapi refuses a server-side import into it (`specs/search.md` §3: the
|
|
59
|
+
* uploaded archive would wait readable in the blob store). Decided as restapi's `zeroAccessStatus()` decides it for mail: the
|
|
60
|
+
* administrator's "Allow zero-access encryption" (`allowZeroAccess`; unknown counts as allowed, so an import is never offered only to be
|
|
61
|
+
* refused), the owner's switch (unset is on), mail among the protected types (undecided is on), and a key that isn't revoked.
|
|
62
|
+
*/
|
|
63
|
+
export declare function mailIsZeroAccess(mailbox: Pick<Mailbox, "zeroAccessEnabled" | "zeroAccessTypes" | "zeroAccessKeys">, allowZeroAccess: boolean | undefined): boolean;
|
|
@@ -37,3 +37,18 @@ export function listImportRequests(params = {}, client) {
|
|
|
37
37
|
export function getImportRequest(uid, client) {
|
|
38
38
|
return withClient(client, `/mail/mailbox-import-requests/${encodeURIComponent(uid)}`);
|
|
39
39
|
}
|
|
40
|
+
/** Why server-side import is refused for a mailbox whose mail is zero-access (restapi answers such an upload 409). */
|
|
41
|
+
export const ZERO_ACCESS_IMPORT_MESSAGE = "Importing an archive isn't available while zero-access encryption protects this mailbox's mail: the server would have to keep " +
|
|
42
|
+
"the uploaded archive readable until the import ran, which zero-access encryption doesn't allow.";
|
|
43
|
+
/**
|
|
44
|
+
* Whether `mailbox`'s mail is stored zero-access encrypted, so restapi refuses a server-side import into it (`specs/search.md` §3: the
|
|
45
|
+
* uploaded archive would wait readable in the blob store). Decided as restapi's `zeroAccessStatus()` decides it for mail: the
|
|
46
|
+
* administrator's "Allow zero-access encryption" (`allowZeroAccess`; unknown counts as allowed, so an import is never offered only to be
|
|
47
|
+
* refused), the owner's switch (unset is on), mail among the protected types (undecided is on), and a key that isn't revoked.
|
|
48
|
+
*/
|
|
49
|
+
export function mailIsZeroAccess(mailbox, allowZeroAccess) {
|
|
50
|
+
return (allowZeroAccess !== false &&
|
|
51
|
+
mailbox.zeroAccessEnabled !== false &&
|
|
52
|
+
mailbox.zeroAccessTypes?.mail !== false &&
|
|
53
|
+
(mailbox.zeroAccessKeys ?? []).some((key) => !key.revokedAt));
|
|
54
|
+
}
|
|
@@ -23,6 +23,10 @@ export interface IndexWeekEntries {
|
|
|
23
23
|
latestEntryAt: string;
|
|
24
24
|
/** The week has closed and its grace period has passed: a client may compact it. */
|
|
25
25
|
compactable: boolean;
|
|
26
|
+
/** When the week becomes (or became) compactable - where the platform stagger starts. Absent from a server before it. */
|
|
27
|
+
compactableAt?: string;
|
|
28
|
+
/** Size of the week's pending sealed entries (bytes of base64). Absent from a server before it. */
|
|
29
|
+
pendingBytes?: number;
|
|
26
30
|
}
|
|
27
31
|
export interface SegmentManifest {
|
|
28
32
|
mailboxUid: string;
|
|
@@ -24,6 +24,9 @@ export interface Task {
|
|
|
24
24
|
myDay?: boolean;
|
|
25
25
|
/** The uid of the user this task is assigned to, if any. */
|
|
26
26
|
assignedTo?: string;
|
|
27
|
+
/** The title and notes sealed to the mailbox's zero-access key while it protects tasks (`title` is then a placeholder) - opened with
|
|
28
|
+
* `openSealedRecords()`. Server-written. */
|
|
29
|
+
sealedFields?: Record<string, string> | null;
|
|
27
30
|
}
|
|
28
31
|
/** `client`, given by every function below, is an explicit `ApiClient` from `createApiClient()` (e.g. one
|
|
29
32
|
* account of a multi-account app) to call instead of the default global `apiFetch()` - see `withClient()`'s
|
|
@@ -56,8 +59,15 @@ export interface UpdateTaskInput {
|
|
|
56
59
|
taskListUid?: string;
|
|
57
60
|
myDay?: boolean;
|
|
58
61
|
assignedTo?: string;
|
|
62
|
+
/** A move to another mailbox, with `folderUid` there - refused for a protected task (`SealedMoveError`). */
|
|
63
|
+
mailboxUid?: string;
|
|
64
|
+
folderUid?: string;
|
|
59
65
|
}
|
|
60
|
-
|
|
66
|
+
/** Saves `input`. One moving the task to another mailbox (`mailboxUid`) is refused with `SealedMoveError` when the task is protected -
|
|
67
|
+
* before sending anything when `current` (the task as this device has it) says so, else from the server's refusal. With `current`, a
|
|
68
|
+
* protected task's sealed fields go only when they changed from it, and an emptied one is named in `clearedFields` when `current` was
|
|
69
|
+
* opened here (`protectedRecordWrite()`). */
|
|
70
|
+
export declare function updateTask(input: UpdateTaskInput, client?: ApiClient, current?: Task): Promise<Task>;
|
|
61
71
|
/** Toggles a task's `completed` flag in place — same pattern as `mailApi.ts`'s `setMessageRead`. */
|
|
62
72
|
export declare function setTaskCompleted(task: Task, completed: boolean, client?: ApiClient): Promise<Task>;
|
|
63
73
|
/** Adds/removes a task from the caller's curated "My Day" working set — same thin-wrapper pattern as
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* comment for the shared ACL/authorization model every wrapper file here follows. */
|
|
7
7
|
import { withClient } from "../util/api.js";
|
|
8
8
|
import { SETTINGS_LIST_LIMIT, buildQuery } from "../util/apiQuery.js";
|
|
9
|
+
import { guardSealedMove } from "../zeroAccess/sealedMoves.js";
|
|
10
|
+
import { protectedRecordWrite } from "../zeroAccess/sealedRecords.js";
|
|
9
11
|
/** `client`, given by every function below, is an explicit `ApiClient` from `createApiClient()` (e.g. one
|
|
10
12
|
* account of a multi-account app) to call instead of the default global `apiFetch()` - see `withClient()`'s
|
|
11
13
|
* own doc comment in `util/api.ts`. Omitted (the default), every function here behaves exactly as before. */
|
|
@@ -22,11 +24,15 @@ export function createTask(input, client) {
|
|
|
22
24
|
body: JSON.stringify({ completed: false, priority: "normal", ...input }),
|
|
23
25
|
});
|
|
24
26
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
/** Saves `input`. One moving the task to another mailbox (`mailboxUid`) is refused with `SealedMoveError` when the task is protected -
|
|
28
|
+
* before sending anything when `current` (the task as this device has it) says so, else from the server's refusal. With `current`, a
|
|
29
|
+
* protected task's sealed fields go only when they changed from it, and an emptied one is named in `clearedFields` when `current` was
|
|
30
|
+
* opened here (`protectedRecordWrite()`). */
|
|
31
|
+
export function updateTask(input, client, current) {
|
|
32
|
+
return guardSealedMove(current, input.mailboxUid, () => withClient(client, `/mail/tasks/${encodeURIComponent(input.uid)}`, {
|
|
27
33
|
method: "PUT",
|
|
28
|
-
body: JSON.stringify(input),
|
|
29
|
-
});
|
|
34
|
+
body: JSON.stringify(protectedRecordWrite(current, input)),
|
|
35
|
+
}));
|
|
30
36
|
}
|
|
31
37
|
/** Toggles a task's `completed` flag in place — same pattern as `mailApi.ts`'s `setMessageRead`. */
|
|
32
38
|
export function setTaskCompleted(task, completed, client) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What kind of client this is, for work that should prefer one over another - the search index's week compaction, which a phone
|
|
3
|
+
* should leave to a computer (restapi `specs/search.md` §8 *Who compacts*).
|
|
4
|
+
*
|
|
5
|
+
* - `"desktop"`: a native app on a computer (`tauri-client`, `electron-client`), which installs `window.rapidmx`.
|
|
6
|
+
* - `"mobile"`: a native app on a phone or tablet (the host sets `window.rapidmx.formFactor: "mobile"`), or a phone or tablet browser.
|
|
7
|
+
* - `"web"`: any other browser.
|
|
8
|
+
*/
|
|
9
|
+
export type ClientPlatform = "desktop" | "web" | "mobile";
|
|
10
|
+
/** This client's platform (see the module comment). */
|
|
11
|
+
export declare function clientPlatform(): ClientPlatform;
|
|
12
|
+
/** Whether the connection is one to spare: cellular, or the user asked to save data (Network Information API, where the browser
|
|
13
|
+
* has it - absent, the connection is taken as unconstrained). */
|
|
14
|
+
export declare function isConstrainedConnection(): boolean;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const MOBILE_USER_AGENT = /Android|iPhone|iPad|iPod|Mobile/i;
|
|
2
|
+
function navigatorHints() {
|
|
3
|
+
return typeof navigator === "undefined" ? undefined : navigator;
|
|
4
|
+
}
|
|
5
|
+
/** Whether this browser runs on a phone or tablet, by its User-Agent Client Hints, else its user agent string. */
|
|
6
|
+
function isMobileBrowser() {
|
|
7
|
+
const hints = navigatorHints();
|
|
8
|
+
if (typeof hints?.userAgentData?.mobile === "boolean") {
|
|
9
|
+
return hints.userAgentData.mobile;
|
|
10
|
+
}
|
|
11
|
+
return MOBILE_USER_AGENT.test(hints?.userAgent ?? "");
|
|
12
|
+
}
|
|
13
|
+
/** This client's platform (see the module comment). */
|
|
14
|
+
export function clientPlatform() {
|
|
15
|
+
const host = typeof window === "undefined" ? undefined : window.rapidmx;
|
|
16
|
+
if (host) {
|
|
17
|
+
return host.formFactor === "mobile" ? "mobile" : "desktop";
|
|
18
|
+
}
|
|
19
|
+
return isMobileBrowser() ? "mobile" : "web";
|
|
20
|
+
}
|
|
21
|
+
/** Whether the connection is one to spare: cellular, or the user asked to save data (Network Information API, where the browser
|
|
22
|
+
* has it - absent, the connection is taken as unconstrained). */
|
|
23
|
+
export function isConstrainedConnection() {
|
|
24
|
+
const connection = navigatorHints()?.connection;
|
|
25
|
+
return connection?.saveData === true || connection?.type === "cellular";
|
|
26
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ApiClient } from "../util/api.js";
|
|
2
|
+
import { type VideoMeetingDetail, type VideoMeetingInvitee } from "./videoMeetingsApi.js";
|
|
3
|
+
/** How long after the meeting ends its key is kept (`mail:videoconf:key_grace_minutes`' default). */
|
|
4
|
+
export declare const MEETING_KEY_GRACE_MS: number;
|
|
5
|
+
/** What `createVideoMeeting()` sends to protect a meeting. */
|
|
6
|
+
export interface MeetingProtectionInput {
|
|
7
|
+
meetingKey: {
|
|
8
|
+
certificate: string;
|
|
9
|
+
};
|
|
10
|
+
keyExpiresAt: string;
|
|
11
|
+
keyWraps: {
|
|
12
|
+
mailboxUid: string;
|
|
13
|
+
recipientFingerprint: string;
|
|
14
|
+
kind: "meeting-key";
|
|
15
|
+
cms: string;
|
|
16
|
+
}[];
|
|
17
|
+
sealedFields: {
|
|
18
|
+
title: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** A protected meeting's fields, as the server returns them to its organizer. */
|
|
22
|
+
export interface ProtectedMeetingFields {
|
|
23
|
+
keyProtected?: boolean | null;
|
|
24
|
+
keyWraps?: {
|
|
25
|
+
mailboxUid: string;
|
|
26
|
+
recipientFingerprint: string;
|
|
27
|
+
kind: string;
|
|
28
|
+
cms: string;
|
|
29
|
+
}[] | null;
|
|
30
|
+
/** When the meeting key is discarded (ISO). */
|
|
31
|
+
keyExpiresAt?: string | null;
|
|
32
|
+
}
|
|
33
|
+
/** Forgets every opened meeting key (sign-out, or the test seam). */
|
|
34
|
+
export declare function clearMeetingKeys(): void;
|
|
35
|
+
/** When a meeting ending at `endDate` - the last occurrence's end, for a series that has one - has its key discarded. */
|
|
36
|
+
export declare function meetingKeyExpiry(endDate: Date, now?: number): Date;
|
|
37
|
+
/** What protects a new meeting of `mailboxUid` titled `title` with `invitees`, its key discarded at `expiresAt` - or `undefined` when
|
|
38
|
+
* the mailbox has no zero-access key unlocked here (the meeting is then not protected). `fragment` is every participant's link key. */
|
|
39
|
+
export declare function protectMeeting(mailboxUid: string, title: string, invitees: VideoMeetingInvitee[], expiresAt: Date): Promise<{
|
|
40
|
+
protection: MeetingProtectionInput;
|
|
41
|
+
invitees: {
|
|
42
|
+
email: string;
|
|
43
|
+
sealedFields?: {
|
|
44
|
+
displayName: string;
|
|
45
|
+
};
|
|
46
|
+
}[];
|
|
47
|
+
fragment: string;
|
|
48
|
+
} | undefined>;
|
|
49
|
+
/** Remembers a just-created meeting's link key - `null` for one that isn't protected - so the save that invites to it needn't fetch the
|
|
50
|
+
* meeting or reopen its wrap. `mailboxUid` is the organizer's mailbox: its lock forgets the key. */
|
|
51
|
+
export declare function rememberMeetingKey(meetingUid: string, fragment: string | null, mailboxUid: string): void;
|
|
52
|
+
/** `meeting`'s link key - remembered, or opened from the organizer's wrap with the mailbox's unlocked zero-access keys - or `undefined`
|
|
53
|
+
* when it isn't protected or can't be opened here. Never rejects. */
|
|
54
|
+
export declare function meetingKeyFragmentOf(meeting: VideoMeetingDetail & ProtectedMeetingFields): Promise<string | undefined>;
|
|
55
|
+
/** `meeting`'s organizer join link, with the meeting key after `#` when it is protected - `null` when there is none, or the key can't
|
|
56
|
+
* be opened here (a protected meeting can't be joined without it). */
|
|
57
|
+
export declare function organizerJoinLink(meeting: VideoMeetingDetail & ProtectedMeetingFields): Promise<string | null>;
|
|
58
|
+
/** For a save of `event` that may invite: every attendee's link key (`invitationContent.meetingLinkSecrets`), when its video meeting is
|
|
59
|
+
* protected and its key opens here - else `undefined`. Never rejects. */
|
|
60
|
+
export declare function meetingLinkSecrets(event: {
|
|
61
|
+
videoMeetingUid?: string | null;
|
|
62
|
+
attendees?: {
|
|
63
|
+
address: string;
|
|
64
|
+
}[];
|
|
65
|
+
organizer?: {
|
|
66
|
+
address: string;
|
|
67
|
+
};
|
|
68
|
+
}, client?: ApiClient): Promise<Record<string, string> | undefined>;
|
|
69
|
+
/**
|
|
70
|
+
* Moves the key expiry of meeting `meetingUid` - when it is protected - to follow its event's new end, `endDate` (the last occurrence's
|
|
71
|
+
* end, for a series): its key is discarded an hour after the end it was made for, so a meeting moved later (or a series extended) would
|
|
72
|
+
* otherwise be unjoinable by then. Best effort, after the event itself is saved: a failure is logged, never thrown.
|
|
73
|
+
*/
|
|
74
|
+
export declare function followMeetingKeyExpiry(meetingUid: string, endDate: Date, client?: ApiClient): Promise<void>;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The organizer's side of a protected video meeting (meet-plugin `specs/meeting_privacy.md`): a meeting whose organizer has a zero-access
|
|
7
|
+
* key gets a temporary meeting key, made here. Its title and invitees' names are sealed to the meeting key and the organizer's
|
|
8
|
+
* zero-access certificate; the meeting key itself is wrapped to the organizer's zero-access certificate, so any of the organizer's
|
|
9
|
+
* devices can reopen it; and every participant's link carries the key after `#k=`, which this module supplies on each save that sends an
|
|
10
|
+
* invitation (`invitationContent.meetingLinkSecrets`) and on the organizer's own join link - never stored by the server.
|
|
11
|
+
*
|
|
12
|
+
* The meeting key is discarded an hour after the event ends (the last occurrence's end for a series with an end; five years at most).
|
|
13
|
+
*/
|
|
14
|
+
import { fromBase64, toBase64 } from "../crypto/encoding.js";
|
|
15
|
+
import { getUnlockedKeys, subscribeKeySession } from "../crypto/keySession.js";
|
|
16
|
+
import { generateMeetingKey, meetingKeyFragment } from "../crypto/meetingKeys.js";
|
|
17
|
+
import { decryptEnvelopedDataWithKeys, encryptForRecipients } from "../crypto/smime.js";
|
|
18
|
+
import { getVideoMeeting, updateVideoMeeting } from "./videoMeetingsApi.js";
|
|
19
|
+
/** How long after the meeting ends its key is kept (`mail:videoconf:key_grace_minutes`' default). */
|
|
20
|
+
export const MEETING_KEY_GRACE_MS = 60 * 60000;
|
|
21
|
+
/** The longest a meeting key is kept: a series without an end keeps it this long, just inside the server's limit. */
|
|
22
|
+
const MAX_KEY_LIFETIME_MS = 5 * 365 * 24 * 60 * 60000;
|
|
23
|
+
/** Link keys by meeting uid, each with the organizer mailbox it belongs to: a protected meeting's key is opened once, and forgotten when
|
|
24
|
+
* that mailbox's keys are locked. `fragment: null`: a meeting known to be unprotected. */
|
|
25
|
+
const fragments = new Map();
|
|
26
|
+
/** Forgets every opened meeting key (sign-out, or the test seam). */
|
|
27
|
+
export function clearMeetingKeys() {
|
|
28
|
+
fragments.clear();
|
|
29
|
+
}
|
|
30
|
+
let watchingKeys = false;
|
|
31
|
+
/** Records `uid`'s link key, forgetting it - like everything opened with a mailbox's keys - once that mailbox locks. Subscribed on the
|
|
32
|
+
* first key remembered, not on import. */
|
|
33
|
+
function remember(uid, mailboxUid, fragment) {
|
|
34
|
+
if (!watchingKeys) {
|
|
35
|
+
watchingKeys = true;
|
|
36
|
+
subscribeKeySession(({ mailboxUid: locked, state }) => {
|
|
37
|
+
if (state === "locked") {
|
|
38
|
+
for (const [meetingUid, entry] of fragments) {
|
|
39
|
+
if (entry.mailboxUid === locked) {
|
|
40
|
+
fragments.delete(meetingUid);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// A key opened or minted across an await the mailbox locked during: the lock's clear has already run, so it isn't kept.
|
|
47
|
+
if (fragment !== null && !getUnlockedKeys(mailboxUid)) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
fragments.set(uid, { mailboxUid, fragment });
|
|
51
|
+
}
|
|
52
|
+
/** Meeting `uid`'s remembered link key - `null` for one known to be unprotected - or `undefined` when none is remembered, or its mailbox
|
|
53
|
+
* isn't unlocked here (a key is never handed out past its mailbox's lock). */
|
|
54
|
+
function rememberedFragment(uid) {
|
|
55
|
+
const entry = fragments.get(uid);
|
|
56
|
+
if (!entry || (entry.fragment !== null && !getUnlockedKeys(entry.mailboxUid))) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
return entry.fragment;
|
|
60
|
+
}
|
|
61
|
+
/** When a meeting ending at `endDate` - the last occurrence's end, for a series that has one - has its key discarded. */
|
|
62
|
+
export function meetingKeyExpiry(endDate, now = Date.now()) {
|
|
63
|
+
return new Date(Math.min(endDate.valueOf() + MEETING_KEY_GRACE_MS, now + MAX_KEY_LIFETIME_MS));
|
|
64
|
+
}
|
|
65
|
+
/** The organizer mailbox's active zero-access key, while the mailbox is unlocked. */
|
|
66
|
+
function organizerKey(mailboxUid) {
|
|
67
|
+
return getUnlockedKeys(mailboxUid)?.zeroAccessKeys?.[0];
|
|
68
|
+
}
|
|
69
|
+
/** What protects a new meeting of `mailboxUid` titled `title` with `invitees`, its key discarded at `expiresAt` - or `undefined` when
|
|
70
|
+
* the mailbox has no zero-access key unlocked here (the meeting is then not protected). `fragment` is every participant's link key. */
|
|
71
|
+
export async function protectMeeting(mailboxUid, title, invitees, expiresAt) {
|
|
72
|
+
const organizer = organizerKey(mailboxUid);
|
|
73
|
+
if (!organizer) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const key = await generateMeetingKey(expiresAt);
|
|
77
|
+
const seal = async (value) => toBase64(await encryptForRecipients(new TextEncoder().encode(JSON.stringify(value)), [key.certificateDer, organizer.certDer]));
|
|
78
|
+
return {
|
|
79
|
+
protection: {
|
|
80
|
+
meetingKey: { certificate: toBase64(key.certificateDer) },
|
|
81
|
+
keyExpiresAt: expiresAt.toISOString(),
|
|
82
|
+
keyWraps: [{ mailboxUid, recipientFingerprint: organizer.fingerprint, kind: "meeting-key", cms: toBase64(await encryptForRecipients(key.pkcs8, [organizer.certDer])) }],
|
|
83
|
+
sealedFields: { title: await seal(title) },
|
|
84
|
+
},
|
|
85
|
+
invitees: await Promise.all(invitees.map(async (invitee) => (invitee.displayName ? { email: invitee.email, sealedFields: { displayName: await seal(invitee.displayName) } } : { email: invitee.email }))),
|
|
86
|
+
fragment: meetingKeyFragment(key.pkcs8),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Remembers a just-created meeting's link key - `null` for one that isn't protected - so the save that invites to it needn't fetch the
|
|
90
|
+
* meeting or reopen its wrap. `mailboxUid` is the organizer's mailbox: its lock forgets the key. */
|
|
91
|
+
export function rememberMeetingKey(meetingUid, fragment, mailboxUid) {
|
|
92
|
+
remember(meetingUid, mailboxUid, fragment);
|
|
93
|
+
}
|
|
94
|
+
/** `meeting`'s link key - remembered, or opened from the organizer's wrap with the mailbox's unlocked zero-access keys - or `undefined`
|
|
95
|
+
* when it isn't protected or can't be opened here. Never rejects. */
|
|
96
|
+
export async function meetingKeyFragmentOf(meeting) {
|
|
97
|
+
if (!meeting.keyProtected) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const known = rememberedFragment(meeting.uid);
|
|
101
|
+
if (known !== undefined) {
|
|
102
|
+
return known ?? undefined;
|
|
103
|
+
}
|
|
104
|
+
const keys = getUnlockedKeys(meeting.mailboxUid)?.zeroAccessKeys;
|
|
105
|
+
const wrap = meeting.keyWraps?.find((candidate) => keys?.some((key) => key.fingerprint === candidate.recipientFingerprint));
|
|
106
|
+
if (!keys?.length || !wrap) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const fragment = meetingKeyFragment(await decryptEnvelopedDataWithKeys(fromBase64(wrap.cms), keys));
|
|
111
|
+
// Locked while it was opened: neither kept nor handed out.
|
|
112
|
+
if (!getUnlockedKeys(meeting.mailboxUid)) {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
remember(meeting.uid, meeting.mailboxUid, fragment);
|
|
116
|
+
return fragment;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** `meeting`'s organizer join link, with the meeting key after `#` when it is protected - `null` when there is none, or the key can't
|
|
123
|
+
* be opened here (a protected meeting can't be joined without it). */
|
|
124
|
+
export async function organizerJoinLink(meeting) {
|
|
125
|
+
if (!meeting.organizerJoinUrl) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
if (!meeting.keyProtected) {
|
|
129
|
+
return meeting.organizerJoinUrl;
|
|
130
|
+
}
|
|
131
|
+
const fragment = await meetingKeyFragmentOf(meeting);
|
|
132
|
+
return fragment ? `${meeting.organizerJoinUrl}#${fragment}` : null;
|
|
133
|
+
}
|
|
134
|
+
/** For a save of `event` that may invite: every attendee's link key (`invitationContent.meetingLinkSecrets`), when its video meeting is
|
|
135
|
+
* protected and its key opens here - else `undefined`. Never rejects. */
|
|
136
|
+
export async function meetingLinkSecrets(event, client) {
|
|
137
|
+
if (!event.videoMeetingUid) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const known = rememberedFragment(event.videoMeetingUid);
|
|
142
|
+
const fragment = known !== undefined ? known : await meetingKeyFragmentOf(await getVideoMeeting(event.videoMeetingUid, client));
|
|
143
|
+
if (!fragment) {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
const organizer = event.organizer?.address.trim().toLowerCase();
|
|
147
|
+
const secrets = {};
|
|
148
|
+
for (const attendee of event.attendees ?? []) {
|
|
149
|
+
const address = attendee.address.trim().toLowerCase();
|
|
150
|
+
if (address && address !== organizer) {
|
|
151
|
+
secrets[address] = fragment;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return Object.keys(secrets).length > 0 ? secrets : undefined;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Moves the key expiry of meeting `meetingUid` - when it is protected - to follow its event's new end, `endDate` (the last occurrence's
|
|
162
|
+
* end, for a series): its key is discarded an hour after the end it was made for, so a meeting moved later (or a series extended) would
|
|
163
|
+
* otherwise be unjoinable by then. Best effort, after the event itself is saved: a failure is logged, never thrown.
|
|
164
|
+
*/
|
|
165
|
+
export async function followMeetingKeyExpiry(meetingUid, endDate, client) {
|
|
166
|
+
try {
|
|
167
|
+
if (fragments.get(meetingUid)?.fragment === null) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const meeting = await getVideoMeeting(meetingUid, client);
|
|
171
|
+
const keyExpiresAt = meetingKeyExpiry(endDate);
|
|
172
|
+
// Unprotected, or already right to the minute (an unchanged end).
|
|
173
|
+
if (!meeting.keyProtected || (meeting.keyExpiresAt && Math.abs(new Date(meeting.keyExpiresAt).valueOf() - keyExpiresAt.valueOf()) < 60000)) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
await updateVideoMeeting(meetingUid, { keyExpiresAt: keyExpiresAt.toISOString() }, client);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
console.warn("Could not move the video meeting's key expiry to the event's new end", err);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* Response bodies are typed, not runtime-validated, matching this library's convention for a plain typed
|
|
17
17
|
* `apiFetch<T>()` call (see `crypto/signingProviderApi.ts`'s own note on the same choice).
|
|
18
18
|
*/
|
|
19
|
+
import type { MeetingProtectionInput } from "./protectedMeetings.js";
|
|
19
20
|
import { ApiClient } from "../util/api.js";
|
|
20
21
|
import { ListParams } from "../util/apiQuery.js";
|
|
21
22
|
export type { ListParams };
|
|
@@ -38,6 +39,16 @@ export interface VideoMeeting {
|
|
|
38
39
|
* (its oldest non-cancelled public meeting - see that app's own doc comment) out of `listVideoMeetings()`'s
|
|
39
40
|
* results. */
|
|
40
41
|
dateCreated: string;
|
|
42
|
+
/** Protected end to end (meet-plugin `specs/meeting_privacy.md`): `title` is `"Meeting"`, the real one sealed, and every link works
|
|
43
|
+
* only with the meeting key after `#` - see `protectedMeetings.ts`. */
|
|
44
|
+
keyProtected?: boolean | null;
|
|
45
|
+
/** A protected meeting's key, sealed to the organizer's zero-access certificate. */
|
|
46
|
+
keyWraps?: {
|
|
47
|
+
mailboxUid: string;
|
|
48
|
+
recipientFingerprint: string;
|
|
49
|
+
kind: string;
|
|
50
|
+
cms: string;
|
|
51
|
+
}[] | null;
|
|
41
52
|
}
|
|
42
53
|
/**
|
|
43
54
|
* A meeting as fetched by `getVideoMeeting()` or listed by `listVideoMeetings()` — the entity plus whichever join
|
|
@@ -67,8 +78,9 @@ export interface VideoMeetingInviteeJoinInfo extends VideoMeetingInvitee {
|
|
|
67
78
|
export interface CreateVideoMeetingInput {
|
|
68
79
|
/** The mailbox that owns the meeting — the one whose ACL every later call on it is checked against. */
|
|
69
80
|
mailboxUid: string;
|
|
70
|
-
/** Non-empty, at most 200 characters (the server rejects anything else with a 400).
|
|
71
|
-
|
|
81
|
+
/** Non-empty, at most 200 characters (the server rejects anything else with a 400). Omitted for a protected meeting, whose title is
|
|
82
|
+
* sealed in `protection`. */
|
|
83
|
+
title?: string;
|
|
72
84
|
visibility: VideoMeetingVisibility;
|
|
73
85
|
/** The `CalendarEvent` this meeting is being minted for, when there is one. */
|
|
74
86
|
calendarEventUid?: string;
|
|
@@ -77,7 +89,14 @@ export interface CreateVideoMeetingInput {
|
|
|
77
89
|
/** Required, and non-empty, for a `"private"` meeting — the server mints one personal join link per entry
|
|
78
90
|
* and 400s a private meeting with none. Never send the organizer here: they reach their own meeting
|
|
79
91
|
* through ownership (and `organizerJoinUrl`), not as a guest. */
|
|
80
|
-
invitees?: VideoMeetingInvitee
|
|
92
|
+
invitees?: (VideoMeetingInvitee | {
|
|
93
|
+
email: string;
|
|
94
|
+
sealedFields?: {
|
|
95
|
+
displayName: string;
|
|
96
|
+
};
|
|
97
|
+
})[];
|
|
98
|
+
/** Makes the meeting protected end to end - `protectMeeting()` in `protectedMeetings.ts`. */
|
|
99
|
+
protection?: MeetingProtectionInput;
|
|
81
100
|
}
|
|
82
101
|
export interface VideoMeetingCreateResult {
|
|
83
102
|
meeting: VideoMeeting;
|
|
@@ -101,8 +120,10 @@ export declare function createVideoMeeting(input: CreateVideoMeetingInput, clien
|
|
|
101
120
|
export interface UpdateVideoMeetingInput {
|
|
102
121
|
title?: string;
|
|
103
122
|
status?: "cancelled";
|
|
123
|
+
/** A protected meeting's new key expiry (ISO), when its event's end moved - `followMeetingKeyExpiry()` in `protectedMeetings.ts`. */
|
|
124
|
+
keyExpiresAt?: string;
|
|
104
125
|
}
|
|
105
|
-
/** Renames or
|
|
126
|
+
/** Renames, cancels or moves the key expiry of a meeting. Requires UPDATE on its owning mailbox. */
|
|
106
127
|
export declare function updateVideoMeeting(uid: string, input: UpdateVideoMeetingInput, client?: ApiClient): Promise<VideoMeeting>;
|
|
107
128
|
/** Fetches one meeting, with the organizer's own join link when it has one. Requires READ on its owning mailbox. */
|
|
108
129
|
export declare function getVideoMeeting(uid: string, client?: ApiClient): Promise<VideoMeetingDetail>;
|
|
@@ -29,7 +29,7 @@ import { buildQuery } from "../util/apiQuery.js";
|
|
|
29
29
|
export function createVideoMeeting(input, client) {
|
|
30
30
|
return withClient(client, "/mail/video-meetings", { method: "POST", body: JSON.stringify(input) });
|
|
31
31
|
}
|
|
32
|
-
/** Renames or
|
|
32
|
+
/** Renames, cancels or moves the key expiry of a meeting. Requires UPDATE on its owning mailbox. */
|
|
33
33
|
export function updateVideoMeeting(uid, input, client) {
|
|
34
34
|
return withClient(client, `/mail/video-meetings/${encodeURIComponent(uid)}`, { method: "PUT", body: JSON.stringify(input) });
|
|
35
35
|
}
|