@rapidmx/web-client 0.34.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/calendarLiveUpdates.ts +2 -5
- package/apps/shared/calendar/sealedEvents.ts +80 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +61 -3
- package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -1
- package/apps/shared/components/contacts/contactCardData.ts +8 -4
- 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 +91 -20
- package/apps/shared/components/mail/compose/draftResume.ts +28 -3
- package/apps/shared/components/mail/compose/quotedBody.ts +31 -15
- 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/MessageBody.tsx +4 -1
- package/apps/shared/components/mail/reading/bodyContent.ts +27 -17
- package/apps/shared/components/mail/reading/conversationPrint.ts +7 -5
- 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/live/useFolderLiveUpdates.ts +59 -0
- package/apps/shared/mail/decryptedMessages.ts +11 -3
- package/apps/shared/mail/outbox/sendJob.ts +2 -2
- package/apps/shared/mail/outbox/sendOutcomes.ts +1 -3
- 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 +180 -58
- package/apps/www/contacts/index.tsx +58 -2
- 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/apps/www/tasks/index.tsx +42 -0
- package/dist/apps/escrow/matters/[uid].js +2 -2
- package/dist/apps/shared/calendar/calendarLiveUpdates.js +2 -5
- 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/ContactsSidebar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
- package/dist/apps/shared/components/contacts/contactCardData.d.ts +2 -2
- package/dist/apps/shared/components/contacts/contactCardData.js +8 -4
- 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 +73 -17
- 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 +4 -3
- package/dist/apps/shared/components/mail/compose/quotedBody.js +28 -15
- 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/MessageBody.js +4 -1
- package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +4 -2
- package/dist/apps/shared/components/mail/reading/bodyContent.js +27 -16
- package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +3 -2
- package/dist/apps/shared/components/mail/reading/conversationPrint.js +7 -5
- 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/live/useFolderLiveUpdates.d.ts +19 -0
- package/dist/apps/shared/live/useFolderLiveUpdates.js +41 -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/outbox/sendOutcomes.js +1 -3
- 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 +48 -22
- package/dist/apps/www/contacts/index.js +49 -3
- 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/apps/www/tasks/index.js +29 -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/mail/pushClient.d.ts +10 -3
- package/dist/lib/mail/pushClient.js +18 -4
- package/dist/lib/search/zeroAccessIndexApi.d.ts +81 -0
- package/dist/lib/search/zeroAccessIndexApi.js +35 -0
- package/dist/lib/tasks/tasksApi.d.ts +1 -0
- package/dist/lib/tasks/tasksApi.js +3 -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/mail/pushClient.ts +18 -4
- package/lib/search/zeroAccessIndexApi.ts +119 -0
- package/lib/tasks/tasksApi.ts +4 -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}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
/** Pure helper behind the calendar page's live updates: which push events say "an event changed". */
|
|
6
6
|
|
|
7
7
|
import type { PushEvent } from "../../../lib/mail/pushClient.js";
|
|
8
|
+
import { changedItemOf } from "../live/useFolderLiveUpdates.js";
|
|
8
9
|
|
|
9
10
|
/** The published model name of a calendar event on either database (`CalendarEventMongo`, `CalendarEventSQL`) - and not the reminder's `"CalendarEvent"`,
|
|
10
11
|
* which has its own action. */
|
|
@@ -20,9 +21,5 @@ const EVENT_MODEL = /^CalendarEvent(Mongo|SQL)$/;
|
|
|
20
21
|
* changed event is handled that way, an ordinary one (an invitation arriving, an edit on another device) included.
|
|
21
22
|
*/
|
|
22
23
|
export function changedEventOf(event: PushEvent): { uid: string; deleted: boolean } | undefined {
|
|
23
|
-
|
|
24
|
-
return undefined;
|
|
25
|
-
}
|
|
26
|
-
const data = event.data as { uid?: unknown } | null | undefined;
|
|
27
|
-
return typeof data?.uid === "string" ? { uid: data.uid, deleted: event.action === "delete" } : undefined;
|
|
24
|
+
return changedItemOf(event, EVENT_MODEL);
|
|
28
25
|
}
|
|
@@ -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
|
}
|
|
@@ -12,6 +12,7 @@ import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
|
12
12
|
|
|
13
13
|
export type ContactsView =
|
|
14
14
|
| { type: "all" }
|
|
15
|
+
| { type: "everyone" }
|
|
15
16
|
| { type: "favorites" }
|
|
16
17
|
| { type: "suggested" }
|
|
17
18
|
| { type: "list"; uid: string; name: string }
|
|
@@ -94,7 +95,7 @@ function NavItem({
|
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
/**
|
|
97
|
-
* Contacts' Outlook-style left sidebar: "Your contacts" (
|
|
98
|
+
* Contacts' Outlook-style left sidebar: "All contacts" (the mailbox's own and its suggested ones together, when there are suggested ones), "Your contacts" (the mailbox's own, with count), "Favorites", "Suggested contacts" (the people the mailbox has corresponded with, when there are any), "Your contact
|
|
98
99
|
* lists" (real `ContactList` records, fetched here), "Deleted", and "Categories" (derived from the
|
|
99
100
|
* distinct `categories` values across the loaded contacts — no separate category registry exists
|
|
100
101
|
* server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
|
|
@@ -166,6 +167,9 @@ export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect
|
|
|
166
167
|
const navContent = (idPrefix: string) => (
|
|
167
168
|
<>
|
|
168
169
|
<div className="flex flex-col gap-0.5">
|
|
170
|
+
{suggestedCount !== undefined && (
|
|
171
|
+
<NavItem label="All contacts" count={contacts.length + suggestedCount} active={active.type === "everyone"} onClick={() => handleSelect({ type: "everyone" })} />
|
|
172
|
+
)}
|
|
169
173
|
<NavItem label="Your contacts" count={contacts.length} active={active.type === "all"} onClick={() => handleSelect({ type: "all" })} />
|
|
170
174
|
<NavItem label="Favorites" count={favoriteCount} active={active.type === "favorites"} onClick={() => handleSelect({ type: "favorites" })} />
|
|
171
175
|
{suggestedCount !== undefined && (
|
|
@@ -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(
|
|
@@ -104,8 +105,11 @@ export async function loadUpcomingEvents(folders: readonly Folder[], address: st
|
|
|
104
105
|
.slice(0, CARD_LIST_SIZE);
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
/** A vCard attachment's text. */
|
|
108
|
-
export async function fetchVCardText(attachmentUid: string): Promise<string> {
|
|
108
|
+
/** A vCard attachment's text - through `client` (its origin and token) when given, else the cookie request it always was. */
|
|
109
|
+
export async function fetchVCardText(attachmentUid: string, client?: ApiClient): Promise<string> {
|
|
110
|
+
if (client) {
|
|
111
|
+
return (await client.fetchBlob(`/mail/attachments/${encodeURIComponent(attachmentUid)}/content`)).text();
|
|
112
|
+
}
|
|
109
113
|
const res = await fetch(attachmentContentUrl(attachmentUid), { credentials: "include" });
|
|
110
114
|
if (!res.ok) {
|
|
111
115
|
throw new ApiRequestError(res.statusText || "Could not load this contact card.", res.status);
|
|
@@ -130,7 +134,7 @@ export async function loadParticipantCard(participant: Participant, context: Con
|
|
|
130
134
|
const isSender = !!context.message && normalizeAddress(context.message.from.address) === normalizeAddress(participant.address);
|
|
131
135
|
let fallback: ParsedVCardContact | undefined;
|
|
132
136
|
for (const attachment of await vCardAttachments(context, client)) {
|
|
133
|
-
const cards = parseVCards(await fetchVCardText(attachment.uid));
|
|
137
|
+
const cards = parseVCards(await fetchVCardText(attachment.uid, client));
|
|
134
138
|
const match = cardForAddress(cards, participant.address, false);
|
|
135
139
|
if (match) {
|
|
136
140
|
return match;
|
|
@@ -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 () => {
|