@rapidmx/web-client 0.2.0 → 0.3.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/admin/data-requests/index.tsx +485 -0
- package/apps/admin/escrow-scopes/[uid].tsx +237 -0
- package/apps/admin/escrow-scopes/index.tsx +124 -0
- package/apps/admin/escrow-scopes/new/index.tsx +128 -0
- package/apps/admin/mailboxes/[uid].tsx +66 -8
- package/apps/admin/retention-policy/index.tsx +132 -0
- package/apps/escrow/_layout.tsx +41 -0
- package/apps/escrow/audit-log/index.tsx +168 -0
- package/apps/escrow/index.tsx +139 -0
- package/apps/escrow/matters/[uid].tsx +521 -0
- package/apps/escrow/matters/new/index.tsx +173 -0
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +171 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +24 -0
- package/apps/shared/components/contacts/ContactDetailPane.tsx +44 -0
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +134 -0
- package/apps/shared/components/forms/StringListField.tsx +104 -0
- package/apps/shared/components/layout/AppShell.tsx +4 -0
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +265 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +8 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +214 -9
- package/apps/shared/components/mail/compose/ComposeContext.tsx +10 -1
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +271 -24
- package/apps/shared/components/mail/layout/MailShell.tsx +302 -287
- package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -0
- package/apps/www/index.tsx +122 -11
- package/apps/www/messages/[uid].tsx +15 -1
- package/apps/www/settings/encryption/index.tsx +664 -0
- package/apps/www/settings/labels/index.tsx +201 -0
- package/apps/www/settings/privacy/index.tsx +436 -0
- package/dist/apps/admin/data-requests/index.js +197 -0
- package/dist/apps/admin/escrow-scopes/[uid].js +141 -0
- package/dist/apps/admin/escrow-scopes/index.js +31 -0
- package/dist/apps/admin/escrow-scopes/new/index.js +69 -0
- package/dist/apps/admin/mailboxes/[uid].js +26 -2
- package/dist/apps/admin/retention-policy/index.js +65 -0
- package/dist/apps/escrow/_layout.js +10 -0
- package/dist/apps/escrow/audit-log/index.js +66 -0
- package/dist/apps/escrow/index.js +31 -0
- package/dist/apps/escrow/matters/[uid].js +234 -0
- package/dist/apps/escrow/matters/new/index.js +75 -0
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +26 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +19 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +10 -1
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +75 -0
- package/dist/apps/shared/components/forms/StringListField.js +39 -0
- package/dist/apps/shared/components/layout/AppShell.js +4 -0
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +142 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +2 -2
- package/dist/apps/shared/components/mail/MessageDetailPane.js +105 -6
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +184 -21
- package/dist/apps/shared/components/mail/layout/MailShell.js +11 -2
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -0
- package/dist/apps/www/index.js +110 -10
- package/dist/apps/www/messages/[uid].js +12 -2
- package/dist/apps/www/settings/encryption/index.js +330 -0
- package/dist/apps/www/settings/labels/index.js +95 -0
- package/dist/apps/www/settings/privacy/index.js +167 -0
- package/package.json +9 -3
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { FormEvent, useEffect, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import {
|
|
8
|
+
KeyVault,
|
|
9
|
+
MasterKeyWrap,
|
|
10
|
+
PublicKey,
|
|
11
|
+
addMasterKeyWrap,
|
|
12
|
+
checkSignEnrollmentStatus,
|
|
13
|
+
findActivePublicKey,
|
|
14
|
+
getEscrowInfo,
|
|
15
|
+
getKeyVault,
|
|
16
|
+
rekey,
|
|
17
|
+
removeMasterKeyWrap,
|
|
18
|
+
startSignEnrollment,
|
|
19
|
+
} from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
20
|
+
import {
|
|
21
|
+
SIGNING_PRIVATE_KEY_AAD_PURPOSE,
|
|
22
|
+
destroyUnlockedKeys,
|
|
23
|
+
getUnlockedKeys,
|
|
24
|
+
unlockWithPassword,
|
|
25
|
+
} from "@rapidmx/react-shared/crypto/keySession.js";
|
|
26
|
+
import { IDLE_TIMEOUT_OPTIONS_MINUTES, getIdleTimeoutMinutes, setIdleTimeoutMinutes } from "@rapidmx/react-shared/crypto/idleTimeout.js";
|
|
27
|
+
import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
|
|
28
|
+
import { buildAad, sealWithKey } from "@rapidmx/react-shared/crypto/masterKey.js";
|
|
29
|
+
import { buildEscrowWrap, buildPasswordWrap, buildRecoveryWraps } from "@rapidmx/react-shared/crypto/masterKeyWraps.js";
|
|
30
|
+
import { rewrapPrivateKeysUnderNewMasterKey } from "@rapidmx/react-shared/crypto/keyRotation.js";
|
|
31
|
+
import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "@rapidmx/react-shared/crypto/keys.js";
|
|
32
|
+
import { getMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
33
|
+
import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
|
|
34
|
+
import KeyEnrollmentGate from "../../../shared/components/layout/KeyEnrollmentGate.js";
|
|
35
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
36
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
37
|
+
|
|
38
|
+
const MIN_PASSWORD_LENGTH = 8;
|
|
39
|
+
|
|
40
|
+
// RFC 8823 ACME issuance is a real email round-trip with a public CA - "likely minutes," not seconds -
|
|
41
|
+
// so this polls infrequently rather than hammering the endpoint.
|
|
42
|
+
const SIGNING_ENROLLMENT_POLL_INTERVAL_MS = 15_000;
|
|
43
|
+
|
|
44
|
+
const METHOD_LABELS: Record<string, string> = {
|
|
45
|
+
password: "Password",
|
|
46
|
+
passkey: "Passkey",
|
|
47
|
+
recovery: "Recovery code",
|
|
48
|
+
escrow: "Escrow (managed by your organization)",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function idleTimeoutLabel(minutes: number): string {
|
|
52
|
+
if (minutes === 0) {
|
|
53
|
+
return "Never";
|
|
54
|
+
}
|
|
55
|
+
if (minutes === 60) {
|
|
56
|
+
return "1 hour";
|
|
57
|
+
}
|
|
58
|
+
return `${minutes} minutes`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type SettingsEncryptionPageProps = Omit<SettingsShellProps, "active">;
|
|
62
|
+
|
|
63
|
+
export default function SettingsEncryptionPage(props: SettingsEncryptionPageProps) {
|
|
64
|
+
return (
|
|
65
|
+
<SettingsShell {...props} active="encryption">
|
|
66
|
+
<EncryptionGate />
|
|
67
|
+
</SettingsShell>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Unlike `MailShell`, `SettingsShell` doesn't already gate its children behind `KeyEnrollmentGate` (a
|
|
73
|
+
* user can reach Settings without ever having opened Mail this session) - wrapped here instead, so
|
|
74
|
+
* `EncryptionContent` below can always assume `getUnlockedKeys()` has something for this mailbox before
|
|
75
|
+
* it renders. See `KeyEnrollmentGate`'s own doc comment for why it's safe to mount more than once (it
|
|
76
|
+
* short-circuits to `children` immediately for a mailbox already unlocked elsewhere this session).
|
|
77
|
+
*/
|
|
78
|
+
function EncryptionGate() {
|
|
79
|
+
const { mailboxUid, mailboxes } = useSettingsShell();
|
|
80
|
+
const mailbox = mailboxes.find((mb) => mb.uid === mailboxUid)!;
|
|
81
|
+
return (
|
|
82
|
+
<KeyEnrollmentGate mailboxUid={mailboxUid} mailboxAddress={mailbox.primarySmtpAddress} mailboxKeys={mailbox.keys}>
|
|
83
|
+
<EncryptionContent />
|
|
84
|
+
</KeyEnrollmentGate>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function EncryptionContent() {
|
|
89
|
+
const { mailboxUid, mailboxes } = useSettingsShell();
|
|
90
|
+
const mailbox = mailboxes.find((mb) => mb.uid === mailboxUid)!;
|
|
91
|
+
|
|
92
|
+
const [vault, setVault] = useState<KeyVault | null>(null);
|
|
93
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
94
|
+
const [removingMethod, setRemovingMethod] = useState<string | null>(null);
|
|
95
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
96
|
+
|
|
97
|
+
const [newPassword, setNewPassword] = useState("");
|
|
98
|
+
const [confirmNewPassword, setConfirmNewPassword] = useState("");
|
|
99
|
+
const [addingPassword, setAddingPassword] = useState(false);
|
|
100
|
+
|
|
101
|
+
const [regenerating, setRegenerating] = useState(false);
|
|
102
|
+
const [newRecoveryCodes, setNewRecoveryCodes] = useState<string[] | null>(null);
|
|
103
|
+
// Distinguishes the "Save your new recovery codes" screen's copy for the two different actions that
|
|
104
|
+
// land on it - a plain regeneration only invalidates old recovery codes, while a rotation also
|
|
105
|
+
// invalidates every other unlock method, which the copy needs to say plainly.
|
|
106
|
+
const [recoveryCodesReason, setRecoveryCodesReason] = useState<"regenerate" | "rotate">("regenerate");
|
|
107
|
+
const [codesSaved, setCodesSaved] = useState(false);
|
|
108
|
+
|
|
109
|
+
const [rotationPassword, setRotationPassword] = useState("");
|
|
110
|
+
const [rotationConfirmPassword, setRotationConfirmPassword] = useState("");
|
|
111
|
+
const [rotating, setRotating] = useState(false);
|
|
112
|
+
|
|
113
|
+
const [destroyed, setDestroyed] = useState(false);
|
|
114
|
+
const [idleTimeoutMinutes, setIdleTimeoutMinutesState] = useState(() => getIdleTimeoutMinutes());
|
|
115
|
+
|
|
116
|
+
// `mailbox.keys` comes from `SettingsShell`'s one-time `listMailboxes()` fetch - once an ACME
|
|
117
|
+
// enrollment issues, the server has installed a new signing key that fetch never saw. Only this
|
|
118
|
+
// page refetches (via `getMailbox()`) to notice; `null` means "no fresher data yet, use mailbox.keys".
|
|
119
|
+
const [refreshedKeys, setRefreshedKeys] = useState<PublicKey[] | null>(null);
|
|
120
|
+
const displayedKeys = refreshedKeys ?? mailbox.keys ?? [];
|
|
121
|
+
const activeSigningKey = findActivePublicKey(displayedKeys, "sign");
|
|
122
|
+
|
|
123
|
+
const [signingStatus, setSigningStatus] = useState<"idle" | "enrolling" | "pending">("idle");
|
|
124
|
+
const [signingEnrollmentId, setSigningEnrollmentId] = useState<string | null>(null);
|
|
125
|
+
const [signingError, setSigningError] = useState<string | null>(null);
|
|
126
|
+
|
|
127
|
+
const hasEscrowWrap = vault?.masterKeyWraps.some((w) => w.method === "escrow") ?? false;
|
|
128
|
+
const [wrappingEscrow, setWrappingEscrow] = useState(false);
|
|
129
|
+
const [escrowError, setEscrowError] = useState<string | null>(null);
|
|
130
|
+
|
|
131
|
+
async function handleWrapEscrow() {
|
|
132
|
+
setEscrowError(null);
|
|
133
|
+
setWrappingEscrow(true);
|
|
134
|
+
try {
|
|
135
|
+
// Only reachable when mailbox.escrowScopeId is set (see the render guard below) and
|
|
136
|
+
// `unlocked` is defined - see `handleAddPassword`'s identical note on the latter.
|
|
137
|
+
const escrowInfo = await getEscrowInfo(mailboxUid!);
|
|
138
|
+
const wrap = await buildEscrowWrap(unlocked!.masterKey, escrowInfo.escrowScopeId, fromBase64(escrowInfo.publicKey.publicKey));
|
|
139
|
+
await addMasterKeyWrap(mailboxUid!, wrap);
|
|
140
|
+
await loadVault();
|
|
141
|
+
} catch (err) {
|
|
142
|
+
setEscrowError(err instanceof ApiRequestError ? err.message : "Could not add escrow protection for this mailbox.");
|
|
143
|
+
} finally {
|
|
144
|
+
setWrappingEscrow(false);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (signingStatus !== "pending" || !signingEnrollmentId) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
let cancelled = false;
|
|
153
|
+
const interval = setInterval(async () => {
|
|
154
|
+
try {
|
|
155
|
+
const result = await checkSignEnrollmentStatus(mailboxUid!, signingEnrollmentId);
|
|
156
|
+
if (cancelled) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (result.status === "issued") {
|
|
160
|
+
setSigningStatus("idle");
|
|
161
|
+
setSigningEnrollmentId(null);
|
|
162
|
+
const refreshed = await getMailbox(mailboxUid!);
|
|
163
|
+
if (!cancelled) {
|
|
164
|
+
setRefreshedKeys(refreshed.keys ?? []);
|
|
165
|
+
}
|
|
166
|
+
} else if (result.status === "failed") {
|
|
167
|
+
setSigningStatus("idle");
|
|
168
|
+
setSigningEnrollmentId(null);
|
|
169
|
+
setSigningError(result.error ?? "Signing certificate enrollment failed.");
|
|
170
|
+
}
|
|
171
|
+
// "pending" leaves state as-is - the interval below just tries again.
|
|
172
|
+
} catch {
|
|
173
|
+
// Transient network error - keep polling rather than surfacing a one-off failure.
|
|
174
|
+
}
|
|
175
|
+
}, SIGNING_ENROLLMENT_POLL_INTERVAL_MS);
|
|
176
|
+
return () => {
|
|
177
|
+
cancelled = true;
|
|
178
|
+
clearInterval(interval);
|
|
179
|
+
};
|
|
180
|
+
}, [signingStatus, signingEnrollmentId, mailboxUid]);
|
|
181
|
+
|
|
182
|
+
async function handleEnrollSigning() {
|
|
183
|
+
setSigningError(null);
|
|
184
|
+
setSigningStatus("enrolling");
|
|
185
|
+
try {
|
|
186
|
+
// Only reachable once `unlocked` is defined - see `handleAddPassword`'s identical note.
|
|
187
|
+
const { keyPair, csrPem } = await generateKeyPairWithCsr(mailbox.primarySmtpAddress, "sign");
|
|
188
|
+
const privateKeyRaw = await exportPrivateKeyPkcs8(keyPair.privateKey);
|
|
189
|
+
const wrappedKeySealed = await sealWithKey(
|
|
190
|
+
unlocked!.masterKey,
|
|
191
|
+
privateKeyRaw,
|
|
192
|
+
buildAad(mailboxUid!, SIGNING_PRIVATE_KEY_AAD_PURPOSE),
|
|
193
|
+
);
|
|
194
|
+
const { enrollmentId } = await startSignEnrollment(mailboxUid!, {
|
|
195
|
+
csr: csrPem,
|
|
196
|
+
wrappedKey: { ciphertext: wrappedKeySealed.ciphertext, nonce: wrappedKeySealed.nonce, algorithm: "AES-256-GCM" },
|
|
197
|
+
});
|
|
198
|
+
setSigningEnrollmentId(enrollmentId);
|
|
199
|
+
setSigningStatus("pending");
|
|
200
|
+
} catch (err) {
|
|
201
|
+
setSigningError(err instanceof ApiRequestError ? err.message : "Could not start signing certificate enrollment.");
|
|
202
|
+
setSigningStatus("idle");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function handleIdleTimeoutChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
|
207
|
+
const minutes = Number(e.target.value);
|
|
208
|
+
setIdleTimeoutMinutes(minutes);
|
|
209
|
+
setIdleTimeoutMinutesState(minutes);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function loadVault() {
|
|
213
|
+
return getKeyVault(mailboxUid!)
|
|
214
|
+
.then(setVault)
|
|
215
|
+
.catch((err) => setLoadError(err instanceof ApiRequestError ? err.message : "Could not load your key vault."));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
loadVault();
|
|
220
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
221
|
+
}, [mailboxUid]);
|
|
222
|
+
|
|
223
|
+
const unlocked = getUnlockedKeys(mailboxUid!);
|
|
224
|
+
const passwordWraps = vault?.masterKeyWraps.filter((w) => w.method === "password") ?? [];
|
|
225
|
+
const recoveryWraps = vault?.masterKeyWraps.filter((w) => w.method === "recovery") ?? [];
|
|
226
|
+
const otherWraps = vault?.masterKeyWraps.filter((w) => w.method !== "password" && w.method !== "recovery") ?? [];
|
|
227
|
+
|
|
228
|
+
async function handleRemove(wrap: MasterKeyWrap) {
|
|
229
|
+
const key = `${wrap.method}:${wrap.methodId ?? ""}`;
|
|
230
|
+
setRemovingMethod(key);
|
|
231
|
+
setActionError(null);
|
|
232
|
+
try {
|
|
233
|
+
await removeMasterKeyWrap(mailboxUid!, wrap.method, wrap.methodId);
|
|
234
|
+
await loadVault();
|
|
235
|
+
} catch (err) {
|
|
236
|
+
setActionError(err instanceof ApiRequestError ? err.message : "Could not remove this unlock method.");
|
|
237
|
+
} finally {
|
|
238
|
+
setRemovingMethod(null);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function handleAddPassword(e: FormEvent) {
|
|
243
|
+
e.preventDefault();
|
|
244
|
+
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
|
245
|
+
setActionError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (newPassword !== confirmNewPassword) {
|
|
249
|
+
setActionError("Passwords do not match.");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
setActionError(null);
|
|
253
|
+
setAddingPassword(true);
|
|
254
|
+
try {
|
|
255
|
+
// Only reachable once `unlocked` is defined - `EncryptionGate` guarantees this mailbox is
|
|
256
|
+
// unlocked before `EncryptionContent` ever mounts.
|
|
257
|
+
const wrap = await buildPasswordWrap(mailboxUid!, unlocked!.masterKey, newPassword);
|
|
258
|
+
await addMasterKeyWrap(mailboxUid!, wrap);
|
|
259
|
+
setNewPassword("");
|
|
260
|
+
setConfirmNewPassword("");
|
|
261
|
+
await loadVault();
|
|
262
|
+
} catch (err) {
|
|
263
|
+
setActionError(err instanceof ApiRequestError ? err.message : "Could not add this password.");
|
|
264
|
+
} finally {
|
|
265
|
+
setAddingPassword(false);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function handleRegenerateRecoveryCodes() {
|
|
270
|
+
setActionError(null);
|
|
271
|
+
setRegenerating(true);
|
|
272
|
+
try {
|
|
273
|
+
for (const wrap of recoveryWraps) {
|
|
274
|
+
await removeMasterKeyWrap(mailboxUid!, "recovery", wrap.methodId);
|
|
275
|
+
}
|
|
276
|
+
const { wraps, codes } = await buildRecoveryWraps(mailboxUid!, unlocked!.masterKey);
|
|
277
|
+
for (const wrap of wraps) {
|
|
278
|
+
await addMasterKeyWrap(mailboxUid!, wrap);
|
|
279
|
+
}
|
|
280
|
+
setRecoveryCodesReason("regenerate");
|
|
281
|
+
setNewRecoveryCodes(codes);
|
|
282
|
+
setCodesSaved(false);
|
|
283
|
+
await loadVault();
|
|
284
|
+
} catch (err) {
|
|
285
|
+
setActionError(err instanceof ApiRequestError ? err.message : "Could not regenerate recovery codes.");
|
|
286
|
+
} finally {
|
|
287
|
+
setRegenerating(false);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Real revocation for a captured wrap (`keyvaultApi.ts`'s `rekey()` - see that function's own doc
|
|
293
|
+
* comment): re-wraps this mailbox's already-unlocked private keys under a brand new master key
|
|
294
|
+
* (`rewrapPrivateKeysUnderNewMasterKey()`), wraps that new MK under a freshly entered password and a
|
|
295
|
+
* fresh set of recovery codes, and atomically replaces the vault - the enrolled keypair/certificate
|
|
296
|
+
* itself is unchanged (restapi's own `rekey()` rejects anything else), only how it's protected.
|
|
297
|
+
* Every *other* unlock method this mailbox had (a second password, a passkey, an old set of recovery
|
|
298
|
+
* codes) stops working the instant this succeeds, since `rekey()` replaces `masterKeyWraps` wholesale
|
|
299
|
+
* - the whole point, for a captured-wrap scenario where it's unclear which method was compromised.
|
|
300
|
+
*/
|
|
301
|
+
async function handleRotateKeys(e: FormEvent) {
|
|
302
|
+
e.preventDefault();
|
|
303
|
+
if (rotationPassword.length < MIN_PASSWORD_LENGTH) {
|
|
304
|
+
setActionError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (rotationPassword !== rotationConfirmPassword) {
|
|
308
|
+
setActionError("Passwords do not match.");
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
setActionError(null);
|
|
312
|
+
setEscrowError(null);
|
|
313
|
+
setRotating(true);
|
|
314
|
+
try {
|
|
315
|
+
// Only reachable once `unlocked` is defined - see `handleAddPassword`'s identical note.
|
|
316
|
+
const { mk, wrappedKeys } = await rewrapPrivateKeysUnderNewMasterKey(mailboxUid!, unlocked!);
|
|
317
|
+
const passwordWrap = await buildPasswordWrap(mailboxUid!, mk, rotationPassword);
|
|
318
|
+
const { wraps: newRecoveryWraps, codes } = await buildRecoveryWraps(mailboxUid!, mk);
|
|
319
|
+
await rekey(mailboxUid!, { wrappedKeys, masterKeyWraps: [passwordWrap, ...newRecoveryWraps], keys: mailbox.keys ?? [] });
|
|
320
|
+
|
|
321
|
+
// restapi's own rekey() can never accept a fresh escrow wrap (its validateMasterKeyWrap()
|
|
322
|
+
// always passes allowEscrow: false there) - it preserves this mailbox's existing escrow wrap
|
|
323
|
+
// verbatim instead, which now encrypts a master key nobody has any longer. Re-wrap it
|
|
324
|
+
// separately, via the same addMasterKeyWrap() path "Add escrow protection" above already
|
|
325
|
+
// uses, so rotating keys for an unrelated reason (lost device, password hygiene) doesn't
|
|
326
|
+
// silently drop real escrow coverage while this page keeps claiming it's still active. A
|
|
327
|
+
// failure here is reported via escrowError, not as a rotation failure - the rotation itself
|
|
328
|
+
// (password/recovery codes) already succeeded by this point and must not be rolled back for
|
|
329
|
+
// an escrow-specific hiccup the user can retry independently.
|
|
330
|
+
if (hasEscrowWrap) {
|
|
331
|
+
try {
|
|
332
|
+
const escrowInfo = await getEscrowInfo(mailboxUid!);
|
|
333
|
+
const escrowWrap = await buildEscrowWrap(mk, escrowInfo.escrowScopeId, fromBase64(escrowInfo.publicKey.publicKey));
|
|
334
|
+
await addMasterKeyWrap(mailboxUid!, escrowWrap);
|
|
335
|
+
} catch (err) {
|
|
336
|
+
setEscrowError(
|
|
337
|
+
err instanceof ApiRequestError
|
|
338
|
+
? err.message
|
|
339
|
+
: 'Your keys were rotated, but escrow protection could not be re-established automatically. Use "Add escrow protection" below to restore it.',
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Refreshes this session's own cached keys against the new MK, via the password we just set -
|
|
345
|
+
// the underlying private key material didn't change, but the stale MK in memory would silently
|
|
346
|
+
// build wrong future wraps (e.g. a second "Add a password") if left as-is.
|
|
347
|
+
await unlockWithPassword(mailboxUid!, mailbox.keys ?? [], rotationPassword);
|
|
348
|
+
setRotationPassword("");
|
|
349
|
+
setRotationConfirmPassword("");
|
|
350
|
+
setRecoveryCodesReason("rotate");
|
|
351
|
+
setNewRecoveryCodes(codes);
|
|
352
|
+
setCodesSaved(false);
|
|
353
|
+
await loadVault();
|
|
354
|
+
} catch (err) {
|
|
355
|
+
setActionError(err instanceof ApiRequestError ? err.message : "Could not rotate your encryption keys.");
|
|
356
|
+
} finally {
|
|
357
|
+
setRotating(false);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function handleDestroyKeysNow() {
|
|
362
|
+
destroyUnlockedKeys(mailboxUid);
|
|
363
|
+
setDestroyed(true);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (destroyed) {
|
|
367
|
+
return (
|
|
368
|
+
<div className="flex-1 min-w-0 overflow-y-auto p-6">
|
|
369
|
+
<div className="max-w-xl">
|
|
370
|
+
<Alert>
|
|
371
|
+
Your encryption keys have been removed from this session. Reload the page (or open Mail
|
|
372
|
+
again) to unlock them when you need to read or send encrypted mail.
|
|
373
|
+
</Alert>
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (newRecoveryCodes) {
|
|
380
|
+
return (
|
|
381
|
+
<div className="flex-1 min-w-0 overflow-y-auto p-6">
|
|
382
|
+
<div className="max-w-xl">
|
|
383
|
+
<h1 className="text-lg font-bold tracking-tight mb-1">Save your new recovery codes</h1>
|
|
384
|
+
<p className="text-sm text-text-muted mb-4">
|
|
385
|
+
{recoveryCodesReason === "rotate"
|
|
386
|
+
? "Your keys have been rotated - every previous unlock method (password, recovery codes, or anything else on file) has stopped working. "
|
|
387
|
+
: "Your old recovery codes no longer work. "}
|
|
388
|
+
If you lose your password, these new codes are the only way to recover your encrypted mail.
|
|
389
|
+
Each code can be used once. Store them somewhere safe — they will not be shown again.
|
|
390
|
+
</p>
|
|
391
|
+
<ul className="grid grid-cols-2 gap-2 mb-5 font-mono text-sm">
|
|
392
|
+
{newRecoveryCodes.map((code) => (
|
|
393
|
+
<li key={code} className="bg-surface-alt rounded-sm py-1.5 px-2 text-center">
|
|
394
|
+
{code}
|
|
395
|
+
</li>
|
|
396
|
+
))}
|
|
397
|
+
</ul>
|
|
398
|
+
<label className="flex items-center gap-2 text-sm mb-4">
|
|
399
|
+
<input type="checkbox" checked={codesSaved} onChange={(e) => setCodesSaved(e.target.checked)} />
|
|
400
|
+
I have saved these recovery codes in a safe place.
|
|
401
|
+
</label>
|
|
402
|
+
<Button type="button" disabled={!codesSaved} onClick={() => setNewRecoveryCodes(null)} className="!w-auto">
|
|
403
|
+
Done
|
|
404
|
+
</Button>
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return (
|
|
411
|
+
<div className="flex-1 min-w-0 overflow-y-auto p-6">
|
|
412
|
+
<div className="max-w-xl flex flex-col gap-6">
|
|
413
|
+
<div>
|
|
414
|
+
<h1 className="text-lg font-bold tracking-tight mb-1">Encryption</h1>
|
|
415
|
+
<p className="text-sm text-text-muted">
|
|
416
|
+
Manage how {mailbox.displayName} unlocks its encryption keys on this and other devices.
|
|
417
|
+
</p>
|
|
418
|
+
</div>
|
|
419
|
+
|
|
420
|
+
{loadError && <Alert>{loadError}</Alert>}
|
|
421
|
+
{actionError && <Alert>{actionError}</Alert>}
|
|
422
|
+
|
|
423
|
+
<div>
|
|
424
|
+
<h2 className="text-sm font-semibold mb-2">Encryption keys</h2>
|
|
425
|
+
{displayedKeys.length > 0 ? (
|
|
426
|
+
<ul className="flex flex-col gap-1 text-sm">
|
|
427
|
+
{displayedKeys.map((key) => (
|
|
428
|
+
<li key={key.fingerprint} className="font-mono text-xs">
|
|
429
|
+
{key.useType === "sign" ? "Signing" : "Encryption"} key: {key.fingerprint}
|
|
430
|
+
{key.revokedAt && <span className="text-danger"> (revoked)</span>}
|
|
431
|
+
</li>
|
|
432
|
+
))}
|
|
433
|
+
</ul>
|
|
434
|
+
) : (
|
|
435
|
+
<p className="text-sm text-text-muted">No keys enrolled yet.</p>
|
|
436
|
+
)}
|
|
437
|
+
</div>
|
|
438
|
+
|
|
439
|
+
<div>
|
|
440
|
+
<h2 className="text-sm font-semibold mb-2">Digital signatures</h2>
|
|
441
|
+
{activeSigningKey ? (
|
|
442
|
+
<p className="text-sm text-text-muted">
|
|
443
|
+
Enabled — outgoing mail from this mailbox is signed with a publicly-trusted
|
|
444
|
+
certificate.
|
|
445
|
+
</p>
|
|
446
|
+
) : signingStatus === "pending" ? (
|
|
447
|
+
<p className="text-sm text-text-muted">
|
|
448
|
+
Requested — a public certificate authority issues this via an automated email
|
|
449
|
+
exchange, which can take a few minutes. You can leave this page; it finishes in
|
|
450
|
+
the background and takes effect automatically once issued.
|
|
451
|
+
</p>
|
|
452
|
+
) : (
|
|
453
|
+
<div className="flex flex-col gap-2">
|
|
454
|
+
<p className="text-xs text-text-muted">
|
|
455
|
+
Lets recipients verify that mail from this mailbox is genuinely from you.
|
|
456
|
+
Optional — encryption already works without it.
|
|
457
|
+
</p>
|
|
458
|
+
{signingError && <Alert>{signingError}</Alert>}
|
|
459
|
+
<Button
|
|
460
|
+
type="button"
|
|
461
|
+
variant="secondary"
|
|
462
|
+
className="!w-auto"
|
|
463
|
+
loading={signingStatus === "enrolling"}
|
|
464
|
+
disabled={signingStatus === "enrolling"}
|
|
465
|
+
onClick={handleEnrollSigning}
|
|
466
|
+
>
|
|
467
|
+
Enable digital signatures
|
|
468
|
+
</Button>
|
|
469
|
+
</div>
|
|
470
|
+
)}
|
|
471
|
+
</div>
|
|
472
|
+
|
|
473
|
+
{mailbox.escrowScopeId && (
|
|
474
|
+
<div>
|
|
475
|
+
<h2 className="text-sm font-semibold mb-2">Escrow</h2>
|
|
476
|
+
{/* Rendered regardless of hasEscrowWrap - a rotation-triggered re-wrap failure
|
|
477
|
+
(see handleRotateKeys) leaves the mailbox's old, now-stale escrow wrap in place,
|
|
478
|
+
so hasEscrowWrap alone can't be trusted to hide this. */}
|
|
479
|
+
{escrowError && <Alert>{escrowError}</Alert>}
|
|
480
|
+
{hasEscrowWrap ? (
|
|
481
|
+
<p className="text-sm text-text-muted">
|
|
482
|
+
This mailbox is under legal/compliance escrow — an authorized holder in your
|
|
483
|
+
organization can recover its encrypted mail if needed. This does not weaken
|
|
484
|
+
protection against anyone else.
|
|
485
|
+
</p>
|
|
486
|
+
) : (
|
|
487
|
+
<div className="flex flex-col gap-2">
|
|
488
|
+
<p className="text-xs text-text-muted">
|
|
489
|
+
Your organization has assigned this mailbox to an escrow scope, but nothing
|
|
490
|
+
has been protected yet — an authorized holder cannot recover this mailbox's
|
|
491
|
+
encrypted mail until you complete this step. This does not weaken protection
|
|
492
|
+
against anyone else.
|
|
493
|
+
</p>
|
|
494
|
+
<Button
|
|
495
|
+
type="button"
|
|
496
|
+
variant="secondary"
|
|
497
|
+
className="!w-auto"
|
|
498
|
+
loading={wrappingEscrow}
|
|
499
|
+
disabled={wrappingEscrow}
|
|
500
|
+
onClick={handleWrapEscrow}
|
|
501
|
+
>
|
|
502
|
+
Add escrow protection
|
|
503
|
+
</Button>
|
|
504
|
+
</div>
|
|
505
|
+
)}
|
|
506
|
+
</div>
|
|
507
|
+
)}
|
|
508
|
+
|
|
509
|
+
<div>
|
|
510
|
+
<h2 className="text-sm font-semibold mb-2">Unlock methods</h2>
|
|
511
|
+
<p className="text-xs text-text-muted mb-3">
|
|
512
|
+
Removing a method here stops it from being usable to unlock this mailbox going forward,
|
|
513
|
+
but it is <strong>not</strong> full revocation — anyone who already captured a wrapped
|
|
514
|
+
copy and knows its secret could still use it. For real revocation (e.g. after a lost
|
|
515
|
+
device), rotate your keys entirely below instead.
|
|
516
|
+
</p>
|
|
517
|
+
{vault && vault.masterKeyWraps.length > 0 ? (
|
|
518
|
+
<ul className="flex flex-col gap-2">
|
|
519
|
+
{[...passwordWraps, ...recoveryWraps, ...otherWraps].map((wrap) => {
|
|
520
|
+
const key = `${wrap.method}:${wrap.methodId ?? ""}`;
|
|
521
|
+
return (
|
|
522
|
+
<li key={key} className="flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm">
|
|
523
|
+
<span>{METHOD_LABELS[wrap.method] ?? wrap.method}</span>
|
|
524
|
+
{wrap.method !== "escrow" && (
|
|
525
|
+
<Button
|
|
526
|
+
type="button"
|
|
527
|
+
variant="text"
|
|
528
|
+
className="!w-auto text-danger"
|
|
529
|
+
loading={removingMethod === key}
|
|
530
|
+
disabled={removingMethod !== null}
|
|
531
|
+
onClick={() => handleRemove(wrap)}
|
|
532
|
+
>
|
|
533
|
+
Remove
|
|
534
|
+
</Button>
|
|
535
|
+
)}
|
|
536
|
+
</li>
|
|
537
|
+
);
|
|
538
|
+
})}
|
|
539
|
+
</ul>
|
|
540
|
+
) : (
|
|
541
|
+
<p className="text-sm text-text-muted">No unlock methods on file.</p>
|
|
542
|
+
)}
|
|
543
|
+
</div>
|
|
544
|
+
|
|
545
|
+
<form onSubmit={handleAddPassword} className="flex flex-col gap-2">
|
|
546
|
+
<h2 className="text-sm font-semibold">Add a password</h2>
|
|
547
|
+
<input
|
|
548
|
+
type="password"
|
|
549
|
+
aria-label="New password"
|
|
550
|
+
placeholder="New password"
|
|
551
|
+
className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
|
|
552
|
+
value={newPassword}
|
|
553
|
+
onChange={(e) => setNewPassword(e.target.value)}
|
|
554
|
+
disabled={addingPassword}
|
|
555
|
+
autoComplete="new-password"
|
|
556
|
+
/>
|
|
557
|
+
<input
|
|
558
|
+
type="password"
|
|
559
|
+
aria-label="Confirm new password"
|
|
560
|
+
placeholder="Confirm new password"
|
|
561
|
+
className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
|
|
562
|
+
value={confirmNewPassword}
|
|
563
|
+
onChange={(e) => setConfirmNewPassword(e.target.value)}
|
|
564
|
+
disabled={addingPassword}
|
|
565
|
+
autoComplete="new-password"
|
|
566
|
+
/>
|
|
567
|
+
<div>
|
|
568
|
+
<Button type="submit" loading={addingPassword} disabled={addingPassword} className="!w-auto">
|
|
569
|
+
Add password
|
|
570
|
+
</Button>
|
|
571
|
+
</div>
|
|
572
|
+
</form>
|
|
573
|
+
|
|
574
|
+
<div>
|
|
575
|
+
<h2 className="text-sm font-semibold mb-2">Recovery codes</h2>
|
|
576
|
+
<p className="text-xs text-text-muted mb-3">
|
|
577
|
+
Regenerating replaces all of your existing recovery codes — old ones stop working
|
|
578
|
+
immediately.
|
|
579
|
+
</p>
|
|
580
|
+
<Button
|
|
581
|
+
type="button"
|
|
582
|
+
variant="secondary"
|
|
583
|
+
loading={regenerating}
|
|
584
|
+
disabled={regenerating}
|
|
585
|
+
onClick={handleRegenerateRecoveryCodes}
|
|
586
|
+
className="!w-auto"
|
|
587
|
+
>
|
|
588
|
+
Regenerate recovery codes
|
|
589
|
+
</Button>
|
|
590
|
+
</div>
|
|
591
|
+
|
|
592
|
+
<form onSubmit={handleRotateKeys} className="flex flex-col gap-2 border-t border-border pt-6">
|
|
593
|
+
<h2 className="text-sm font-semibold">Rotate keys</h2>
|
|
594
|
+
<p className="text-xs text-text-muted mb-1">
|
|
595
|
+
Real revocation, for when a device or an unlock method may have been compromised.
|
|
596
|
+
Re-protects your existing encryption key under a brand new master key - your signing/
|
|
597
|
+
encryption keypair itself doesn’t change, so mail you’ve already sent or
|
|
598
|
+
received still decrypts normally. Every current unlock method (password, recovery codes,
|
|
599
|
+
and anything else on file) stops working immediately; you’ll set a new password and
|
|
600
|
+
get new recovery codes below.
|
|
601
|
+
</p>
|
|
602
|
+
<input
|
|
603
|
+
type="password"
|
|
604
|
+
aria-label="New password for rotated keys"
|
|
605
|
+
placeholder="New password"
|
|
606
|
+
className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
|
|
607
|
+
value={rotationPassword}
|
|
608
|
+
onChange={(e) => setRotationPassword(e.target.value)}
|
|
609
|
+
disabled={rotating}
|
|
610
|
+
autoComplete="new-password"
|
|
611
|
+
/>
|
|
612
|
+
<input
|
|
613
|
+
type="password"
|
|
614
|
+
aria-label="Confirm new password for rotated keys"
|
|
615
|
+
placeholder="Confirm new password"
|
|
616
|
+
className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
|
|
617
|
+
value={rotationConfirmPassword}
|
|
618
|
+
onChange={(e) => setRotationConfirmPassword(e.target.value)}
|
|
619
|
+
disabled={rotating}
|
|
620
|
+
autoComplete="new-password"
|
|
621
|
+
/>
|
|
622
|
+
<div>
|
|
623
|
+
<Button type="submit" variant="secondary" className="!w-auto text-danger" loading={rotating} disabled={rotating}>
|
|
624
|
+
Rotate keys now
|
|
625
|
+
</Button>
|
|
626
|
+
</div>
|
|
627
|
+
</form>
|
|
628
|
+
|
|
629
|
+
<div>
|
|
630
|
+
<h2 className="text-sm font-semibold mb-2">Session timeout</h2>
|
|
631
|
+
<p className="text-xs text-text-muted mb-3">
|
|
632
|
+
Automatically destroys your unlocked keys on this device after this much time with no
|
|
633
|
+
activity anywhere in the app - not just Mail or Settings. Applies the next time you open
|
|
634
|
+
or reload the app.
|
|
635
|
+
</p>
|
|
636
|
+
<select
|
|
637
|
+
aria-label="Session timeout"
|
|
638
|
+
className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
|
|
639
|
+
value={idleTimeoutMinutes}
|
|
640
|
+
onChange={handleIdleTimeoutChange}
|
|
641
|
+
>
|
|
642
|
+
{IDLE_TIMEOUT_OPTIONS_MINUTES.map((minutes) => (
|
|
643
|
+
<option key={minutes} value={minutes}>
|
|
644
|
+
{idleTimeoutLabel(minutes)}
|
|
645
|
+
</option>
|
|
646
|
+
))}
|
|
647
|
+
</select>
|
|
648
|
+
</div>
|
|
649
|
+
|
|
650
|
+
<div>
|
|
651
|
+
<h2 className="text-sm font-semibold mb-2">This session</h2>
|
|
652
|
+
<p className="text-xs text-text-muted mb-3">
|
|
653
|
+
Removes your unlocked keys from this browser tab’s memory right now, without
|
|
654
|
+
affecting any other device. You’ll be asked to unlock again the next time you read
|
|
655
|
+
or send encrypted mail here.
|
|
656
|
+
</p>
|
|
657
|
+
<Button type="button" variant="secondary" className="!w-auto text-danger" onClick={handleDestroyKeysNow}>
|
|
658
|
+
Destroy keys on this device now
|
|
659
|
+
</Button>
|
|
660
|
+
</div>
|
|
661
|
+
</div>
|
|
662
|
+
</div>
|
|
663
|
+
);
|
|
664
|
+
}
|