@rapidmx/web-client 0.35.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/escrow/matters/[uid].tsx +4 -3
- package/apps/shared/calendar/calendarReminders.ts +35 -0
- package/apps/shared/calendar/sealedEvents.ts +113 -0
- 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 +89 -3
- 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 +8 -4
- 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 +30 -1
- package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +122 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
- package/apps/shared/components/mail/ConversationList.tsx +1 -1
- package/apps/shared/components/mail/InviteCard.tsx +11 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +59 -26
- 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/draftResume.ts +28 -3
- package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
- package/apps/shared/components/mail/compose/quotedBody.ts +4 -3
- package/apps/shared/components/mail/invite/inviteStore.ts +17 -1
- package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
- package/apps/shared/components/mail/pinnedSigners.ts +14 -0
- package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
- package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
- package/apps/shared/components/settings/KeyExportSetting.tsx +173 -0
- package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
- package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
- package/apps/shared/components/settings/ZeroAccessSetting.tsx +128 -0
- package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
- package/apps/shared/mail/decryptedMessages.ts +11 -3
- package/apps/shared/mail/outbox/sendJob.ts +349 -50
- package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
- package/apps/shared/mail/reportNotices.ts +3 -1
- package/apps/shared/mail/zeroAccessSource.ts +27 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +84 -24
- package/apps/shared/search/localIndexBuilder.ts +57 -20
- package/apps/shared/search/localIndexRpcClient.ts +96 -12
- package/apps/shared/search/localIndexSchema.ts +20 -4
- package/apps/shared/search/localIndexTransport.ts +6 -2
- package/apps/shared/search/localIndexWorker.ts +37 -18
- package/apps/shared/search/searchTier2.ts +5 -0
- package/apps/shared/search/zeroAccessIndexSync.ts +679 -0
- 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 +225 -61
- package/apps/www/contacts/index.tsx +67 -11
- package/apps/www/index.tsx +19 -3
- package/apps/www/settings/encryption/index.tsx +21 -1
- package/apps/www/settings/privacy/index.tsx +89 -39
- package/apps/www/tasks/index.tsx +24 -6
- package/dist/apps/escrow/matters/[uid].js +2 -2
- 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 +14 -0
- package/dist/apps/shared/calendar/sealedEvents.js +85 -0
- 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 +25 -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 +8 -4
- 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 +27 -2
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +17 -0
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +98 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
- package/dist/apps/shared/components/mail/ConversationList.js +1 -1
- package/dist/apps/shared/components/mail/InviteCard.js +11 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +46 -21
- 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/draftResume.d.ts +2 -0
- package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
- 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/compose/quotedBody.d.ts +1 -1
- package/dist/apps/shared/components/mail/compose/quotedBody.js +4 -3
- package/dist/apps/shared/components/mail/invite/inviteStore.js +16 -1
- package/dist/apps/shared/components/mail/layout/MailShell.js +2 -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/mail/reading/useMessageActions.js +4 -1
- package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
- package/dist/apps/shared/components/settings/KeyExportSetting.js +90 -0
- 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/PolicyLockedNotice.d.ts +11 -0
- package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +16 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.js +80 -0
- 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/decryptedMessages.d.ts +1 -1
- package/dist/apps/shared/mail/decryptedMessages.js +11 -3
- 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/mail/reportNotices.js +3 -1
- package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
- package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +74 -24
- package/dist/apps/shared/search/localIndexBuilder.d.ts +9 -1
- package/dist/apps/shared/search/localIndexBuilder.js +50 -17
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
- package/dist/apps/shared/search/localIndexRpcClient.js +129 -11
- package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
- package/dist/apps/shared/search/localIndexSchema.js +18 -4
- package/dist/apps/shared/search/localIndexTransport.d.ts +6 -2
- package/dist/apps/shared/search/localIndexWorker.d.ts +11 -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 +102 -0
- package/dist/apps/shared/search/zeroAccessIndexSync.js +544 -0
- 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 +88 -20
- package/dist/apps/www/contacts/index.js +64 -11
- package/dist/apps/www/index.js +19 -3
- package/dist/apps/www/settings/encryption/index.js +16 -4
- package/dist/apps/www/settings/privacy/index.js +47 -6
- package/dist/apps/www/tasks/index.js +21 -7
- package/dist/lib/admin/domainsApi.d.ts +1 -1
- package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
- package/dist/lib/calendar/calendarApi.d.ts +52 -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 +86 -7
- 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 +9 -0
- package/dist/lib/calendar/sealedInvites.js +65 -0
- 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.d.ts +54 -0
- package/dist/lib/crypto/keyExport.js +161 -0
- package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
- package/dist/lib/crypto/keyPairMatch.js +65 -0
- package/dist/lib/crypto/keySession.d.ts +64 -3
- package/dist/lib/crypto/keySession.js +179 -12
- package/dist/lib/crypto/keyvaultApi.d.ts +129 -8
- package/dist/lib/crypto/keyvaultApi.js +45 -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 +56 -3
- package/dist/lib/crypto/messageSecurity.js +201 -13
- package/dist/lib/crypto/mime.d.ts +5 -0
- package/dist/lib/crypto/mime.js +25 -0
- 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 +62 -0
- package/dist/lib/crypto/sharedMailboxKeys.js +337 -0
- package/dist/lib/crypto/smimeMessage.d.ts +21 -0
- package/dist/lib/crypto/smimeMessage.js +11 -7
- package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
- package/dist/lib/crypto/zeroAccessKey.js +71 -0
- package/dist/lib/mail/conversationsApi.d.ts +6 -0
- package/dist/lib/mail/mailApi.d.ts +63 -2
- package/dist/lib/mail/mailApi.js +5 -1
- package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
- package/dist/lib/mail/mailboxImportApi.js +15 -0
- package/dist/lib/search/zeroAccessIndexApi.d.ts +85 -0
- package/dist/lib/search/zeroAccessIndexApi.js +35 -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/admin/escrowAccessRequestsApi.ts +6 -0
- package/lib/calendar/calendarApi.ts +143 -17
- package/lib/calendar/calendarMutations.ts +79 -12
- package/lib/calendar/inviteApi.ts +101 -8
- package/lib/calendar/protectedEvents.ts +143 -0
- package/lib/calendar/recurrence.ts +17 -0
- package/lib/calendar/sealedInvites.ts +73 -0
- 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 +229 -0
- package/lib/crypto/keyPairMatch.ts +69 -0
- package/lib/crypto/keySession.ts +216 -12
- package/lib/crypto/keyvaultApi.ts +174 -10
- package/lib/crypto/meetingKeys.ts +118 -0
- package/lib/crypto/messageSecurity.ts +284 -16
- package/lib/crypto/mime.ts +26 -0
- 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 +402 -0
- package/lib/crypto/smimeMessage.ts +11 -7
- package/lib/crypto/zeroAccessKey.ts +102 -0
- package/lib/mail/conversationsApi.ts +3 -0
- package/lib/mail/mailApi.ts +60 -3
- package/lib/mail/mailboxImportApi.ts +24 -0
- package/lib/search/zeroAccessIndexApi.ts +123 -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 +14 -5
|
@@ -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
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A calendar event, contact or task whose content is sealed to its mailbox's zero-access key - field by field (`sealedFields`), as one
|
|
3
|
+
* blob (`sealedContent`, an event from a zero-access invitation) or its photo (`photoSealed`) - can't move to another mailbox: what it
|
|
4
|
+
* keeps sealed is addressed to the mailbox it is in, and the server can't seal it again to the destination's key. restapi refuses such a
|
|
5
|
+
* move with a `409` (`ZeroAccessRouteSealing.refuseSealedMove()`).
|
|
6
|
+
*
|
|
7
|
+
* The record API wrappers (`updateContact()`, `updateCalendarEvent()`, `updateTask()`) refuse it before sending anything when they know the
|
|
8
|
+
* record (`guardSealedMove()`), and turn the server's refusal into the same `SealedMoveError` when they don't - one request, so nothing is
|
|
9
|
+
* left half moved. A UI offering a move asks `canMoveToMailbox()` first, and shows `SealedMoveError`'s message.
|
|
10
|
+
*/
|
|
11
|
+
import { ApiRequestError } from "../util/api.js";
|
|
12
|
+
/** The fields of a record that say whether its content is sealed, and where. */
|
|
13
|
+
export interface MovableRecord {
|
|
14
|
+
mailboxUid: string;
|
|
15
|
+
sealedFields?: Record<string, string> | null;
|
|
16
|
+
sealedContent?: unknown;
|
|
17
|
+
photoSealed?: boolean | null;
|
|
18
|
+
}
|
|
19
|
+
/** What a refused move says. */
|
|
20
|
+
export declare const SEALED_MOVE_MESSAGE = "This item is protected by its mailbox's zero-access encryption, so it can't be moved to another mailbox. Create a copy there instead.";
|
|
21
|
+
/** `SealedMoveError.code`. */
|
|
22
|
+
export declare const SEALED_MOVE_CODE = "sealed-move";
|
|
23
|
+
/** restapi's `ApiErrors.INVALID_REQUEST`, the code of its sealed-move refusal and of its other zero-access refusals (a version conflict,
|
|
24
|
+
* also a `409`, is `api-012`; an identifier already taken, `api-011`). */
|
|
25
|
+
export declare const INVALID_REQUEST_CODE = "api-003";
|
|
26
|
+
/** A move of a protected record to another mailbox, refused - by this client before sending it, or by the server. Nothing was written. */
|
|
27
|
+
export declare class SealedMoveError extends ApiRequestError {
|
|
28
|
+
constructor(message?: string);
|
|
29
|
+
}
|
|
30
|
+
/** Whether `record` keeps any of its content sealed to its mailbox's key. */
|
|
31
|
+
export declare function isSealedRecord(record: MovableRecord): boolean;
|
|
32
|
+
/** Whether `record` may move to mailbox `targetMailboxUid`: always within its own mailbox, and to another only when nothing of it is sealed. */
|
|
33
|
+
export declare function canMoveToMailbox(record: MovableRecord, targetMailboxUid: string): boolean;
|
|
34
|
+
/** Whether `err` is restapi's refusal of a sealed record's move. */
|
|
35
|
+
export declare function isSealedMoveRefusal(err: unknown): err is ApiRequestError;
|
|
36
|
+
/**
|
|
37
|
+
* Runs `write`, a save of a record that sets its `mailboxUid` to `targetMailboxUid` (`undefined`: the save doesn't set it). With `current`,
|
|
38
|
+
* the record as this device has it, a move of a sealed one is refused before anything is sent. A move the server refuses for the same reason
|
|
39
|
+
* rejects with `SealedMoveError` too.
|
|
40
|
+
*/
|
|
41
|
+
export declare function guardSealedMove<T>(current: MovableRecord | undefined, targetMailboxUid: string | undefined, write: () => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* A calendar event, contact or task whose content is sealed to its mailbox's zero-access key - field by field (`sealedFields`), as one
|
|
7
|
+
* blob (`sealedContent`, an event from a zero-access invitation) or its photo (`photoSealed`) - can't move to another mailbox: what it
|
|
8
|
+
* keeps sealed is addressed to the mailbox it is in, and the server can't seal it again to the destination's key. restapi refuses such a
|
|
9
|
+
* move with a `409` (`ZeroAccessRouteSealing.refuseSealedMove()`).
|
|
10
|
+
*
|
|
11
|
+
* The record API wrappers (`updateContact()`, `updateCalendarEvent()`, `updateTask()`) refuse it before sending anything when they know the
|
|
12
|
+
* record (`guardSealedMove()`), and turn the server's refusal into the same `SealedMoveError` when they don't - one request, so nothing is
|
|
13
|
+
* left half moved. A UI offering a move asks `canMoveToMailbox()` first, and shows `SealedMoveError`'s message.
|
|
14
|
+
*/
|
|
15
|
+
import { ApiRequestError } from "../util/api.js";
|
|
16
|
+
/** What a refused move says. */
|
|
17
|
+
export const SEALED_MOVE_MESSAGE = "This item is protected by its mailbox's zero-access encryption, so it can't be moved to another mailbox. Create a copy there instead.";
|
|
18
|
+
/** `SealedMoveError.code`. */
|
|
19
|
+
export const SEALED_MOVE_CODE = "sealed-move";
|
|
20
|
+
/** restapi's `ApiErrors.INVALID_REQUEST`, the code of its sealed-move refusal and of its other zero-access refusals (a version conflict,
|
|
21
|
+
* also a `409`, is `api-012`; an identifier already taken, `api-011`). */
|
|
22
|
+
export const INVALID_REQUEST_CODE = "api-003";
|
|
23
|
+
/** A move of a protected record to another mailbox, refused - by this client before sending it, or by the server. Nothing was written. */
|
|
24
|
+
export class SealedMoveError extends ApiRequestError {
|
|
25
|
+
constructor(message = SEALED_MOVE_MESSAGE) {
|
|
26
|
+
super(message, 409, SEALED_MOVE_CODE);
|
|
27
|
+
this.name = "SealedMoveError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Whether `record` keeps any of its content sealed to its mailbox's key. */
|
|
31
|
+
export function isSealedRecord(record) {
|
|
32
|
+
return (!!record.sealedFields && Object.keys(record.sealedFields).length > 0) || !!record.sealedContent || !!record.photoSealed;
|
|
33
|
+
}
|
|
34
|
+
/** Whether `record` may move to mailbox `targetMailboxUid`: always within its own mailbox, and to another only when nothing of it is sealed. */
|
|
35
|
+
export function canMoveToMailbox(record, targetMailboxUid) {
|
|
36
|
+
return targetMailboxUid === record.mailboxUid || !isSealedRecord(record);
|
|
37
|
+
}
|
|
38
|
+
/** Whether `err` is restapi's refusal of a sealed record's move. */
|
|
39
|
+
export function isSealedMoveRefusal(err) {
|
|
40
|
+
return err instanceof SealedMoveError || (err instanceof ApiRequestError && err.status === 409 && err.code === INVALID_REQUEST_CODE);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Runs `write`, a save of a record that sets its `mailboxUid` to `targetMailboxUid` (`undefined`: the save doesn't set it). With `current`,
|
|
44
|
+
* the record as this device has it, a move of a sealed one is refused before anything is sent. A move the server refuses for the same reason
|
|
45
|
+
* rejects with `SealedMoveError` too.
|
|
46
|
+
*/
|
|
47
|
+
export async function guardSealedMove(current, targetMailboxUid, write) {
|
|
48
|
+
if (targetMailboxUid === undefined) {
|
|
49
|
+
return write();
|
|
50
|
+
}
|
|
51
|
+
if (current && !canMoveToMailbox(current, targetMailboxUid)) {
|
|
52
|
+
throw new SealedMoveError();
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
return await write();
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
if (current?.mailboxUid !== targetMailboxUid && isSealedMoveRefusal(err) && !(err instanceof SealedMoveError)) {
|
|
59
|
+
throw new SealedMoveError();
|
|
60
|
+
}
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** A record that may carry sealed fields. */
|
|
2
|
+
export interface SealedRecord {
|
|
3
|
+
mailboxUid: string;
|
|
4
|
+
/** Protected field name -> base64 CMS of its JSON value. */
|
|
5
|
+
sealedFields?: Record<string, string> | null;
|
|
6
|
+
}
|
|
7
|
+
/** Forgets every opened value (a mailbox locking, or the test seam). */
|
|
8
|
+
export declare function clearSealedRecords(): void;
|
|
9
|
+
/** Whether every protected field of `record` holds its real value - opened on this device - rather than its placeholder. */
|
|
10
|
+
export declare function isOpenedRecord(record: SealedRecord | undefined): boolean;
|
|
11
|
+
/** Whether `record` has protected fields. */
|
|
12
|
+
export declare function isProtectedRecord(record: SealedRecord | undefined): boolean;
|
|
13
|
+
/** `records` with every protected one this device can open shown with its real values - only the fields in `only`, when given (a list
|
|
14
|
+
* shows a contact's name before anyone asks for its phone numbers: each field is one decryption). Never rejects. */
|
|
15
|
+
export declare function openSealedRecords<T extends SealedRecord>(records: T[], only?: readonly string[]): Promise<T[]>;
|
|
16
|
+
/** `der` (a blob the server sealed to `mailboxUid`'s zero-access key, e.g. a protected contact's photo) opened, or `undefined` when this
|
|
17
|
+
* device can't (the mailbox is locked, or it isn't its key). */
|
|
18
|
+
export declare function openSealedBytes(mailboxUid: string, der: Uint8Array): Promise<Uint8Array | undefined>;
|
|
19
|
+
/**
|
|
20
|
+
* `input` as written to `shown` - the record the edit started from, as this device showed it: unchanged when `shown` isn't protected.
|
|
21
|
+
* Otherwise without the protected fields (those `shown.sealedFields` names) whose values are what `shown` has: an opened record's write
|
|
22
|
+
* sends only what changed, and a placeholder a record that couldn't be opened showed is never sent back as its value. A field of an opened
|
|
23
|
+
* record that held a value and is emptied is named in `clearedFields`: restapi applies an empty value of a sealed field as a clear only
|
|
24
|
+
* then (`ZeroAccessRouteSealing.takeClearedFields()`), since an empty placeholder sent back by a client that couldn't open the record would
|
|
25
|
+
* otherwise wipe the sealed value for good. `opened`: `shown` holds the real values (by default, whether it was opened here).
|
|
26
|
+
*/
|
|
27
|
+
export declare function protectedRecordWrite<T extends object>(shown: SealedRecord | undefined, input: T, opened?: boolean): T;
|
|
28
|
+
/** `openSealedRecords()` for one record. */
|
|
29
|
+
export declare function openSealedRecord<T extends SealedRecord>(record: T, only?: readonly string[]): Promise<T>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Calendar events, contacts and tasks of a mailbox whose zero-access encryption covers their type keep their protected fields only
|
|
7
|
+
* sealed, field by field, to the mailbox's zero-access key (`sealedFields`; the readable column holds a placeholder - restapi
|
|
8
|
+
* `specs/search.md` §3 *What It Covers*). They are opened here with the mailbox's unlocked keys; while it is locked a record keeps its
|
|
9
|
+
* placeholders. The decryption code is loaded only when a sealed record is actually seen.
|
|
10
|
+
*
|
|
11
|
+
* Writing needs nothing here: the client sends plaintext and the server seals it on arrival.
|
|
12
|
+
*/
|
|
13
|
+
import { fromBase64 } from "../crypto/encoding.js";
|
|
14
|
+
import { getUnlockedKeys, subscribeKeySession } from "../crypto/keySession.js";
|
|
15
|
+
/** Opened values by sealed text - the same sealed text always opens to the same value. Cleared with `clearSealedRecords()`. */
|
|
16
|
+
const opened = new Map();
|
|
17
|
+
/** Forgets every opened value (a mailbox locking, or the test seam). */
|
|
18
|
+
export function clearSealedRecords() {
|
|
19
|
+
opened.clear();
|
|
20
|
+
}
|
|
21
|
+
/** Set on a record whose every sealed field was opened. A symbol, so it survives a spread copy (an occurrence of an event) and is never sent. */
|
|
22
|
+
const OPENED = Symbol("openedSealedRecord");
|
|
23
|
+
/** Whether every protected field of `record` holds its real value - opened on this device - rather than its placeholder. */
|
|
24
|
+
export function isOpenedRecord(record) {
|
|
25
|
+
return !!record && record[OPENED] === true;
|
|
26
|
+
}
|
|
27
|
+
/** Whether `record` has protected fields. */
|
|
28
|
+
export function isProtectedRecord(record) {
|
|
29
|
+
return !!record?.sealedFields && Object.keys(record.sealedFields).length > 0;
|
|
30
|
+
}
|
|
31
|
+
/** `record` with every sealed field (of `only`, when given) this device can open set to its value; `undefined` when it can't open them
|
|
32
|
+
* (locked, or not its key). */
|
|
33
|
+
async function openRecord(record, only) {
|
|
34
|
+
const keys = getUnlockedKeys(record.mailboxUid)?.zeroAccessKeys;
|
|
35
|
+
if (!keys?.length) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const values = {};
|
|
40
|
+
for (const [name, sealed] of Object.entries(record.sealedFields)) {
|
|
41
|
+
if (only && !only.includes(name)) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (!opened.has(sealed)) {
|
|
45
|
+
const { decryptEnvelopedDataWithKeys } = await import("../crypto/smime.js");
|
|
46
|
+
const value = JSON.parse(new TextDecoder().decode(await decryptEnvelopedDataWithKeys(fromBase64(sealed), keys)));
|
|
47
|
+
// The mailbox locked while this was opened: the lock's clear has already run, so nothing is kept or shown.
|
|
48
|
+
if (!getUnlockedKeys(record.mailboxUid)) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
opened.set(sealed, value);
|
|
52
|
+
}
|
|
53
|
+
values[name] = opened.get(sealed);
|
|
54
|
+
}
|
|
55
|
+
return only ? { ...record, ...values } : { ...record, ...values, [OPENED]: true };
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
let watchingKeys = false;
|
|
62
|
+
/** Opened values go when keys are locked (which signing out does), so nothing opened outlives its key session. Subscribed when the
|
|
63
|
+
* first protected record is opened, not on import. */
|
|
64
|
+
function forgetOnLock() {
|
|
65
|
+
if (watchingKeys) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
watchingKeys = true;
|
|
69
|
+
subscribeKeySession((event) => {
|
|
70
|
+
if (event.state === "locked") {
|
|
71
|
+
clearSealedRecords();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** `records` with every protected one this device can open shown with its real values - only the fields in `only`, when given (a list
|
|
76
|
+
* shows a contact's name before anyone asks for its phone numbers: each field is one decryption). Never rejects. */
|
|
77
|
+
export async function openSealedRecords(records, only) {
|
|
78
|
+
if (!records.some(isProtectedRecord)) {
|
|
79
|
+
return records;
|
|
80
|
+
}
|
|
81
|
+
forgetOnLock();
|
|
82
|
+
return Promise.all(records.map(async (record) => (isProtectedRecord(record) ? ((await openRecord(record, only)) ?? record) : record)));
|
|
83
|
+
}
|
|
84
|
+
/** `der` (a blob the server sealed to `mailboxUid`'s zero-access key, e.g. a protected contact's photo) opened, or `undefined` when this
|
|
85
|
+
* device can't (the mailbox is locked, or it isn't its key). */
|
|
86
|
+
export async function openSealedBytes(mailboxUid, der) {
|
|
87
|
+
const keys = getUnlockedKeys(mailboxUid)?.zeroAccessKeys;
|
|
88
|
+
if (!keys?.length) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const { decryptEnvelopedDataWithKeys } = await import("../crypto/smime.js");
|
|
93
|
+
const bytes = await decryptEnvelopedDataWithKeys(der, keys);
|
|
94
|
+
// Locked while it was opened: not handed out.
|
|
95
|
+
return getUnlockedKeys(mailboxUid) ? bytes : undefined;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** A field's value for comparing what a write sends with what was shown: every empty form (`null`, `undefined`, `""`, `[]`) is one. */
|
|
102
|
+
function comparable(value) {
|
|
103
|
+
return value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0) ? "" : JSON.stringify(value);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* `input` as written to `shown` - the record the edit started from, as this device showed it: unchanged when `shown` isn't protected.
|
|
107
|
+
* Otherwise without the protected fields (those `shown.sealedFields` names) whose values are what `shown` has: an opened record's write
|
|
108
|
+
* sends only what changed, and a placeholder a record that couldn't be opened showed is never sent back as its value. A field of an opened
|
|
109
|
+
* record that held a value and is emptied is named in `clearedFields`: restapi applies an empty value of a sealed field as a clear only
|
|
110
|
+
* then (`ZeroAccessRouteSealing.takeClearedFields()`), since an empty placeholder sent back by a client that couldn't open the record would
|
|
111
|
+
* otherwise wipe the sealed value for good. `opened`: `shown` holds the real values (by default, whether it was opened here).
|
|
112
|
+
*/
|
|
113
|
+
export function protectedRecordWrite(shown, input, opened = isOpenedRecord(shown)) {
|
|
114
|
+
if (!isProtectedRecord(shown)) {
|
|
115
|
+
return input;
|
|
116
|
+
}
|
|
117
|
+
const changes = { ...input };
|
|
118
|
+
const clearedFields = [];
|
|
119
|
+
for (const field of Object.keys(shown.sealedFields)) {
|
|
120
|
+
if (!(field in changes)) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const value = comparable(changes[field]);
|
|
124
|
+
if (value === comparable(shown[field])) {
|
|
125
|
+
delete changes[field];
|
|
126
|
+
}
|
|
127
|
+
else if (value === "" && opened) {
|
|
128
|
+
clearedFields.push(field);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return (clearedFields.length > 0 ? { ...changes, clearedFields } : changes);
|
|
132
|
+
}
|
|
133
|
+
/** `openSealedRecords()` for one record. */
|
|
134
|
+
export async function openSealedRecord(record, only) {
|
|
135
|
+
return (await openSealedRecords([record], only))[0];
|
|
136
|
+
}
|
package/lib/admin/domainsApi.ts
CHANGED
|
@@ -90,7 +90,7 @@ export function verifyDomain(uid: string, client?: ApiClient): Promise<Domain> {
|
|
|
90
90
|
return withClient(client, `/mail/domains/${encodeURIComponent(uid)}/verify`, { method: "POST" });
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
export type DnsRecordType = "ownership" | "mx" | "spf" | "dkim" | "dmarc" | "autodiscover_cname" | "autodiscover_srv";
|
|
93
|
+
export type DnsRecordType = "ownership" | "mx" | "spf" | "dkim" | "dmarc" | "openpgpkey_cname" | "autodiscover_cname" | "autodiscover_srv";
|
|
94
94
|
|
|
95
95
|
/** One mail-related DNS record this server recommends for a domain, live-checked against real DNS —
|
|
96
96
|
* purely diagnostic, never mutates anything (unlike `verifyDomain`). The two `autodiscover_*` types are only
|
|
@@ -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
|