@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
|
@@ -20,9 +20,12 @@ import {
|
|
|
20
20
|
import {
|
|
21
21
|
ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE,
|
|
22
22
|
getUnlockedKeys,
|
|
23
|
+
subscribeKeySession,
|
|
23
24
|
unlockWithPassword,
|
|
24
25
|
type UnlockResult,
|
|
25
26
|
} from "../../../../lib/crypto/keySession.js";
|
|
27
|
+
import { ensureZeroAccessKey, provisionZeroAccessKey } from "../../../../lib/crypto/zeroAccessKey.js";
|
|
28
|
+
import { ensureOpenPgpKey } from "../../../../lib/crypto/openPgpKey.js";
|
|
26
29
|
import { buildAad, generateMasterKey, sealWithKey } from "../../../../lib/crypto/masterKey.js";
|
|
27
30
|
import { buildPasswordWrap, buildRecoveryWraps } from "../../../../lib/crypto/masterKeyWraps.js";
|
|
28
31
|
import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "../../../../lib/crypto/keys.js";
|
|
@@ -46,13 +49,17 @@ async function provisionEncryptionKey(
|
|
|
46
49
|
const privateKeyRaw = await exportPrivateKeyPkcs8(keyPair.privateKey);
|
|
47
50
|
const wrappedKeySealed = await sealWithKey(mk, privateKeyRaw, buildAad(mailboxUid, ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE));
|
|
48
51
|
|
|
49
|
-
await enrollKey(mailboxUid, {
|
|
52
|
+
const vault = await enrollKey(mailboxUid, {
|
|
50
53
|
useType: "encrypt",
|
|
51
54
|
csr: csrPem,
|
|
52
55
|
wrappedKey: { ciphertext: wrappedKeySealed.ciphertext, nonce: wrappedKeySealed.nonce, algorithm: "AES-256-GCM" },
|
|
53
56
|
masterKeyWraps: [passwordWrap, ...recoveryWraps],
|
|
54
57
|
});
|
|
55
58
|
|
|
59
|
+
// The zero-access key (restapi specs/search.md §3), under the same master key. Not fatal: setup is done once the
|
|
60
|
+
// encryption key is in, and a mailbox without a zero-access key gets one on its next unlock (`ensureZeroAccessKey()`).
|
|
61
|
+
await provisionZeroAccessKey(mailboxUid, mailboxAddress, mk, vault.masterKeyGeneration).catch(() => undefined);
|
|
62
|
+
|
|
56
63
|
return { recoveryCodes };
|
|
57
64
|
}
|
|
58
65
|
|
|
@@ -154,6 +161,28 @@ export default function KeyEnrollmentGate({
|
|
|
154
161
|
};
|
|
155
162
|
}, [mailboxUid, canProvision]);
|
|
156
163
|
|
|
164
|
+
// A mailbox set up before zero-access encryption existed has no zero-access key, and one set up before OpenPGP was supported
|
|
165
|
+
// has no OpenPGP key (restapi specs/end-to-end_encryption.md *OpenPGP Interoperability*): give it each whenever its owner has
|
|
166
|
+
// it unlocked - now, or on a later unlock from anywhere (this gate, an unlock prompt, recovery, first-time setup). Best
|
|
167
|
+
// effort: a failure is retried on the next unlock.
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (!mailboxUid || !mailboxAddress || !canProvision) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// One after the other: both enrollments update the vault, which a concurrent write would refuse (409).
|
|
173
|
+
const ensure = () =>
|
|
174
|
+
void ensureZeroAccessKey(mailboxUid, mailboxAddress)
|
|
175
|
+
.catch(() => undefined)
|
|
176
|
+
.then(() => ensureOpenPgpKey(mailboxUid, mailboxAddress))
|
|
177
|
+
.catch(() => undefined);
|
|
178
|
+
ensure();
|
|
179
|
+
return subscribeKeySession((event) => {
|
|
180
|
+
if (event.mailboxUid === mailboxUid && event.state === "unlocked") {
|
|
181
|
+
ensure();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}, [mailboxUid, mailboxAddress, canProvision]);
|
|
185
|
+
|
|
157
186
|
async function handleUnlock(e: React.FormEvent) {
|
|
158
187
|
e.preventDefault();
|
|
159
188
|
setError(null);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect, useRef } from "react";
|
|
6
|
+
import { getUnlockedKeys, personallyUnlockedKeys, subscribeKeySession } from "../../../../lib/crypto/keySession.js";
|
|
7
|
+
import type { Mailbox } from "../../../../lib/mail/mailApi.js";
|
|
8
|
+
import type { ApiClient } from "../../../../lib/util/api.js";
|
|
9
|
+
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
10
|
+
import { notify } from "../../notifications/store.js";
|
|
11
|
+
|
|
12
|
+
/** How often the shared mailboxes are looked after again while some own mailbox stays unlocked. */
|
|
13
|
+
export const SHARED_MAILBOX_CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
/** The shared mailbox key code (pkijs and friends), loaded only once an own mailbox is unlocked. */
|
|
16
|
+
let loaded: Promise<typeof import("../../../../lib/crypto/sharedMailboxKeys.js")> | undefined;
|
|
17
|
+
const loadSharedKeys = () =>
|
|
18
|
+
(loaded ??= import("../../../../lib/crypto/sharedMailboxKeys.js").catch((err) => {
|
|
19
|
+
loaded = undefined;
|
|
20
|
+
throw err;
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
/** The refused rotations already shown, per mailbox (the reason): each is shown once a session, not on every pass that retries it. */
|
|
24
|
+
const shownRefusals = new Map<string, string>();
|
|
25
|
+
|
|
26
|
+
/** Forgets which refused rotations were shown (tests). */
|
|
27
|
+
export function resetSharedMailboxKeyManager(): void {
|
|
28
|
+
shownRefusals.clear();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SharedMailboxKeyManagerProps {
|
|
32
|
+
/** Every mailbox the user can open. */
|
|
33
|
+
mailboxes: Mailbox[];
|
|
34
|
+
userUid?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Looks after the zero-access keys of the user's shared mailboxes (`lib/crypto/sharedMailboxKeys.ts`) whenever a mailbox of their own is
|
|
39
|
+
* unlocked, and every `SHARED_MAILBOX_CHECK_INTERVAL_MS` after that: opens each mailbox shared with them through their member key wraps (no
|
|
40
|
+
* password), and - for a mailbox they own or manage - provisions, rotates and shares its key with members still waiting. Best effort and
|
|
41
|
+
* silent (a pass that fails is tried again on the next), except for a rotation the server refuses, shown once as a warning. Renders nothing; mounted by `MailShell`.
|
|
42
|
+
*/
|
|
43
|
+
export default function SharedMailboxKeyManager({ mailboxes, userUid }: SharedMailboxKeyManagerProps) {
|
|
44
|
+
const client = useApiClient();
|
|
45
|
+
const latest = useRef({ mailboxes, userUid, client });
|
|
46
|
+
latest.current = { mailboxes, userUid, client };
|
|
47
|
+
const running = useRef(false);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
async function pass(): Promise<void> {
|
|
51
|
+
if (running.current || personallyUnlockedKeys().length === 0) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
running.current = true;
|
|
55
|
+
try {
|
|
56
|
+
const { maintainSharedMailbox, unlockSharedMailbox } = await loadSharedKeys();
|
|
57
|
+
const { mailboxes: current, userUid: me, client: apiClient } = latest.current;
|
|
58
|
+
for (const mailbox of current) {
|
|
59
|
+
await look(mailbox, me, apiClient, unlockSharedMailbox, maintainSharedMailbox).catch(() => undefined);
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// Nothing to do until the next pass.
|
|
63
|
+
} finally {
|
|
64
|
+
running.current = false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
void pass();
|
|
68
|
+
const unsubscribe = subscribeKeySession((event) => {
|
|
69
|
+
if (event.state === "unlocked" && !getUnlockedKeys(event.mailboxUid)?.shared) {
|
|
70
|
+
void pass();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
const interval = setInterval(() => void pass(), SHARED_MAILBOX_CHECK_INTERVAL_MS);
|
|
74
|
+
return () => {
|
|
75
|
+
unsubscribe();
|
|
76
|
+
clearInterval(interval);
|
|
77
|
+
};
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function look(
|
|
84
|
+
mailbox: Mailbox,
|
|
85
|
+
me: string | undefined,
|
|
86
|
+
client: ApiClient | undefined,
|
|
87
|
+
unlockSharedMailbox: (uid: string, client?: ApiClient, refresh?: boolean) => Promise<string>,
|
|
88
|
+
maintainSharedMailbox: (
|
|
89
|
+
uid: string,
|
|
90
|
+
address: string,
|
|
91
|
+
client?: ApiClient,
|
|
92
|
+
) => Promise<{ provisioned: boolean; rotated: boolean; rotationRefused?: string }>,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
const owned = !!mailbox.ownerUserUid && mailbox.ownerUserUid === me;
|
|
95
|
+
if (!owned) {
|
|
96
|
+
// Opened, or - once open - opened again when a rotation left it without the active key.
|
|
97
|
+
await unlockSharedMailbox(mailbox.uid, client, true);
|
|
98
|
+
}
|
|
99
|
+
const unlocked = getUnlockedKeys(mailbox.uid);
|
|
100
|
+
// An owner looks after their mailbox with its own keys; a member, with full access, whether or not it is open yet (a mailbox
|
|
101
|
+
// without an owner may have no key to open).
|
|
102
|
+
if (owned ? unlocked && !unlocked.shared : true) {
|
|
103
|
+
const done = await maintainSharedMailbox(mailbox.uid, mailbox.primarySmtpAddress, client);
|
|
104
|
+
// The server kept the current key (its vault can't take the new key's member wraps): say so once, members keep reading.
|
|
105
|
+
if (done.rotationRefused && shownRefusals.get(mailbox.uid) !== done.rotationRefused) {
|
|
106
|
+
shownRefusals.set(mailbox.uid, done.rotationRefused);
|
|
107
|
+
notify({
|
|
108
|
+
kind: "warning",
|
|
109
|
+
title: `${mailbox.displayName || mailbox.primarySmtpAddress}: the zero-access key wasn't replaced`,
|
|
110
|
+
message: "A member has left, but the server refused a new key for this shared mailbox. The current key stays active.",
|
|
111
|
+
details: done.rotationRefused,
|
|
112
|
+
dedupeKey: `shared-rotation-refused:${mailbox.uid}`,
|
|
113
|
+
});
|
|
114
|
+
} else if (!done.rotationRefused) {
|
|
115
|
+
shownRefusals.delete(mailbox.uid);
|
|
116
|
+
}
|
|
117
|
+
// A key this member just made (a mailbox without an owner) goes into their session now, not on the next pass.
|
|
118
|
+
if (!owned && (done.provisioned || done.rotated)) {
|
|
119
|
+
await unlockSharedMailbox(mailbox.uid, client, true);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -15,6 +15,7 @@ import Modal from "../../../../lib/components/overlays/Modal.js";
|
|
|
15
15
|
import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
16
16
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
17
17
|
import FormField from "../../../../lib/components/forms/FormField.js";
|
|
18
|
+
import { notify } from "../../notifications/store.js";
|
|
18
19
|
import {
|
|
19
20
|
RECOVERY_CODE_ERROR,
|
|
20
21
|
RecoveryFollowUp,
|
|
@@ -98,6 +99,34 @@ const UnlockPromptContext = createContext<UnlockPromptContextValue | null>(null)
|
|
|
98
99
|
* mailbox happens to resolve - see `KeyEnrollmentGate`'s `blocking` prop, which `MailShell` now sets to
|
|
99
100
|
* `false` for exactly this reason.
|
|
100
101
|
*/
|
|
102
|
+
/** Messages for a shared mailbox `openShared()` can't open. */
|
|
103
|
+
export const SHARED_ACCESS_PENDING_MESSAGE = "This mailbox is shared with you, and is waiting for another member to share its key with you. Try again shortly.";
|
|
104
|
+
export const SHARED_OWN_KEYS_LOCKED_MESSAGE = "Unlock your own mailbox first: it holds the key that opens the mailboxes shared with you.";
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Opens `mailboxUid` through the user's member key wraps when it is a mailbox shared with them (`sharedMailboxKeys.ts`): its keys, or
|
|
108
|
+
* `undefined` when it isn't one of theirs (the password prompt applies). Rejects, after saying why, when it is shared with them but can't
|
|
109
|
+
* open yet. The shared key code is loaded only here.
|
|
110
|
+
*/
|
|
111
|
+
async function openShared(mailboxUid: string): Promise<UnlockedKeys | undefined> {
|
|
112
|
+
let outcome: string;
|
|
113
|
+
try {
|
|
114
|
+
const { unlockSharedMailbox } = await import("../../../../lib/crypto/sharedMailboxKeys.js");
|
|
115
|
+
outcome = await unlockSharedMailbox(mailboxUid);
|
|
116
|
+
} catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
if (outcome === "unlocked") {
|
|
120
|
+
return getUnlockedKeys(mailboxUid);
|
|
121
|
+
}
|
|
122
|
+
if (outcome === "pending" || outcome === "own_keys_locked") {
|
|
123
|
+
const message = outcome === "pending" ? SHARED_ACCESS_PENDING_MESSAGE : SHARED_OWN_KEYS_LOCKED_MESSAGE;
|
|
124
|
+
notify({ kind: "info", title: "Shared mailbox", message });
|
|
125
|
+
throw new Error(message);
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
101
130
|
export function UnlockPromptProvider({ children }: { children: React.ReactNode }) {
|
|
102
131
|
const [pending, setPendingState] = useState<PendingUnlock | null>(null);
|
|
103
132
|
// Mirrors `pending` synchronously, so two requests issued in the same tick see each other.
|
|
@@ -122,6 +151,11 @@ export function UnlockPromptProvider({ children }: { children: React.ReactNode }
|
|
|
122
151
|
if (existing) {
|
|
123
152
|
return Promise.resolve(existing);
|
|
124
153
|
}
|
|
154
|
+
// A mailbox shared with the user opens through their member key wraps, not a password - and its password, if it has one, isn't theirs.
|
|
155
|
+
return openShared(mailboxUid).then((shared) => shared ?? promptForPassword(mailboxUid, mailboxKeys));
|
|
156
|
+
}, []);
|
|
157
|
+
|
|
158
|
+
const promptForPassword = useCallback((mailboxUid: string, mailboxKeys: PublicKey[]): Promise<UnlockedKeys> => {
|
|
125
159
|
return new Promise<UnlockedKeys>((resolve, reject) => {
|
|
126
160
|
const current = pendingRef.current;
|
|
127
161
|
if (current?.mailboxUid === mailboxUid) {
|
|
@@ -125,7 +125,7 @@ export default function ConversationList({
|
|
|
125
125
|
let stale = false;
|
|
126
126
|
void mapWithConcurrency(conversations.filter(conversationLooksEncrypted), 4, async (conversation) => {
|
|
127
127
|
if (!stale) {
|
|
128
|
-
await decryptForDisplay(conversation.latestMessageUid, conversation.mailboxUid ?? mailboxUid);
|
|
128
|
+
await decryptForDisplay(conversation.latestMessageUid, conversation.mailboxUid ?? mailboxUid, conversation.latestZeroAccess?.sealedSummary);
|
|
129
129
|
}
|
|
130
130
|
});
|
|
131
131
|
return () => {
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
respondToMessageInvite,
|
|
16
16
|
} from "../../../../lib/calendar/inviteApi.js";
|
|
17
17
|
import { guestPermissionsOf } from "../../../../lib/calendar/calendarApi.js";
|
|
18
|
+
import { openSealedEvent } from "../../calendar/sealedEvents.js";
|
|
18
19
|
import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
|
|
19
20
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
20
21
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
@@ -334,7 +335,7 @@ export default function InviteCard({ messageUid, headingLevel = 2 }: InviteCardP
|
|
|
334
335
|
aria-busy={pending === "acceptProposal" || undefined}
|
|
335
336
|
loading={pending === "acceptProposal"}
|
|
336
337
|
disabled={busy}
|
|
337
|
-
onClick={() => void run("acceptProposal", "Couldn't accept the proposed time", () => acceptProposal(messageUid, client))}
|
|
338
|
+
onClick={() => void run("acceptProposal", "Couldn't accept the proposed time", () => acceptProposal(messageUid, client, invite.calendarEventUid, openSealedEvent))}
|
|
338
339
|
>
|
|
339
340
|
Accept proposal
|
|
340
341
|
</Button>
|
|
@@ -387,6 +388,15 @@ export default function InviteCard({ messageUid, headingLevel = 2 }: InviteCardP
|
|
|
387
388
|
description: invite.description,
|
|
388
389
|
descriptionHtml: invite.descriptionHtml,
|
|
389
390
|
guestAddresses: invite.attendees.map((attendee) => attendee.address),
|
|
391
|
+
// The invitation's own content: what a protected copy of the event is named by.
|
|
392
|
+
invitationContent: invite.summary
|
|
393
|
+
? {
|
|
394
|
+
title: invite.summary,
|
|
395
|
+
location: invite.location ?? undefined,
|
|
396
|
+
description: invite.description ?? undefined,
|
|
397
|
+
descriptionHtml: invite.descriptionHtml ?? undefined,
|
|
398
|
+
}
|
|
399
|
+
: undefined,
|
|
390
400
|
}}
|
|
391
401
|
canChange={!!invite.canRequestChange}
|
|
392
402
|
canInvite={!!invite.canRequestInvite}
|
|
@@ -48,12 +48,13 @@ import MoveToFolderDialog from "./MoveToFolderDialog.js";
|
|
|
48
48
|
import InviteCard from "./InviteCard.js";
|
|
49
49
|
import { isCalendarAttachment } from "./invite/inviteFormat.js";
|
|
50
50
|
import { useMessageInvite } from "./invite/inviteStore.js";
|
|
51
|
+
import { rememberSealedInvite } from "../../../../lib/calendar/sealedInvites.js";
|
|
51
52
|
import { getUnlockedKeys, subscribeKeySession } from "../../../../lib/crypto/keySession.js";
|
|
52
|
-
import type { MessageSecurityResult, SignatureFailureReason } from "../../../../lib/crypto/messageSecurity.js";
|
|
53
|
+
import type { MessageSecurityResult, SignatureFailureReason, SignerPinsInput } from "../../../../lib/crypto/messageSecurity.js";
|
|
53
54
|
import { extractAddresses, type MimeAttachment } from "../../../../lib/crypto/mime.js";
|
|
54
55
|
import { SignerKeyConflictError, signingKeyFingerprints, trustSigner } from "../../../../lib/crypto/keyvaultApi.js";
|
|
55
56
|
import { isLikelyMailingList } from "../../../../lib/crypto/composeSecurity.js";
|
|
56
|
-
import { SenderKeyState, clearPinnedSignerCache,
|
|
57
|
+
import { SenderKeyState, clearPinnedSignerCache, getPinnedSigners, getSignerKeyState } from "./pinnedSigners.js";
|
|
57
58
|
import { currentVerificationSeal, getVaultGeneration, sendVerificationSeal } from "./verificationSeals.js";
|
|
58
59
|
import { getMyMailboxAccess } from "../../../../lib/mail/mailboxAccessApi.js";
|
|
59
60
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
@@ -116,6 +117,9 @@ const SECURITY_INDICATOR: Record<MessageSecurityResult["state"], { label: string
|
|
|
116
117
|
// Verified when first opened (a verification seal), but not live: muted, never the green verified pill. A signer key
|
|
117
118
|
// later reported compromised switches it to `LATER_COMPROMISED_INDICATOR_CLASS`.
|
|
118
119
|
verified_at_first_open: { label: "Verified when first opened", className: "bg-surface-alt text-text-muted" },
|
|
120
|
+
// Sealed by the server on arrival (zero-access): distinct from "Encrypted" - the server held this message in the clear while it
|
|
121
|
+
// delivered it - and never worded as end-to-end, per the spec's indicator table.
|
|
122
|
+
protected_at_rest: { label: "Protected at rest", className: "bg-primary/5 text-text" },
|
|
119
123
|
};
|
|
120
124
|
|
|
121
125
|
/** The amber `verified_at_first_open` badge for a signer key since reported compromised. */
|
|
@@ -289,11 +293,14 @@ function IconAction({
|
|
|
289
293
|
);
|
|
290
294
|
}
|
|
291
295
|
|
|
292
|
-
function SecurityIndicator({ security }: { security: MessageSecurityResult }) {
|
|
293
|
-
|
|
296
|
+
function SecurityIndicator({ security, zeroAccess }: { security: MessageSecurityResult; zeroAccess?: boolean }) {
|
|
297
|
+
// A zero-access message this device can't open yet (keys locked) evaluates as an encrypted one it has no key for: it is still
|
|
298
|
+
// labelled for what it is.
|
|
299
|
+
const state = zeroAccess && security.state === "encrypted" && security.decryptError !== undefined ? "protected_at_rest" : security.state;
|
|
300
|
+
const { label, className } = SECURITY_INDICATOR[state];
|
|
294
301
|
if (security.state !== "verified_at_first_open") {
|
|
295
302
|
// Every encrypted state carries a small lock, so the badge reads at a glance; the words are unchanged.
|
|
296
|
-
const encrypted =
|
|
303
|
+
const encrypted = state.startsWith("encrypted") || state === "protected_at_rest";
|
|
297
304
|
return (
|
|
298
305
|
<span className={`inline-flex items-center gap-1 text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${className}`}>
|
|
299
306
|
{encrypted && <HiOutlineLockClosed size={12} aria-hidden="true" />}
|
|
@@ -473,7 +480,7 @@ export default function MessageDetailPane(props: MessageDetailPaneProps) {
|
|
|
473
480
|
* surfaced as an attachment - so a message with neither flag can't carry a signature to verify, and
|
|
474
481
|
* fetching its full raw source (attachments included) would be pure waste. */
|
|
475
482
|
function needsRawSecurityEvaluation(message: Message): boolean {
|
|
476
|
-
return !!message.encrypted || message.hasAttachments;
|
|
483
|
+
return !!message.encrypted || !!message.zeroAccess || message.hasAttachments;
|
|
477
484
|
}
|
|
478
485
|
|
|
479
486
|
function MessageDetailContent({
|
|
@@ -605,9 +612,9 @@ function MessageDetailContent({
|
|
|
605
612
|
// mailbox's own signing keys when it sent the message itself. A failed lookup means no pins, which can only
|
|
606
613
|
// ever make a signature "signer not verified", never verified.
|
|
607
614
|
const senderAddress = message.from.address;
|
|
608
|
-
const pinsPromise =
|
|
609
|
-
(
|
|
610
|
-
() => ({ loaded: false, fingerprints: [] as string[] }),
|
|
615
|
+
const pinsPromise = getPinnedSigners(message.mailboxUid, senderAddress, client).then(
|
|
616
|
+
(signers) => ({ loaded: true, ...signers }),
|
|
617
|
+
() => ({ loaded: false, fingerprints: [] as string[], openPgpKeys: [] as string[] }),
|
|
611
618
|
);
|
|
612
619
|
// Verification seals (see `verificationSeals.ts`) need unlocked keys and the vault's current master key
|
|
613
620
|
// generation; without either the message is evaluated exactly as before, with no seal. The sender's key records
|
|
@@ -633,7 +640,9 @@ function MessageDetailContent({
|
|
|
633
640
|
const ownPins = signingKeyFingerprints(ownKeys);
|
|
634
641
|
const [contactPins, sealContext] = await Promise.all([pinsPromise, sealContextPromise]);
|
|
635
642
|
const pins = [...new Set([...contactPins.fingerprints, ...ownPins])];
|
|
636
|
-
|
|
643
|
+
// An OpenPGP signature is checked against the sender's pinned OpenPGP certificates themselves (it names only a key ID).
|
|
644
|
+
const pinned: SignerPinsInput =
|
|
645
|
+
contactPins.openPgpKeys.length > 0 ? { fingerprints: pins, openPgpKeys: contactPins.openPgpKeys } : pins.length > 0 ? pins : undefined;
|
|
637
646
|
// The raw content is a byte string - only ever handed to evaluateMessageSecurity(), never shown. Keys
|
|
638
647
|
// are read after the pin lookup's await, so a lock that happened meanwhile is honored.
|
|
639
648
|
const unlocked = getUnlockedKeys(message.mailboxUid);
|
|
@@ -670,7 +679,7 @@ function MessageDetailContent({
|
|
|
670
679
|
const viaAlias = await messageSecurity.evaluateMessageSecurity(rawMime, unlocked, pinned, alias);
|
|
671
680
|
result = { ...result, notAddressedToReader: viaAlias.notAddressedToReader };
|
|
672
681
|
}
|
|
673
|
-
const unpinned = contactPins.loaded && pins.length === 0;
|
|
682
|
+
const unpinned = contactPins.loaded && pins.length === 0 && contactPins.openPgpKeys.length === 0;
|
|
674
683
|
const keyChanged = signerKeyChanged(result);
|
|
675
684
|
const [keyState, access] = await Promise.all([
|
|
676
685
|
keyChanged || (unpinned && UNVERIFIED_SIGNER_STATES.has(result.state)) ? loadKeyState() : undefined,
|
|
@@ -686,14 +695,14 @@ function MessageDetailContent({
|
|
|
686
695
|
.catch(() => {
|
|
687
696
|
if (!cancelled) {
|
|
688
697
|
setSecurity(
|
|
689
|
-
message.encrypted ? { state: "encrypted", decryptError: ENCRYPTED_LOAD_ERROR } : { state: "unprotected" },
|
|
698
|
+
message.encrypted || message.zeroAccess ? { state: "encrypted", decryptError: ENCRYPTED_LOAD_ERROR } : { state: "unprotected" },
|
|
690
699
|
);
|
|
691
700
|
}
|
|
692
701
|
});
|
|
693
702
|
return () => {
|
|
694
703
|
cancelled = true;
|
|
695
704
|
};
|
|
696
|
-
}, [message.uid, message.mailboxUid, message.from.address, message.encrypted, rawEvaluationNeeded, unlockRefresh, readerAddressesKey, client]);
|
|
705
|
+
}, [message.uid, message.mailboxUid, message.from.address, message.encrypted, message.zeroAccess?.keyFingerprint, rawEvaluationNeeded, unlockRefresh, readerAddressesKey, client]);
|
|
697
706
|
|
|
698
707
|
// Whether the body is the locked state right now (an encrypted message this device holds no unlocked keys for), for the subscription below.
|
|
699
708
|
const lockedRef = useRef(false);
|
|
@@ -776,11 +785,11 @@ function MessageDetailContent({
|
|
|
776
785
|
// Only ever invoked from the "Trust this signer" dialog, which only opens for an unverified-signer result carrying
|
|
777
786
|
// `signerCertificate`. The pin goes on the contact in the mailbox the message belongs to; the evaluation is then
|
|
778
787
|
// re-run against a fresh pinned-signer lookup, so the badge turns verified.
|
|
779
|
-
async function handleTrustSigner(address: string, certificate: string) {
|
|
788
|
+
async function handleTrustSigner(address: string, certificate: string, type?: "openpgp") {
|
|
780
789
|
setTrusting(true);
|
|
781
790
|
setTrustError(null);
|
|
782
791
|
try {
|
|
783
|
-
await trustSigner(message.mailboxUid, { address, certificate }, client);
|
|
792
|
+
await trustSigner(message.mailboxUid, { address, certificate, ...(type ? { type } : {}) }, client);
|
|
784
793
|
clearPinnedSignerCache();
|
|
785
794
|
setTrustConfirmOpen(false);
|
|
786
795
|
setUnlockRefresh((n) => n + 1);
|
|
@@ -1078,7 +1087,11 @@ function MessageDetailContent({
|
|
|
1078
1087
|
// listed with a warning. A signed-only failure never carries recovered attachments.
|
|
1079
1088
|
const innerAttachments =
|
|
1080
1089
|
security?.attachments !== undefined &&
|
|
1081
|
-
(verifiedOrSealed ||
|
|
1090
|
+
(verifiedOrSealed ||
|
|
1091
|
+
security.zeroAccess ||
|
|
1092
|
+
security.state === "encrypted" ||
|
|
1093
|
+
security.state === "encrypted_unverified_signer" ||
|
|
1094
|
+
security.state === "signature_failed")
|
|
1082
1095
|
? security.attachments
|
|
1083
1096
|
: undefined;
|
|
1084
1097
|
// Any state with a valid signature shows the address actually signed for (the protected From when the message
|
|
@@ -1101,7 +1114,10 @@ function MessageDetailContent({
|
|
|
1101
1114
|
? security.signerCertificate
|
|
1102
1115
|
: undefined;
|
|
1103
1116
|
const keyChanged = security && signerKeyChanged(security) ? security : undefined;
|
|
1104
|
-
|
|
1117
|
+
// An OpenPGP signer's key is pinned as the contact's OpenPGP certificate, not a signing certificate.
|
|
1118
|
+
const openPgpSigner = keyChanged?.signerKeyType === "openpgp";
|
|
1119
|
+
const pinnedSignerKey = openPgpSigner ? senderKeyState?.openPgp?.[0] : senderKeyState?.pinned[0];
|
|
1120
|
+
const pinnedSignerSince = openPgpSigner ? senderKeyState?.openPgpPinnedSince : senderKeyState?.pinnedSince;
|
|
1105
1121
|
const recordedConflict =
|
|
1106
1122
|
keyChanged && sameFingerprint(senderKeyState?.conflict?.observedKey.fingerprint, keyChanged.signerFingerprint)
|
|
1107
1123
|
? senderKeyState!.conflict
|
|
@@ -1132,7 +1148,18 @@ function MessageDetailContent({
|
|
|
1132
1148
|
// Only a message the server can read and that carries a calendar file is worth asking about an invitation. An encrypted message is out: the
|
|
1133
1149
|
// server holds only its ciphertext (the calendar file is inside it), so there is nothing for it to find. Drafts and Outbox hold messages
|
|
1134
1150
|
// that are being written or sent, not received invitations.
|
|
1135
|
-
|
|
1151
|
+
// A zero-access message's calendar file is inside the sealed copy: the server reads the invitation from the one decrypted here.
|
|
1152
|
+
const sealedCalendar: string | undefined = security?.zeroAccess ? security.calendar : undefined;
|
|
1153
|
+
// Registered while rendering, not in an effect: the invitation lookup below must find it the moment it is enabled. Writing the same
|
|
1154
|
+
// file for the same message again is a no-op.
|
|
1155
|
+
if (sealedCalendar !== undefined) {
|
|
1156
|
+
rememberSealedInvite(message.uid, sealedCalendar, message.mailboxUid);
|
|
1157
|
+
}
|
|
1158
|
+
const mayHoldInvite =
|
|
1159
|
+
!message.encrypted &&
|
|
1160
|
+
!inOutbox &&
|
|
1161
|
+
message.folderUid !== draftsFolderUid &&
|
|
1162
|
+
(attachments.some(isCalendarAttachment) || sealedCalendar !== undefined);
|
|
1136
1163
|
// The calendar file of a message whose invitation card is drawn is the card, not a chip in the attachment list (a nameless "attachment" to click):
|
|
1137
1164
|
// it is listed again if the card could not be drawn (no invitation the server can read, or the lookup failed), so the file is never out of reach.
|
|
1138
1165
|
const { invite: shownInvite } = useMessageInvite(message.uid, mayHoldInvite);
|
|
@@ -1247,7 +1274,7 @@ function MessageDetailContent({
|
|
|
1247
1274
|
/** The rules page's "New mail filter", started from this message: its sender and (for a message the server can read) its subject. */
|
|
1248
1275
|
function handleCreateRule() {
|
|
1249
1276
|
const params = new URLSearchParams({ mailboxUid: message.mailboxUid, from: message.from.address });
|
|
1250
|
-
const subject = message.encrypted ? "" : withoutSubjectPrefixes(message.subject);
|
|
1277
|
+
const subject = message.encrypted || message.zeroAccess ? "" : withoutSubjectPrefixes(message.subject);
|
|
1251
1278
|
if (subject) {
|
|
1252
1279
|
params.set("subject", subject);
|
|
1253
1280
|
}
|
|
@@ -1273,7 +1300,7 @@ function MessageDetailContent({
|
|
|
1273
1300
|
createRule: handleCreateRule,
|
|
1274
1301
|
};
|
|
1275
1302
|
// An encrypted message can be printed once it has been opened here: while it is still opening, or when it can't be, there is nothing to print.
|
|
1276
|
-
const printReason = !message.encrypted
|
|
1303
|
+
const printReason = !message.encrypted && !message.zeroAccess
|
|
1277
1304
|
? undefined
|
|
1278
1305
|
: security === null
|
|
1279
1306
|
? "Still opening this message"
|
|
@@ -1325,8 +1352,13 @@ function MessageDetailContent({
|
|
|
1325
1352
|
</ParticipantLink>
|
|
1326
1353
|
</p>
|
|
1327
1354
|
)}
|
|
1328
|
-
{security && <SecurityIndicator security={security} />}
|
|
1355
|
+
{security && <SecurityIndicator security={security} zeroAccess={!!message.zeroAccess} />}
|
|
1329
1356
|
</div>
|
|
1357
|
+
{security?.signerKeyUnknown && (
|
|
1358
|
+
<p role="status" className="mt-2 text-sm text-text-muted">
|
|
1359
|
+
Signed with an OpenPGP key RapidMX doesn’t have for this sender yet, so the signature couldn’t be checked.
|
|
1360
|
+
</p>
|
|
1361
|
+
)}
|
|
1330
1362
|
{senderNameCheck.misleading && (
|
|
1331
1363
|
<p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
|
|
1332
1364
|
The sender’s name “{senderName}” looks like an email address, but this message was
|
|
@@ -1555,9 +1587,10 @@ function MessageDetailContent({
|
|
|
1555
1587
|
<KeyChangeReview
|
|
1556
1588
|
mailboxUid={message.mailboxUid}
|
|
1557
1589
|
address={senderAddress}
|
|
1558
|
-
useType="sign"
|
|
1590
|
+
useType={openPgpSigner ? "encrypt" : "sign"}
|
|
1591
|
+
{...(openPgpSigner ? { keyType: "openpgp" as const } : {})}
|
|
1559
1592
|
ownerName="the sender"
|
|
1560
|
-
current={pinnedSignerKey && { fingerprint: pinnedSignerKey.fingerprint, since:
|
|
1593
|
+
current={pinnedSignerKey && { fingerprint: pinnedSignerKey.fingerprint, since: pinnedSignerSince! }}
|
|
1561
1594
|
proposed={{
|
|
1562
1595
|
fingerprint: keyChanged.signerFingerprint!,
|
|
1563
1596
|
emails: keyChanged.signerEmails,
|
|
@@ -1711,7 +1744,7 @@ function MessageDetailContent({
|
|
|
1711
1744
|
body never came through the server's own sanitizer (the server never saw the plaintext); `MessageBody` sanitizes every body
|
|
1712
1745
|
client-side regardless, behind a no-remote-loads CSP, in a frame that runs no script. */}
|
|
1713
1746
|
<div ref={bodyRegionRef} tabIndex={-1} className="outline-none">
|
|
1714
|
-
{message.encrypted && security === null ? (
|
|
1747
|
+
{(message.encrypted || message.zeroAccess) && security === null ? (
|
|
1715
1748
|
<BodySkeleton />
|
|
1716
1749
|
) : security?.decryptError !== undefined ? (
|
|
1717
1750
|
// An encrypted message that could not be read: locked (no unlocked keys - the button asks the app's own unlock prompt, and the message
|
|
@@ -1804,7 +1837,7 @@ function MessageDetailContent({
|
|
|
1804
1837
|
onClose={closeSource}
|
|
1805
1838
|
mode={sourceView?.mode ?? "source"}
|
|
1806
1839
|
source={sourceView?.source ?? { status: "loading" }}
|
|
1807
|
-
encrypted={!!message.encrypted}
|
|
1840
|
+
encrypted={!!message.encrypted || !!message.zeroAccess}
|
|
1808
1841
|
/>
|
|
1809
1842
|
<Modal open={confirming} onClose={() => setConfirming(false)} title="Recall this message?">
|
|
1810
1843
|
<p className="text-sm text-text-muted mb-4">
|
|
@@ -1857,7 +1890,7 @@ function MessageDetailContent({
|
|
|
1857
1890
|
className="!w-auto"
|
|
1858
1891
|
loading={trusting}
|
|
1859
1892
|
disabled={trusting}
|
|
1860
|
-
onClick={() => handleTrustSigner(senderAddress, trustableCertificate
|
|
1893
|
+
onClick={() => handleTrustSigner(senderAddress, trustableCertificate!, security?.signerKeyType)}
|
|
1861
1894
|
>
|
|
1862
1895
|
Trust
|
|
1863
1896
|
</Button>
|
|
@@ -48,7 +48,7 @@ import { notify } from "../../../notifications/store.js";
|
|
|
48
48
|
import { isFileDrag, isInlineImage } from "./dropFiles.js";
|
|
49
49
|
import { evaluateEncryptionRequirement } from "./encryptionRequirement.js";
|
|
50
50
|
import { decideSend, SendBlock } from "../../../mail/outbox/sendDecision.js";
|
|
51
|
-
import { SendRequest, sendDecisionInput, startSend } from "../../../mail/outbox/sendJob.js";
|
|
51
|
+
import { SendRequest, sendDecisionInput, senderCanUseOpenPgp, startSend } from "../../../mail/outbox/sendJob.js";
|
|
52
52
|
import { useCompose, type ComposeSession } from "./ComposeContext.js";
|
|
53
53
|
import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
|
|
54
54
|
import { SHORTCUTS, ShortcutDef } from "../../../keyboard/keymap.js";
|
|
@@ -492,7 +492,14 @@ export default function ComposeWindow({
|
|
|
492
492
|
return;
|
|
493
493
|
}
|
|
494
494
|
lookupFailuresRef.current.delete(key);
|
|
495
|
-
const status = resolveRecipientEncryption(
|
|
495
|
+
const status = resolveRecipientEncryption(
|
|
496
|
+
mailbox.primarySmtpAddress,
|
|
497
|
+
ownPrefersMutual,
|
|
498
|
+
encryptionPolicy,
|
|
499
|
+
recipient.address,
|
|
500
|
+
result.lookup,
|
|
501
|
+
senderCanUseOpenPgp(getUnlockedKeys(mailbox.uid)),
|
|
502
|
+
);
|
|
496
503
|
setRecipientStatuses((prev) => ({ ...prev, [key]: status }));
|
|
497
504
|
});
|
|
498
505
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import React, { ChangeEvent, KeyboardEvent, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
6
6
|
import { createPortal } from "react-dom";
|
|
7
7
|
import { HiOutlineXMark } from "react-icons/hi2";
|
|
8
|
+
import { fetchSuggestionsWithProtectedContacts } from "./protectedContactSuggestions.js";
|
|
8
9
|
import {
|
|
9
10
|
fetchRecipientSuggestions,
|
|
10
11
|
RECIPIENT_SUGGESTION_MIN_QUERY_LENGTH,
|
|
@@ -111,7 +112,7 @@ export default function RecipientInput({
|
|
|
111
112
|
onBlur,
|
|
112
113
|
onCommit,
|
|
113
114
|
mailboxUid,
|
|
114
|
-
fetchSuggestions =
|
|
115
|
+
fetchSuggestions = fetchSuggestionsWithProtectedContacts,
|
|
115
116
|
debounceMs = RECIPIENT_SUGGESTION_DEBOUNCE_MS,
|
|
116
117
|
disabled,
|
|
117
118
|
autoFocus,
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import {
|
|
5
|
+
import { getUnlockedKeys } from "../../../../../lib/crypto/keySession.js";
|
|
6
|
+
import { evaluateMessageSecurity, type MessageSecurityResult } from "../../../../../lib/crypto/messageSecurity.js";
|
|
7
|
+
import { getMessageRawContent, type Message, type Recipient, type RecipientType, listAttachments } from "../../../../../lib/mail/mailApi.js";
|
|
6
8
|
import { formatMailAddress } from "../../../../../lib/mail/mailAddress.js";
|
|
7
9
|
import type { ApiClient } from "../../../../../lib/util/api.js";
|
|
8
10
|
import type { ResumeCompose } from "../../../mail/outbox/composeBridge.js";
|
|
@@ -29,13 +31,22 @@ function textToHtml(text: string): string {
|
|
|
29
31
|
* What the compose window needs to continue `draft` - a message in the Drafts folder the reader opened rather than one a window of this tab
|
|
30
32
|
* saved - read back from the server: its recipients and subject from the record, its body from the server's copy (with its embedded pictures)
|
|
31
33
|
* and its attachments. `undefined` for a draft that cannot be edited here: an encrypted one holds only its ciphertext, which is never opened.
|
|
34
|
+
* A draft saved in a zero-access mailbox is stored sealed to the mailbox's zero-access key: it is decrypted here with the unlocked keys (its
|
|
35
|
+
* subject comes from the decrypted message too, the record holding only `[...]`), and is `undefined` - left to read - while they're locked.
|
|
32
36
|
* Never rejects for a body that could not be loaded - the draft then opens with the preview, as a reply's quote does.
|
|
33
37
|
*/
|
|
34
38
|
export async function resumeFromDraft(draft: Message, client?: ApiClient): Promise<ResumeCompose | undefined> {
|
|
35
39
|
if (draft.encrypted) {
|
|
36
40
|
return undefined;
|
|
37
41
|
}
|
|
38
|
-
|
|
42
|
+
let security: MessageSecurityResult | null = null;
|
|
43
|
+
if (draft.zeroAccess) {
|
|
44
|
+
security = await openSealedDraft(draft, client);
|
|
45
|
+
if (!security) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const [original, attachments] = await Promise.all([loadOriginalMessage(draft, security), listAttachments(draft.folderUid, draft.uid, client)]);
|
|
39
50
|
const html =
|
|
40
51
|
original.body.html !== undefined ? original.body.html : original.body.text !== undefined ? textToHtml(original.body.text) : textToHtml(draft.bodyPreview ?? "");
|
|
41
52
|
return {
|
|
@@ -44,7 +55,7 @@ export async function resumeFromDraft(draft: Message, client?: ApiClient): Promi
|
|
|
44
55
|
to: recipientText(draft.recipients, "to"),
|
|
45
56
|
cc: recipientText(draft.recipients, "cc"),
|
|
46
57
|
bcc: recipientText(draft.recipients, "bcc"),
|
|
47
|
-
subject: draft.subject,
|
|
58
|
+
subject: security?.subject ?? draft.subject,
|
|
48
59
|
html,
|
|
49
60
|
attachments,
|
|
50
61
|
requestReceipt: !!draft.requestReceipt,
|
|
@@ -52,3 +63,17 @@ export async function resumeFromDraft(draft: Message, client?: ApiClient): Promi
|
|
|
52
63
|
encryptRequested: false,
|
|
53
64
|
};
|
|
54
65
|
}
|
|
66
|
+
|
|
67
|
+
/** `draft`'s sealed message decrypted with its mailbox's unlocked keys, or `null` when they're locked or it won't open with them. */
|
|
68
|
+
async function openSealedDraft(draft: Message, client?: ApiClient): Promise<MessageSecurityResult | null> {
|
|
69
|
+
const unlocked = getUnlockedKeys(draft.mailboxUid);
|
|
70
|
+
if (!unlocked) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const security = await evaluateMessageSecurity(await getMessageRawContent(draft.uid, client), unlocked);
|
|
75
|
+
return security.zeroAccess ? security : null;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|