@rapidmx/web-client 0.35.0 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/escrow/matters/[uid].tsx +4 -3
- package/apps/shared/calendar/sealedEvents.ts +80 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +61 -3
- package/apps/shared/components/contacts/contactCardData.ts +2 -1
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +23 -1
- package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +91 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
- package/apps/shared/components/mail/ConversationList.tsx +1 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +35 -13
- package/apps/shared/components/mail/compose/draftResume.ts +28 -3
- package/apps/shared/components/mail/compose/quotedBody.ts +4 -3
- package/apps/shared/components/mail/invite/inviteStore.ts +7 -1
- package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
- package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
- package/apps/shared/components/settings/KeyExportSetting.tsx +170 -0
- package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
- package/apps/shared/components/settings/ZeroAccessSetting.tsx +117 -0
- package/apps/shared/mail/decryptedMessages.ts +11 -3
- package/apps/shared/mail/outbox/sendJob.ts +2 -2
- package/apps/shared/mail/reportNotices.ts +3 -1
- package/apps/shared/mail/zeroAccessSource.ts +27 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +31 -0
- package/apps/shared/search/localIndexBuilder.ts +27 -1
- package/apps/shared/search/localIndexRpcClient.ts +8 -4
- package/apps/shared/search/localIndexSchema.ts +2 -2
- package/apps/shared/search/localIndexTransport.ts +2 -1
- package/apps/shared/search/localIndexWorker.ts +6 -4
- package/apps/shared/search/zeroAccessIndexSync.ts +487 -0
- package/apps/www/calendar/index.tsx +176 -56
- package/apps/www/index.tsx +10 -3
- package/apps/www/settings/encryption/index.tsx +5 -0
- package/apps/www/settings/privacy/index.tsx +2 -1
- package/dist/apps/escrow/matters/[uid].js +2 -2
- package/dist/apps/shared/calendar/sealedEvents.d.ts +13 -0
- package/dist/apps/shared/calendar/sealedEvents.js +54 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +18 -2
- package/dist/apps/shared/components/contacts/contactCardData.js +2 -1
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +21 -2
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +15 -0
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +72 -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/MessageDetailPane.js +34 -13
- 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/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 +6 -1
- package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
- 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 +88 -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 +14 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.js +71 -0
- 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.js +2 -2
- 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 +28 -0
- package/dist/apps/shared/search/localIndexBuilder.d.ts +5 -0
- package/dist/apps/shared/search/localIndexBuilder.js +24 -2
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +1 -1
- package/dist/apps/shared/search/localIndexRpcClient.js +6 -4
- package/dist/apps/shared/search/localIndexSchema.d.ts +1 -1
- package/dist/apps/shared/search/localIndexSchema.js +2 -2
- package/dist/apps/shared/search/localIndexTransport.d.ts +2 -1
- package/dist/apps/shared/search/localIndexWorker.d.ts +2 -0
- package/dist/apps/shared/search/localIndexWorker.js +4 -4
- package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +71 -0
- package/dist/apps/shared/search/zeroAccessIndexSync.js +382 -0
- package/dist/apps/www/calendar/index.js +41 -15
- package/dist/apps/www/index.js +10 -3
- package/dist/apps/www/settings/encryption/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.js +1 -1
- package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
- package/dist/lib/calendar/calendarApi.d.ts +6 -0
- package/dist/lib/calendar/inviteApi.js +27 -5
- package/dist/lib/calendar/sealedInvites.d.ts +11 -0
- package/dist/lib/calendar/sealedInvites.js +28 -0
- package/dist/lib/crypto/keyExport.d.ts +54 -0
- package/dist/lib/crypto/keyExport.js +161 -0
- package/dist/lib/crypto/keySession.d.ts +45 -1
- package/dist/lib/crypto/keySession.js +93 -2
- package/dist/lib/crypto/keyvaultApi.d.ts +88 -3
- package/dist/lib/crypto/keyvaultApi.js +32 -0
- package/dist/lib/crypto/messageSecurity.d.ts +30 -1
- package/dist/lib/crypto/messageSecurity.js +80 -4
- package/dist/lib/crypto/mime.d.ts +5 -0
- package/dist/lib/crypto/mime.js +25 -0
- package/dist/lib/crypto/sharedMailboxKeys.d.ts +50 -0
- package/dist/lib/crypto/sharedMailboxKeys.js +264 -0
- package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
- package/dist/lib/crypto/zeroAccessKey.js +66 -0
- package/dist/lib/mail/conversationsApi.d.ts +6 -0
- package/dist/lib/mail/mailApi.d.ts +30 -2
- package/dist/lib/mail/mailApi.js +5 -1
- package/dist/lib/search/zeroAccessIndexApi.d.ts +81 -0
- package/dist/lib/search/zeroAccessIndexApi.js +35 -0
- package/lib/admin/escrowAccessRequestsApi.ts +6 -0
- package/lib/calendar/calendarApi.ts +3 -0
- package/lib/calendar/inviteApi.ts +28 -5
- package/lib/calendar/sealedInvites.ts +33 -0
- package/lib/crypto/keyExport.ts +229 -0
- package/lib/crypto/keySession.ts +117 -3
- package/lib/crypto/keyvaultApi.ts +125 -3
- package/lib/crypto/messageSecurity.ts +110 -3
- package/lib/crypto/mime.ts +26 -0
- package/lib/crypto/sharedMailboxKeys.ts +324 -0
- package/lib/crypto/zeroAccessKey.ts +97 -0
- package/lib/mail/conversationsApi.ts +3 -0
- package/lib/mail/mailApi.ts +33 -3
- package/lib/search/zeroAccessIndexApi.ts +119 -0
- package/package.json +3 -3
|
@@ -213,7 +213,7 @@ function MatterDetailContent({ uid }: { uid: string }) {
|
|
|
213
213
|
setLoadingMaterial(true);
|
|
214
214
|
try {
|
|
215
215
|
const material = await getAccessRequestMaterial(request.uid);
|
|
216
|
-
if (seq === materialSeq.current) setMaterialText(JSON.stringify(material
|
|
216
|
+
if (seq === materialSeq.current) setMaterialText(JSON.stringify(material, null, 2));
|
|
217
217
|
} catch (err) {
|
|
218
218
|
if (seq === materialSeq.current) {
|
|
219
219
|
setMaterialError(err instanceof ApiRequestError ? err.message : "Could not read this request's material.");
|
|
@@ -631,8 +631,9 @@ function MatterDetailContent({ uid }: { uid: string }) {
|
|
|
631
631
|
) : (
|
|
632
632
|
<>
|
|
633
633
|
<p className="text-sm text-text-muted mb-3">
|
|
634
|
-
Still-encrypted
|
|
635
|
-
|
|
634
|
+
Still-encrypted key material for <strong>{materialFor?.mailboxUid}</strong>: the escrow master key wrap,
|
|
635
|
+
and the private keys that read its mail, sealed under that master key. This server cannot decrypt any of it
|
|
636
|
+
either. Copy the JSON below into your own offline tooling.
|
|
636
637
|
</p>
|
|
637
638
|
<pre className="text-xs font-mono bg-surface-alt border border-border rounded-sm p-3 overflow-x-auto whitespace-pre-wrap break-all">
|
|
638
639
|
{materialText}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Calendar events the server made from invitations that reached a zero-access mailbox keep their title, location and description only
|
|
7
|
+
* sealed to the mailbox's zero-access key (`CalendarEvent.sealedContent`; the row's `title` is `[...]`). These are opened here with the
|
|
8
|
+
* mailbox's unlocked keys so the calendar shows the real ones; while the mailbox is locked the event keeps its placeholder. The
|
|
9
|
+
* decryption code is loaded only when a sealed event is actually seen.
|
|
10
|
+
*/
|
|
11
|
+
import type { CalendarEvent } from "../../../lib/calendar/calendarApi.js";
|
|
12
|
+
import { getUnlockedKeys } from "../../../lib/crypto/keySession.js";
|
|
13
|
+
import { fromBase64 } from "../../../lib/crypto/encoding.js";
|
|
14
|
+
import { onSigningOut } from "../components/mail/compose/composeFlushRegistry.js";
|
|
15
|
+
|
|
16
|
+
/** What `sealedContent.sealed` decrypts to. */
|
|
17
|
+
interface SealedEventFields {
|
|
18
|
+
title?: unknown;
|
|
19
|
+
location?: unknown;
|
|
20
|
+
description?: unknown;
|
|
21
|
+
descriptionHtml?: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Opened fields by sealed text - the same sealed content always opens to the same fields. Cleared with `clearSealedEvents()`. */
|
|
25
|
+
const opened: Map<string, Partial<CalendarEvent>> = new Map();
|
|
26
|
+
|
|
27
|
+
/** Forgets every opened event (sign-out, or the test seam). */
|
|
28
|
+
export function clearSealedEvents(): void {
|
|
29
|
+
opened.clear();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function openFields(event: CalendarEvent): Promise<Partial<CalendarEvent> | undefined> {
|
|
33
|
+
const sealed: string = event.sealedContent!.sealed;
|
|
34
|
+
const known = opened.get(sealed);
|
|
35
|
+
if (known) {
|
|
36
|
+
return known;
|
|
37
|
+
}
|
|
38
|
+
const keys = getUnlockedKeys(event.mailboxUid)?.zeroAccessKeys;
|
|
39
|
+
if (!keys?.length) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const { decryptEnvelopedDataWithKeys } = await import("../../../lib/crypto/smime.js");
|
|
44
|
+
const fields = JSON.parse(new TextDecoder().decode(await decryptEnvelopedDataWithKeys(fromBase64(sealed), keys))) as SealedEventFields;
|
|
45
|
+
const text = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
|
|
46
|
+
const result: Partial<CalendarEvent> = {
|
|
47
|
+
title: text(fields.title) ?? event.title,
|
|
48
|
+
location: text(fields.location),
|
|
49
|
+
description: text(fields.description) ?? null,
|
|
50
|
+
descriptionHtml: text(fields.descriptionHtml) ?? null,
|
|
51
|
+
};
|
|
52
|
+
opened.set(sealed, result);
|
|
53
|
+
return result;
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `events` with every sealed one this device can open shown with its real title, location and description. Never rejects. */
|
|
60
|
+
export async function openSealedEvents(events: CalendarEvent[]): Promise<CalendarEvent[]> {
|
|
61
|
+
if (!events.some((event) => event.sealedContent)) {
|
|
62
|
+
return events;
|
|
63
|
+
}
|
|
64
|
+
return Promise.all(
|
|
65
|
+
events.map(async (event) => {
|
|
66
|
+
if (!event.sealedContent) {
|
|
67
|
+
return event;
|
|
68
|
+
}
|
|
69
|
+
const fields = await openFields(event);
|
|
70
|
+
return fields ? { ...event, ...fields } : event;
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `openSealedEvents()` for one event. */
|
|
76
|
+
export async function openSealedEvent(event: CalendarEvent): Promise<CalendarEvent> {
|
|
77
|
+
return (await openSealedEvents([event]))[0];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
onSigningOut(clearSealedEvents);
|
|
@@ -12,7 +12,9 @@ import { actionErrorMessage } from "../elevation.js";
|
|
|
12
12
|
const SELECT_CLASS =
|
|
13
13
|
"w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
type TierKey = "encryptSameOrg" | "encryptFederated" | "encryptExternal";
|
|
16
|
+
|
|
17
|
+
const TIERS: { key: TierKey; label: string; help: string }[] = [
|
|
16
18
|
{ key: "encryptSameOrg", label: "Mail within this server", help: "Messages between mailboxes hosted on this server." },
|
|
17
19
|
{
|
|
18
20
|
key: "encryptFederated",
|
|
@@ -52,11 +54,16 @@ export default function EncryptionPolicyForm({ policy, onChange, onDirtyChange,
|
|
|
52
54
|
|
|
53
55
|
// Edits are unsaved until they match what was loaded or last saved.
|
|
54
56
|
const [savedValues, setSavedValues] = useState<EncryptionPolicy>(policy);
|
|
55
|
-
|
|
57
|
+
// A server without zero-access encryption sends no `allowZeroAccess`; the checkbox is then not shown at all.
|
|
58
|
+
const zeroAccessSupported: boolean = policy.allowZeroAccess !== undefined;
|
|
59
|
+
const dirty: boolean =
|
|
60
|
+
TIERS.some((tier) => values[tier.key] !== savedValues[tier.key]) || (zeroAccessSupported && values.allowZeroAccess !== savedValues.allowZeroAccess);
|
|
56
61
|
useEffect(() => onDirtyChange?.(dirty), [dirty]);
|
|
57
62
|
|
|
58
63
|
// Tiers this save would newly set to "Never encrypt", which switches encryption off for everyone's mail of that kind: confirmed first.
|
|
59
64
|
const [confirmingProhibit, setConfirmingProhibit] = useState<string[] | null>(null);
|
|
65
|
+
// Turning zero-access encryption off makes new mail readable by this server again for every mailbox: confirmed first.
|
|
66
|
+
const [confirmingZeroAccessOff, setConfirmingZeroAccessOff] = useState(false);
|
|
60
67
|
|
|
61
68
|
function handleSubmit(e: FormEvent) {
|
|
62
69
|
e.preventDefault();
|
|
@@ -65,11 +72,21 @@ export default function EncryptionPolicyForm({ policy, onChange, onDirtyChange,
|
|
|
65
72
|
setConfirmingProhibit(newlyProhibited.map((tier) => tier.label));
|
|
66
73
|
return;
|
|
67
74
|
}
|
|
75
|
+
confirmZeroAccessThenSave();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function confirmZeroAccessThenSave() {
|
|
79
|
+
setConfirmingProhibit(null);
|
|
80
|
+
if (zeroAccessSupported && values.allowZeroAccess === false && savedValues.allowZeroAccess !== false) {
|
|
81
|
+
setConfirmingZeroAccessOff(true);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
68
84
|
void save();
|
|
69
85
|
}
|
|
70
86
|
|
|
71
87
|
async function save() {
|
|
72
88
|
setConfirmingProhibit(null);
|
|
89
|
+
setConfirmingZeroAccessOff(false);
|
|
73
90
|
setError(null);
|
|
74
91
|
setSaved(false);
|
|
75
92
|
setSaving(true);
|
|
@@ -122,6 +139,31 @@ export default function EncryptionPolicyForm({ policy, onChange, onDirtyChange,
|
|
|
122
139
|
<span className="text-xs text-text-muted">{tier.help}</span>
|
|
123
140
|
</label>
|
|
124
141
|
))}
|
|
142
|
+
{zeroAccessSupported && (
|
|
143
|
+
<div className="mt-2 pt-4 border-t border-border">
|
|
144
|
+
<span className="font-semibold text-sm block mb-1">Zero-access encryption</span>
|
|
145
|
+
<label className="flex items-start gap-2 text-sm">
|
|
146
|
+
<input
|
|
147
|
+
type="checkbox"
|
|
148
|
+
className="mt-1"
|
|
149
|
+
checked={values.allowZeroAccess !== false}
|
|
150
|
+
onChange={(e) => {
|
|
151
|
+
setSaved(false);
|
|
152
|
+
setValues({ ...values, allowZeroAccess: e.target.checked });
|
|
153
|
+
}}
|
|
154
|
+
/>
|
|
155
|
+
<span>
|
|
156
|
+
<span className="font-semibold block">Allow zero-access encryption</span>
|
|
157
|
+
<span className="text-xs text-text-muted">
|
|
158
|
+
Mail that arrives unencrypted is encrypted the moment it is received, to a key only the mailbox’s
|
|
159
|
+
owner can unlock, and this server keeps no readable copy - administrators can’t read it either,
|
|
160
|
+
except through an escrow scope. People can still turn it off for their own mailbox. Search of message
|
|
161
|
+
contents then happens in the app instead of on the server.
|
|
162
|
+
</span>
|
|
163
|
+
</span>
|
|
164
|
+
</label>
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
125
167
|
<div>
|
|
126
168
|
<Button type="submit" loading={saving} disabled={saving} className="!w-auto">
|
|
127
169
|
Save
|
|
@@ -138,11 +180,27 @@ export default function EncryptionPolicyForm({ policy, onChange, onDirtyChange,
|
|
|
138
180
|
<Button type="button" variant="secondary" className="!w-auto" onClick={() => setConfirmingProhibit(null)}>
|
|
139
181
|
Cancel
|
|
140
182
|
</Button>
|
|
141
|
-
<Button type="button" className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger" onClick={
|
|
183
|
+
<Button type="button" className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger" onClick={confirmZeroAccessThenSave}>
|
|
142
184
|
Save and turn off encryption
|
|
143
185
|
</Button>
|
|
144
186
|
</div>
|
|
145
187
|
</Modal>
|
|
188
|
+
|
|
189
|
+
<Modal open={confirmingZeroAccessOff} onClose={() => setConfirmingZeroAccessOff(false)} title="Turn off zero-access encryption?">
|
|
190
|
+
<p className="text-sm mb-3">
|
|
191
|
+
Mail that arrives from now on will be stored in a form this server can read, for every mailbox. Mail that is already
|
|
192
|
+
encrypted stays encrypted - this server can’t decrypt it. Everyone sees in their settings that an administrator
|
|
193
|
+
turned it off.
|
|
194
|
+
</p>
|
|
195
|
+
<div className="flex gap-3 justify-end">
|
|
196
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={() => setConfirmingZeroAccessOff(false)}>
|
|
197
|
+
Cancel
|
|
198
|
+
</Button>
|
|
199
|
+
<Button type="button" className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger" onClick={() => void save()}>
|
|
200
|
+
Save and turn off zero-access encryption
|
|
201
|
+
</Button>
|
|
202
|
+
</div>
|
|
203
|
+
</Modal>
|
|
146
204
|
</div>
|
|
147
205
|
);
|
|
148
206
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { addDays } from "date-fns";
|
|
11
11
|
import { CalendarEvent, listCalendarEvents } from "../../../../lib/calendar/calendarApi.js";
|
|
12
|
+
import { openSealedEvents } from "../../calendar/sealedEvents.js";
|
|
12
13
|
import { type CalendarOccurrence, expandAllOccurrences } from "../../../../lib/calendar/recurrence.js";
|
|
13
14
|
import { type Contact, createContact, listContactsInFolders } from "../../../../lib/contacts/contactsApi.js";
|
|
14
15
|
import { type ParsedVCardContact, parseVCards } from "../../../../lib/contacts/vcard.js";
|
|
@@ -90,7 +91,7 @@ export async function loadRecentMessages(mailboxes: readonly Mailbox[], address:
|
|
|
90
91
|
export async function loadUpcomingEvents(folders: readonly Folder[], address: string, now: Date, client?: ApiClient): Promise<CalendarOccurrence[]> {
|
|
91
92
|
const wanted = normalizeAddress(address);
|
|
92
93
|
const calendars = folders.filter((folder) => folder.type === "calendar");
|
|
93
|
-
const lists = await Promise.all(calendars.map((folder) => listCalendarEvents(folder.uid, client)));
|
|
94
|
+
const lists = await Promise.all(calendars.map((folder) => listCalendarEvents(folder.uid, client).then(openSealedEvents)));
|
|
94
95
|
const involved = lists
|
|
95
96
|
.flat()
|
|
96
97
|
.filter(
|
|
@@ -20,9 +20,11 @@ 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";
|
|
26
28
|
import { buildAad, generateMasterKey, sealWithKey } from "../../../../lib/crypto/masterKey.js";
|
|
27
29
|
import { buildPasswordWrap, buildRecoveryWraps } from "../../../../lib/crypto/masterKeyWraps.js";
|
|
28
30
|
import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "../../../../lib/crypto/keys.js";
|
|
@@ -46,13 +48,17 @@ async function provisionEncryptionKey(
|
|
|
46
48
|
const privateKeyRaw = await exportPrivateKeyPkcs8(keyPair.privateKey);
|
|
47
49
|
const wrappedKeySealed = await sealWithKey(mk, privateKeyRaw, buildAad(mailboxUid, ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE));
|
|
48
50
|
|
|
49
|
-
await enrollKey(mailboxUid, {
|
|
51
|
+
const vault = await enrollKey(mailboxUid, {
|
|
50
52
|
useType: "encrypt",
|
|
51
53
|
csr: csrPem,
|
|
52
54
|
wrappedKey: { ciphertext: wrappedKeySealed.ciphertext, nonce: wrappedKeySealed.nonce, algorithm: "AES-256-GCM" },
|
|
53
55
|
masterKeyWraps: [passwordWrap, ...recoveryWraps],
|
|
54
56
|
});
|
|
55
57
|
|
|
58
|
+
// The zero-access key (restapi specs/search.md §3), under the same master key. Not fatal: setup is done once the
|
|
59
|
+
// encryption key is in, and a mailbox without a zero-access key gets one on its next unlock (`ensureZeroAccessKey()`).
|
|
60
|
+
await provisionZeroAccessKey(mailboxUid, mailboxAddress, mk, vault.masterKeyGeneration).catch(() => undefined);
|
|
61
|
+
|
|
56
62
|
return { recoveryCodes };
|
|
57
63
|
}
|
|
58
64
|
|
|
@@ -154,6 +160,22 @@ export default function KeyEnrollmentGate({
|
|
|
154
160
|
};
|
|
155
161
|
}, [mailboxUid, canProvision]);
|
|
156
162
|
|
|
163
|
+
// A mailbox set up before zero-access encryption existed has no zero-access key: give it one whenever its owner has
|
|
164
|
+
// it unlocked - now, or on a later unlock from anywhere (this gate, an unlock prompt, recovery). Best effort: a
|
|
165
|
+
// failure is retried on the next unlock.
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (!mailboxUid || !mailboxAddress || !canProvision) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const ensure = () => void ensureZeroAccessKey(mailboxUid, mailboxAddress).catch(() => undefined);
|
|
171
|
+
ensure();
|
|
172
|
+
return subscribeKeySession((event) => {
|
|
173
|
+
if (event.mailboxUid === mailboxUid && event.state === "unlocked") {
|
|
174
|
+
ensure();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}, [mailboxUid, mailboxAddress, canProvision]);
|
|
178
|
+
|
|
157
179
|
async function handleUnlock(e: React.FormEvent) {
|
|
158
180
|
e.preventDefault();
|
|
159
181
|
setError(null);
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
|
|
11
|
+
/** How often the shared mailboxes are looked after again while some own mailbox stays unlocked. */
|
|
12
|
+
export const SHARED_MAILBOX_CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
/** The shared mailbox key code (pkijs and friends), loaded only once an own mailbox is unlocked. */
|
|
15
|
+
let loaded: Promise<typeof import("../../../../lib/crypto/sharedMailboxKeys.js")> | undefined;
|
|
16
|
+
const loadSharedKeys = () =>
|
|
17
|
+
(loaded ??= import("../../../../lib/crypto/sharedMailboxKeys.js").catch((err) => {
|
|
18
|
+
loaded = undefined;
|
|
19
|
+
throw err;
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
export interface SharedMailboxKeyManagerProps {
|
|
23
|
+
/** Every mailbox the user can open. */
|
|
24
|
+
mailboxes: Mailbox[];
|
|
25
|
+
userUid?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Looks after the zero-access keys of the user's shared mailboxes (`lib/crypto/sharedMailboxKeys.ts`) whenever a mailbox of their own is
|
|
30
|
+
* unlocked, and every `SHARED_MAILBOX_CHECK_INTERVAL_MS` after that: opens each mailbox shared with them through their member key wraps (no
|
|
31
|
+
* password), and - for a mailbox they own or manage - provisions, rotates and shares its key with members still waiting. Best effort and
|
|
32
|
+
* silent: a pass that fails is tried again on the next. Renders nothing; mounted by `MailShell`.
|
|
33
|
+
*/
|
|
34
|
+
export default function SharedMailboxKeyManager({ mailboxes, userUid }: SharedMailboxKeyManagerProps) {
|
|
35
|
+
const client = useApiClient();
|
|
36
|
+
const latest = useRef({ mailboxes, userUid, client });
|
|
37
|
+
latest.current = { mailboxes, userUid, client };
|
|
38
|
+
const running = useRef(false);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
async function pass(): Promise<void> {
|
|
42
|
+
if (running.current || personallyUnlockedKeys().length === 0) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
running.current = true;
|
|
46
|
+
try {
|
|
47
|
+
const { maintainSharedMailbox, unlockSharedMailbox } = await loadSharedKeys();
|
|
48
|
+
const { mailboxes: current, userUid: me, client: apiClient } = latest.current;
|
|
49
|
+
for (const mailbox of current) {
|
|
50
|
+
await look(mailbox, me, apiClient, unlockSharedMailbox, maintainSharedMailbox).catch(() => undefined);
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// Nothing to do until the next pass.
|
|
54
|
+
} finally {
|
|
55
|
+
running.current = false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
void pass();
|
|
59
|
+
const unsubscribe = subscribeKeySession((event) => {
|
|
60
|
+
if (event.state === "unlocked" && !getUnlockedKeys(event.mailboxUid)?.shared) {
|
|
61
|
+
void pass();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
const interval = setInterval(() => void pass(), SHARED_MAILBOX_CHECK_INTERVAL_MS);
|
|
65
|
+
return () => {
|
|
66
|
+
unsubscribe();
|
|
67
|
+
clearInterval(interval);
|
|
68
|
+
};
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function look(
|
|
75
|
+
mailbox: Mailbox,
|
|
76
|
+
me: string | undefined,
|
|
77
|
+
client: ApiClient | undefined,
|
|
78
|
+
unlockSharedMailbox: (uid: string, client?: ApiClient) => Promise<string>,
|
|
79
|
+
maintainSharedMailbox: (uid: string, address: string, client?: ApiClient) => Promise<unknown>,
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
const owned = !!mailbox.ownerUserUid && mailbox.ownerUserUid === me;
|
|
82
|
+
if (!owned && !getUnlockedKeys(mailbox.uid)) {
|
|
83
|
+
await unlockSharedMailbox(mailbox.uid, client);
|
|
84
|
+
}
|
|
85
|
+
const unlocked = getUnlockedKeys(mailbox.uid);
|
|
86
|
+
// An owner looks after their mailbox with its own keys; a member, with full access, whether or not it is open yet (a mailbox
|
|
87
|
+
// without an owner may have no key to open).
|
|
88
|
+
if (owned ? unlocked && !unlocked.shared : true) {
|
|
89
|
+
await maintainSharedMailbox(mailbox.uid, mailbox.primarySmtpAddress, client);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -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 () => {
|
|
@@ -48,6 +48,7 @@ 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
53
|
import type { MessageSecurityResult, SignatureFailureReason } from "../../../../lib/crypto/messageSecurity.js";
|
|
53
54
|
import { extractAddresses, type MimeAttachment } from "../../../../lib/crypto/mime.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({
|
|
@@ -686,14 +693,14 @@ function MessageDetailContent({
|
|
|
686
693
|
.catch(() => {
|
|
687
694
|
if (!cancelled) {
|
|
688
695
|
setSecurity(
|
|
689
|
-
message.encrypted ? { state: "encrypted", decryptError: ENCRYPTED_LOAD_ERROR } : { state: "unprotected" },
|
|
696
|
+
message.encrypted || message.zeroAccess ? { state: "encrypted", decryptError: ENCRYPTED_LOAD_ERROR } : { state: "unprotected" },
|
|
690
697
|
);
|
|
691
698
|
}
|
|
692
699
|
});
|
|
693
700
|
return () => {
|
|
694
701
|
cancelled = true;
|
|
695
702
|
};
|
|
696
|
-
}, [message.uid, message.mailboxUid, message.from.address, message.encrypted, rawEvaluationNeeded, unlockRefresh, readerAddressesKey, client]);
|
|
703
|
+
}, [message.uid, message.mailboxUid, message.from.address, message.encrypted, message.zeroAccess?.keyFingerprint, rawEvaluationNeeded, unlockRefresh, readerAddressesKey, client]);
|
|
697
704
|
|
|
698
705
|
// Whether the body is the locked state right now (an encrypted message this device holds no unlocked keys for), for the subscription below.
|
|
699
706
|
const lockedRef = useRef(false);
|
|
@@ -1078,7 +1085,11 @@ function MessageDetailContent({
|
|
|
1078
1085
|
// listed with a warning. A signed-only failure never carries recovered attachments.
|
|
1079
1086
|
const innerAttachments =
|
|
1080
1087
|
security?.attachments !== undefined &&
|
|
1081
|
-
(verifiedOrSealed ||
|
|
1088
|
+
(verifiedOrSealed ||
|
|
1089
|
+
security.zeroAccess ||
|
|
1090
|
+
security.state === "encrypted" ||
|
|
1091
|
+
security.state === "encrypted_unverified_signer" ||
|
|
1092
|
+
security.state === "signature_failed")
|
|
1082
1093
|
? security.attachments
|
|
1083
1094
|
: undefined;
|
|
1084
1095
|
// Any state with a valid signature shows the address actually signed for (the protected From when the message
|
|
@@ -1132,7 +1143,18 @@ function MessageDetailContent({
|
|
|
1132
1143
|
// 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
1144
|
// 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
1145
|
// that are being written or sent, not received invitations.
|
|
1135
|
-
|
|
1146
|
+
// A zero-access message's calendar file is inside the sealed copy: the server reads the invitation from the one decrypted here.
|
|
1147
|
+
const sealedCalendar: string | undefined = security?.zeroAccess ? security.calendar : undefined;
|
|
1148
|
+
// Registered while rendering, not in an effect: the invitation lookup below must find it the moment it is enabled. Writing the same
|
|
1149
|
+
// file for the same message again is a no-op.
|
|
1150
|
+
if (sealedCalendar !== undefined) {
|
|
1151
|
+
rememberSealedInvite(message.uid, sealedCalendar);
|
|
1152
|
+
}
|
|
1153
|
+
const mayHoldInvite =
|
|
1154
|
+
!message.encrypted &&
|
|
1155
|
+
!inOutbox &&
|
|
1156
|
+
message.folderUid !== draftsFolderUid &&
|
|
1157
|
+
(attachments.some(isCalendarAttachment) || sealedCalendar !== undefined);
|
|
1136
1158
|
// 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
1159
|
// 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
1160
|
const { invite: shownInvite } = useMessageInvite(message.uid, mayHoldInvite);
|
|
@@ -1247,7 +1269,7 @@ function MessageDetailContent({
|
|
|
1247
1269
|
/** The rules page's "New mail filter", started from this message: its sender and (for a message the server can read) its subject. */
|
|
1248
1270
|
function handleCreateRule() {
|
|
1249
1271
|
const params = new URLSearchParams({ mailboxUid: message.mailboxUid, from: message.from.address });
|
|
1250
|
-
const subject = message.encrypted ? "" : withoutSubjectPrefixes(message.subject);
|
|
1272
|
+
const subject = message.encrypted || message.zeroAccess ? "" : withoutSubjectPrefixes(message.subject);
|
|
1251
1273
|
if (subject) {
|
|
1252
1274
|
params.set("subject", subject);
|
|
1253
1275
|
}
|
|
@@ -1273,7 +1295,7 @@ function MessageDetailContent({
|
|
|
1273
1295
|
createRule: handleCreateRule,
|
|
1274
1296
|
};
|
|
1275
1297
|
// 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
|
|
1298
|
+
const printReason = !message.encrypted && !message.zeroAccess
|
|
1277
1299
|
? undefined
|
|
1278
1300
|
: security === null
|
|
1279
1301
|
? "Still opening this message"
|
|
@@ -1325,7 +1347,7 @@ function MessageDetailContent({
|
|
|
1325
1347
|
</ParticipantLink>
|
|
1326
1348
|
</p>
|
|
1327
1349
|
)}
|
|
1328
|
-
{security && <SecurityIndicator security={security} />}
|
|
1350
|
+
{security && <SecurityIndicator security={security} zeroAccess={!!message.zeroAccess} />}
|
|
1329
1351
|
</div>
|
|
1330
1352
|
{senderNameCheck.misleading && (
|
|
1331
1353
|
<p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
|
|
@@ -1711,7 +1733,7 @@ function MessageDetailContent({
|
|
|
1711
1733
|
body never came through the server's own sanitizer (the server never saw the plaintext); `MessageBody` sanitizes every body
|
|
1712
1734
|
client-side regardless, behind a no-remote-loads CSP, in a frame that runs no script. */}
|
|
1713
1735
|
<div ref={bodyRegionRef} tabIndex={-1} className="outline-none">
|
|
1714
|
-
{message.encrypted && security === null ? (
|
|
1736
|
+
{(message.encrypted || message.zeroAccess) && security === null ? (
|
|
1715
1737
|
<BodySkeleton />
|
|
1716
1738
|
) : security?.decryptError !== undefined ? (
|
|
1717
1739
|
// 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 +1826,7 @@ function MessageDetailContent({
|
|
|
1804
1826
|
onClose={closeSource}
|
|
1805
1827
|
mode={sourceView?.mode ?? "source"}
|
|
1806
1828
|
source={sourceView?.source ?? { status: "loading" }}
|
|
1807
|
-
encrypted={!!message.encrypted}
|
|
1829
|
+
encrypted={!!message.encrypted || !!message.zeroAccess}
|
|
1808
1830
|
/>
|
|
1809
1831
|
<Modal open={confirming} onClose={() => setConfirming(false)} title="Recall this message?">
|
|
1810
1832
|
<p className="text-sm text-text-muted mb-4">
|
|
@@ -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
|
+
}
|