@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
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The calendar files of zero-access messages, as decrypted on this device (`MessageSecurityResult.calendar`). The server holds only
|
|
7
|
+
* the sealed copy of such a message, so every invitation call for it (`inviteApi.ts`) sends the file along. Kept in memory for the
|
|
8
|
+
* messages read most recently, and cleared on sign-out (`clearSealedInvites()`).
|
|
9
|
+
*/
|
|
10
|
+
/** How many messages' calendar files are kept. */
|
|
11
|
+
const MAX_SEALED_INVITES = 100;
|
|
12
|
+
const calendars = new Map();
|
|
13
|
+
/** Remembers the decrypted calendar file of zero-access message `messageUid`. */
|
|
14
|
+
export function rememberSealedInvite(messageUid, ics) {
|
|
15
|
+
calendars.delete(messageUid);
|
|
16
|
+
calendars.set(messageUid, ics);
|
|
17
|
+
while (calendars.size > MAX_SEALED_INVITES) {
|
|
18
|
+
calendars.delete(calendars.keys().next().value);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** The decrypted calendar file of zero-access message `messageUid`, when this device has read it. */
|
|
22
|
+
export function sealedInviteCalendar(messageUid) {
|
|
23
|
+
return calendars.get(messageUid);
|
|
24
|
+
}
|
|
25
|
+
/** Forgets every remembered calendar file (sign-out). */
|
|
26
|
+
export function clearSealedInvites() {
|
|
27
|
+
calendars.clear();
|
|
28
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Export keys for Outlook/phone" (restapi `specs/search.md` §3, *Clients that don't run RapidMX*): a passphrase-protected
|
|
3
|
+
* PKCS#12 file holding the private keys a mail client needs to read this mailbox's mail on its own - every zero-access key
|
|
4
|
+
* (mail at rest is S/MIME `enveloped-data` to these) and every end-to-end encryption key (current and retained), plus, only
|
|
5
|
+
* when asked, the active signing key. Built entirely here from a fresh master-password unlock: the session's own private
|
|
6
|
+
* keys are non-extractable, and the server never sees the file or the passphrase. restapi only records that an export
|
|
7
|
+
* happened (`key_vault.export`), with the fingerprints exported and nothing else.
|
|
8
|
+
*/
|
|
9
|
+
import "reflect-metadata";
|
|
10
|
+
import type { ApiClient } from "../util/api.js";
|
|
11
|
+
import { type PublicKey } from "./keyvaultApi.js";
|
|
12
|
+
/** PBKDF2 iterations for the shrouded key bags and the PKCS#12 MAC. Windows, macOS and iOS all accept this. */
|
|
13
|
+
export declare const KEY_EXPORT_ITERATIONS = 100000;
|
|
14
|
+
/** The shortest export passphrase accepted. The file grants read access to all of this mailbox's mail. */
|
|
15
|
+
export declare const MIN_EXPORT_PASSPHRASE_LENGTH = 12;
|
|
16
|
+
/** Which key a bag holds, for its friendly name and the export's audit record. */
|
|
17
|
+
export type ExportedKeyKind = "zero-access" | "encrypt" | "sign";
|
|
18
|
+
/** What `exportKeysAsPkcs12()` takes. */
|
|
19
|
+
export interface KeyExportInput {
|
|
20
|
+
mailboxUid: string;
|
|
21
|
+
/** The mailbox's address, used in each key's friendly name. */
|
|
22
|
+
mailboxAddress: string;
|
|
23
|
+
/** The mailbox's published keys (`Mailbox.keys`): the encryption and signing certificates. */
|
|
24
|
+
mailboxKeys: PublicKey[];
|
|
25
|
+
/** The master password, for the fresh unlock every export needs. */
|
|
26
|
+
masterPassword: string;
|
|
27
|
+
/** Protects the PKCS#12 file. At least `MIN_EXPORT_PASSPHRASE_LENGTH` characters. */
|
|
28
|
+
exportPassphrase: string;
|
|
29
|
+
/** Whether to include the active signing key, for clients that will sign. Off unless the user opts in. */
|
|
30
|
+
includeSigningKey?: boolean;
|
|
31
|
+
}
|
|
32
|
+
/** One key in the export. */
|
|
33
|
+
export interface ExportedKey {
|
|
34
|
+
kind: ExportedKeyKind;
|
|
35
|
+
fingerprint: string;
|
|
36
|
+
}
|
|
37
|
+
/** What `exportKeysAsPkcs12()` resolves with. */
|
|
38
|
+
export interface KeyExport {
|
|
39
|
+
/** The DER-encoded PKCS#12 (`.p12`) file. */
|
|
40
|
+
pkcs12: Uint8Array;
|
|
41
|
+
/** The keys it holds, zero-access keys first. */
|
|
42
|
+
keys: ExportedKey[];
|
|
43
|
+
}
|
|
44
|
+
/** The export has nothing to hold - the vault has no zero-access or encryption private key this password opens. */
|
|
45
|
+
export declare class NothingToExportError extends Error {
|
|
46
|
+
constructor();
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Builds the "Export keys for Outlook/phone" PKCS#12 for `input.mailboxUid` (see this module's comment) and records the
|
|
50
|
+
* export with restapi. Re-opens the vault with `masterPassword` - rejecting as `unlockWithPassword()` does on a wrong
|
|
51
|
+
* one - and never touches the in-memory session. Every opened private key and the master key are zeroed before this
|
|
52
|
+
* returns, whether or not it succeeds. Throws `NothingToExportError` when no key opens.
|
|
53
|
+
*/
|
|
54
|
+
export declare function exportKeysAsPkcs12(input: KeyExportInput, client?: ApiClient): Promise<KeyExport>;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* "Export keys for Outlook/phone" (restapi `specs/search.md` §3, *Clients that don't run RapidMX*): a passphrase-protected
|
|
7
|
+
* PKCS#12 file holding the private keys a mail client needs to read this mailbox's mail on its own - every zero-access key
|
|
8
|
+
* (mail at rest is S/MIME `enveloped-data` to these) and every end-to-end encryption key (current and retained), plus, only
|
|
9
|
+
* when asked, the active signing key. Built entirely here from a fresh master-password unlock: the session's own private
|
|
10
|
+
* keys are non-extractable, and the server never sees the file or the passphrase. restapi only records that an export
|
|
11
|
+
* happened (`key_vault.export`), with the fingerprints exported and nothing else.
|
|
12
|
+
*/
|
|
13
|
+
import "reflect-metadata";
|
|
14
|
+
import * as asn1js from "asn1js";
|
|
15
|
+
import * as pkijs from "pkijs";
|
|
16
|
+
import { fromBase64 } from "./encoding.js";
|
|
17
|
+
import { findActivePublicKey, recordKeyExport } from "./keyvaultApi.js";
|
|
18
|
+
import { ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, MAX_RETAINED_ENCRYPTION_KEYS, openMasterKeyWithPassword, SIGNING_PRIVATE_KEY_AAD_PURPOSE, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE, } from "./keySession.js";
|
|
19
|
+
import { buildAad, openWithKey } from "./masterKey.js";
|
|
20
|
+
/** PBKDF2 iterations for the shrouded key bags and the PKCS#12 MAC. Windows, macOS and iOS all accept this. */
|
|
21
|
+
export const KEY_EXPORT_ITERATIONS = 100000;
|
|
22
|
+
/** The shortest export passphrase accepted. The file grants read access to all of this mailbox's mail. */
|
|
23
|
+
export const MIN_EXPORT_PASSPHRASE_LENGTH = 12;
|
|
24
|
+
const PKCS8_SHROUDED_KEY_BAG = "1.2.840.113549.1.12.10.1.2";
|
|
25
|
+
const CERT_BAG = "1.2.840.113549.1.12.10.1.3";
|
|
26
|
+
const X509_CERTIFICATE = "1.2.840.113549.1.9.22.1";
|
|
27
|
+
const FRIENDLY_NAME = "1.2.840.113549.1.9.20";
|
|
28
|
+
const LOCAL_KEY_ID = "1.2.840.113549.1.9.21";
|
|
29
|
+
/** The shrouded key bags' cipher. pkijs's type wants an `iv`, but it generates a fresh one for every bag and ignores this one. */
|
|
30
|
+
const AES_256_CBC = { name: "AES-CBC", length: 256, iv: new Uint8Array(16) };
|
|
31
|
+
/** The export has nothing to hold - the vault has no zero-access or encryption private key this password opens. */
|
|
32
|
+
export class NothingToExportError extends Error {
|
|
33
|
+
constructor() {
|
|
34
|
+
super("This mailbox has no keys to export yet.");
|
|
35
|
+
this.name = "NothingToExportError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function toArrayBuffer(bytes) {
|
|
39
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
40
|
+
}
|
|
41
|
+
function hexBytes(hex) {
|
|
42
|
+
const out = new Uint8Array(hex.length >> 1);
|
|
43
|
+
for (let i = 0; i < out.length; i++) {
|
|
44
|
+
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
function bagAttributes(friendlyName, localKeyId) {
|
|
49
|
+
return [
|
|
50
|
+
new pkijs.Attribute({ type: FRIENDLY_NAME, values: [new asn1js.BmpString({ value: friendlyName })] }),
|
|
51
|
+
new pkijs.Attribute({ type: LOCAL_KEY_ID, values: [new asn1js.OctetString({ valueHex: toArrayBuffer(localKeyId) })] }),
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
function friendlyName(kind, address, index) {
|
|
55
|
+
const label = kind === "zero-access" ? "zero-access key" : kind === "encrypt" ? "encryption key" : "signing key";
|
|
56
|
+
return `RapidMX ${label} (${address})${index > 0 ? ` ${index + 1}` : ""}`;
|
|
57
|
+
}
|
|
58
|
+
/** Opens one wrapped private key to its raw PKCS#8, or `undefined` when it won't open under this master key - an export
|
|
59
|
+
* carries what it can rather than failing on one stale wrap. */
|
|
60
|
+
async function openPkcs8(masterKey, wrapped, aad) {
|
|
61
|
+
try {
|
|
62
|
+
return await openWithKey(masterKey, wrapped, aad);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Opens every key the export holds, in order: zero-access keys (unrevoked newest first), the encryption keys (active
|
|
69
|
+
* first, then retained newest first), then the active signing key if asked. Each list is capped like the session's. */
|
|
70
|
+
async function openExportedKeys(input, vault, masterKey) {
|
|
71
|
+
const opened = [];
|
|
72
|
+
const wrappedFor = (fingerprint) => vault.wrappedKeys.find((k) => k.fingerprint === fingerprint);
|
|
73
|
+
const take = async (kind, keys, purpose, max) => {
|
|
74
|
+
const aad = buildAad(input.mailboxUid, purpose);
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
for (const key of keys) {
|
|
77
|
+
const wrapped = wrappedFor(key.fingerprint);
|
|
78
|
+
if (!wrapped || seen.has(key.fingerprint) || seen.size === max) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const pkcs8 = await openPkcs8(masterKey, wrapped, aad);
|
|
82
|
+
if (pkcs8) {
|
|
83
|
+
seen.add(key.fingerprint);
|
|
84
|
+
opened.push({ kind, fingerprint: key.fingerprint, certDer: fromBase64(key.publicKey), pkcs8 });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const zeroAccess = [...(vault.zeroAccessKeys ?? [])].sort((a, b) => Number(!!a.revokedAt) - Number(!!b.revokedAt) || b.notBefore - a.notBefore);
|
|
89
|
+
await take("zero-access", zeroAccess, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE, MAX_RETAINED_ENCRYPTION_KEYS + 1);
|
|
90
|
+
const active = findActivePublicKey(input.mailboxKeys, "encrypt");
|
|
91
|
+
const encryption = [
|
|
92
|
+
...(active ? [active] : []),
|
|
93
|
+
...input.mailboxKeys.filter((k) => k.useType === "encrypt" && k !== active).sort((a, b) => b.notBefore - a.notBefore),
|
|
94
|
+
];
|
|
95
|
+
await take("encrypt", encryption, ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, MAX_RETAINED_ENCRYPTION_KEYS + 1);
|
|
96
|
+
if (input.includeSigningKey) {
|
|
97
|
+
const signing = findActivePublicKey(input.mailboxKeys, "sign");
|
|
98
|
+
await take("sign", signing ? [signing] : [], SIGNING_PRIVATE_KEY_AAD_PURPOSE, 1);
|
|
99
|
+
}
|
|
100
|
+
return opened;
|
|
101
|
+
}
|
|
102
|
+
/** Builds the PKCS#12 file: one PBES2 (PBKDF2-SHA256, AES-256-CBC) shrouded key bag and one certificate bag per key,
|
|
103
|
+
* paired by `localKeyID`, in an unencrypted SafeContents, under a SHA-256 MAC. */
|
|
104
|
+
async function buildPkcs12(keys, address, passphrase) {
|
|
105
|
+
const crypto = pkijs.getCrypto(true);
|
|
106
|
+
// UTF-8 for both: pkijs re-encodes it as the BMPString the PKCS#12 MAC derivation (RFC 7292 Appendix B) takes.
|
|
107
|
+
const utf8 = toArrayBuffer(new TextEncoder().encode(passphrase));
|
|
108
|
+
const bags = [];
|
|
109
|
+
const shrouded = [];
|
|
110
|
+
const counts = { "zero-access": 0, encrypt: 0, sign: 0 };
|
|
111
|
+
for (const key of keys) {
|
|
112
|
+
const name = friendlyName(key.kind, address, counts[key.kind]++);
|
|
113
|
+
const localKeyId = hexBytes(key.fingerprint).slice(0, 20);
|
|
114
|
+
const keyBag = new pkijs.PKCS8ShroudedKeyBag({ parsedValue: pkijs.PrivateKeyInfo.fromBER(toArrayBuffer(key.pkcs8)) });
|
|
115
|
+
shrouded.push(keyBag);
|
|
116
|
+
bags.push(new pkijs.SafeBag({ bagId: PKCS8_SHROUDED_KEY_BAG, bagValue: keyBag, bagAttributes: bagAttributes(name, localKeyId) }));
|
|
117
|
+
const certBag = new pkijs.CertBag({ certId: X509_CERTIFICATE, parsedValue: pkijs.Certificate.fromBER(toArrayBuffer(key.certDer)) });
|
|
118
|
+
bags.push(new pkijs.SafeBag({ bagId: CERT_BAG, bagValue: certBag, bagAttributes: bagAttributes(name, localKeyId) }));
|
|
119
|
+
}
|
|
120
|
+
for (const keyBag of shrouded) {
|
|
121
|
+
await keyBag.makeInternalValues({ password: utf8, contentEncryptionAlgorithm: AES_256_CBC, hmacHashAlgorithm: "SHA-256", iterationCount: KEY_EXPORT_ITERATIONS }, crypto);
|
|
122
|
+
}
|
|
123
|
+
const pfx = new pkijs.PFX({
|
|
124
|
+
parsedValue: {
|
|
125
|
+
integrityMode: 0,
|
|
126
|
+
authenticatedSafe: new pkijs.AuthenticatedSafe({ parsedValue: { safeContents: [{ privacyMode: 0, value: new pkijs.SafeContents({ safeBags: bags }) }] } }),
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
await pfx.parsedValue.authenticatedSafe.makeInternalValues({ safeContents: [{}] }, crypto);
|
|
130
|
+
await pfx.makeInternalValues({ password: utf8, iterations: KEY_EXPORT_ITERATIONS, pbkdf2HashAlgorithm: { name: "SHA-256" }, hmacHashAlgorithm: "SHA-256" }, crypto);
|
|
131
|
+
return new Uint8Array(pfx.toSchema().toBER(false));
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Builds the "Export keys for Outlook/phone" PKCS#12 for `input.mailboxUid` (see this module's comment) and records the
|
|
135
|
+
* export with restapi. Re-opens the vault with `masterPassword` - rejecting as `unlockWithPassword()` does on a wrong
|
|
136
|
+
* one - and never touches the in-memory session. Every opened private key and the master key are zeroed before this
|
|
137
|
+
* returns, whether or not it succeeds. Throws `NothingToExportError` when no key opens.
|
|
138
|
+
*/
|
|
139
|
+
export async function exportKeysAsPkcs12(input, client) {
|
|
140
|
+
if (input.exportPassphrase.length < MIN_EXPORT_PASSPHRASE_LENGTH) {
|
|
141
|
+
throw new Error(`The export passphrase must be at least ${MIN_EXPORT_PASSPHRASE_LENGTH} characters.`);
|
|
142
|
+
}
|
|
143
|
+
const { vault, masterKey } = await openMasterKeyWithPassword(input.mailboxUid, input.masterPassword, client);
|
|
144
|
+
let opened = [];
|
|
145
|
+
try {
|
|
146
|
+
opened = await openExportedKeys(input, vault, masterKey);
|
|
147
|
+
if (!opened.some((k) => k.kind !== "sign")) {
|
|
148
|
+
throw new NothingToExportError();
|
|
149
|
+
}
|
|
150
|
+
const pkcs12 = await buildPkcs12(opened, input.mailboxAddress, input.exportPassphrase);
|
|
151
|
+
const keys = opened.map(({ kind, fingerprint }) => ({ kind, fingerprint }));
|
|
152
|
+
await recordKeyExport(input.mailboxUid, keys, client);
|
|
153
|
+
return { pkcs12, keys };
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
masterKey.fill(0);
|
|
157
|
+
for (const key of opened) {
|
|
158
|
+
key.pkcs8.fill(0);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* follow-up, not a silent gap: a mailbox enrolled *only* with a passkey has no way to unlock through this
|
|
18
18
|
* module today.
|
|
19
19
|
*/
|
|
20
|
-
import { type PublicKey } from "./keyvaultApi.js";
|
|
20
|
+
import { type KeyVault, type PublicKey } from "./keyvaultApi.js";
|
|
21
|
+
import type { ApiClient } from "../util/api.js";
|
|
21
22
|
import { KeysLockedError } from "./masterKey.js";
|
|
22
23
|
/** AAD purpose labels — MUST exactly match what `KeyEnrollmentGate.tsx` used when it originally
|
|
23
24
|
* sealed each of these values, or `openWithKey()` fails (GCM authenticates the AAD, not just the
|
|
@@ -25,6 +26,7 @@ import { KeysLockedError } from "./masterKey.js";
|
|
|
25
26
|
export declare const MASTER_KEY_AAD_PURPOSE = "master-key";
|
|
26
27
|
export declare const SIGNING_PRIVATE_KEY_AAD_PURPOSE = "sign-private-key";
|
|
27
28
|
export declare const ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE = "encrypt-private-key";
|
|
29
|
+
export declare const ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE = "zero-access-private-key";
|
|
28
30
|
export { KeysLockedError };
|
|
29
31
|
/** How many retained (non-active) encryption keys an unlock opens at most: the most recently issued ones by
|
|
30
32
|
* `notBefore`. Each costs an AEAD open and a key import at unlock, and `smime.ts`'s `decryptEnvelopedDataWithKeys()`
|
|
@@ -60,6 +62,17 @@ export interface UnlockedKeys {
|
|
|
60
62
|
* that). A compromised key is kept because reading one's own mail encrypted to it is still needed. Absent when there
|
|
61
63
|
* are none. */
|
|
62
64
|
retainedEncryptionKeys?: RetainedEncryptionKey[];
|
|
65
|
+
/** The mailbox's zero-access private keys (restapi `specs/search.md` §3) - the keys the server encrypts mail at rest,
|
|
66
|
+
* index entries and sealed summaries to - the active one first, then retained ones newest first, at most
|
|
67
|
+
* `MAX_RETAINED_ENCRYPTION_KEYS + 1`. Decryption only. Absent when the mailbox has none (or none opened). */
|
|
68
|
+
zeroAccessKeys?: RetainedEncryptionKey[];
|
|
69
|
+
/** A shared mailbox opened through this user's member key wraps (`sharedMailboxKeys.ts`), not with its own master key: it holds
|
|
70
|
+
* only `zeroAccessKeys`, and `masterKey` is a per-key secret derived from its zero-access private key (what the local search
|
|
71
|
+
* index's key is derived from), never a key that opens the mailbox's vault. */
|
|
72
|
+
shared?: boolean;
|
|
73
|
+
/** A shared session only: the user's own mailboxes whose zero-access keys opened its member key wraps. Locking any of them locks it too -
|
|
74
|
+
* it was opened with their keys, so it doesn't outlive them. */
|
|
75
|
+
openedWith?: string[];
|
|
63
76
|
}
|
|
64
77
|
/** What changed in the session store — passed to every `subscribeKeySession()` listener. */
|
|
65
78
|
export interface KeySessionEvent {
|
|
@@ -76,6 +89,28 @@ export type KeySessionListener = (event: KeySessionEvent) => void;
|
|
|
76
89
|
* nothing. Returns an unsubscribe function.
|
|
77
90
|
*/
|
|
78
91
|
export declare function subscribeKeySession(listener: KeySessionListener): () => void;
|
|
92
|
+
/**
|
|
93
|
+
* Makes a zero-access key this device has just enrolled (`zeroAccessKey.ts`'s `provisionZeroAccessKey()`) the active one
|
|
94
|
+
* of `unlocked` - the session object the key's private half was sealed under - so zero-access mail encrypted to it reads
|
|
95
|
+
* without another unlock. Does nothing when `unlocked` is no longer this mailbox's session (it was locked or replaced
|
|
96
|
+
* meanwhile; the next unlock opens the key from the vault anyway).
|
|
97
|
+
*/
|
|
98
|
+
export declare function addZeroAccessKey(mailboxUid: string, unlocked: UnlockedKeys, key: RetainedEncryptionKey): void;
|
|
99
|
+
/**
|
|
100
|
+
* Stores the keys of a shared mailbox opened through member key wraps (`sharedMailboxKeys.ts`'s `unlockSharedMailbox()`) as that mailbox's
|
|
101
|
+
* session, exactly as an unlock would - tracked for `destroyUnlockedKeys()`, and notified - unless the mailbox was locked since `generation`
|
|
102
|
+
* was taken (`sharedUnlockGeneration()`), when they are destroyed instead and `KeysLockedError` is thrown.
|
|
103
|
+
*/
|
|
104
|
+
export declare function installSharedSession(mailboxUid: string, unlocked: UnlockedKeys, generation: string): void;
|
|
105
|
+
/** The lock generation to pass `installSharedSession()`, taken before a shared unlock starts its requests. */
|
|
106
|
+
export declare function sharedUnlockGeneration(mailboxUid: string): string;
|
|
107
|
+
/** Every mailbox unlocked this session with its own keys (not a shared one opened through member key wraps). */
|
|
108
|
+
export declare function personallyUnlockedKeys(): UnlockedKeys[];
|
|
109
|
+
/** `personallyUnlockedKeys()` with each mailbox's uid. */
|
|
110
|
+
export declare function personalSessions(): {
|
|
111
|
+
mailboxUid: string;
|
|
112
|
+
unlocked: UnlockedKeys;
|
|
113
|
+
}[];
|
|
79
114
|
/** Reads back a mailbox's already-unlocked keys this session, or `undefined` if it hasn't been
|
|
80
115
|
* unlocked yet (or was destroyed). Never triggers an unlock itself. */
|
|
81
116
|
export declare function getUnlockedKeys(mailboxUid: string): UnlockedKeys | undefined;
|
|
@@ -133,6 +168,15 @@ export declare class UnopenableEncryptionKeyError extends Error {
|
|
|
133
168
|
* `destroyUnlockedKeys()` locked this mailbox (or all mailboxes) while the unlock was in flight.
|
|
134
169
|
*/
|
|
135
170
|
export declare function unlockWithPassword(mailboxUid: string, mailboxKeys: PublicKey[], password: string): Promise<UnlockResult>;
|
|
171
|
+
/**
|
|
172
|
+
* Fetches `mailboxUid`'s vault and opens its master key with the password wrap, touching no session - for an operation that
|
|
173
|
+
* must re-prove the password even while the mailbox is unlocked (`keyExport.ts`). Rejects exactly as `unlockWithPassword()`
|
|
174
|
+
* does on a missing wrap, an unknown KDF or a wrong password. The caller owns the returned master key and must zero it.
|
|
175
|
+
*/
|
|
176
|
+
export declare function openMasterKeyWithPassword(mailboxUid: string, password: string, client?: ApiClient): Promise<{
|
|
177
|
+
vault: KeyVault;
|
|
178
|
+
masterKey: Uint8Array;
|
|
179
|
+
}>;
|
|
136
180
|
/** What `unlockWithRecoveryCode()` resolves with. */
|
|
137
181
|
export interface RecoveryUnlockResult extends UnlockResult {
|
|
138
182
|
/** The `methodId` of the recovery wrap the code opened (e.g. `"recovery-3"`). Recovery codes are single-use:
|
|
@@ -33,6 +33,7 @@ import { RECOVERY_KDF_LABEL, deriveFromRecoveryCode } from "./recoveryCode.js";
|
|
|
33
33
|
export const MASTER_KEY_AAD_PURPOSE = "master-key";
|
|
34
34
|
export const SIGNING_PRIVATE_KEY_AAD_PURPOSE = "sign-private-key";
|
|
35
35
|
export const ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE = "encrypt-private-key";
|
|
36
|
+
export const ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE = "zero-access-private-key";
|
|
36
37
|
export { KeysLockedError };
|
|
37
38
|
/** How many retained (non-active) encryption keys an unlock opens at most: the most recently issued ones by
|
|
38
39
|
* `notBefore`. Each costs an AEAD open and a key import at unlock, and `smime.ts`'s `decryptEnvelopedDataWithKeys()`
|
|
@@ -89,6 +90,47 @@ export function subscribeKeySession(listener) {
|
|
|
89
90
|
listeners.delete(listener);
|
|
90
91
|
};
|
|
91
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Makes a zero-access key this device has just enrolled (`zeroAccessKey.ts`'s `provisionZeroAccessKey()`) the active one
|
|
95
|
+
* of `unlocked` - the session object the key's private half was sealed under - so zero-access mail encrypted to it reads
|
|
96
|
+
* without another unlock. Does nothing when `unlocked` is no longer this mailbox's session (it was locked or replaced
|
|
97
|
+
* meanwhile; the next unlock opens the key from the vault anyway).
|
|
98
|
+
*/
|
|
99
|
+
export function addZeroAccessKey(mailboxUid, unlocked, key) {
|
|
100
|
+
if (unlocked.destroyed || sessions.get(mailboxUid) !== unlocked) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
unlocked.zeroAccessKeys = [key, ...(unlocked.zeroAccessKeys ?? []).filter((k) => k.fingerprint !== key.fingerprint)];
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Stores the keys of a shared mailbox opened through member key wraps (`sharedMailboxKeys.ts`'s `unlockSharedMailbox()`) as that mailbox's
|
|
107
|
+
* session, exactly as an unlock would - tracked for `destroyUnlockedKeys()`, and notified - unless the mailbox was locked since `generation`
|
|
108
|
+
* was taken (`sharedUnlockGeneration()`), when they are destroyed instead and `KeysLockedError` is thrown.
|
|
109
|
+
*/
|
|
110
|
+
export function installSharedSession(mailboxUid, unlocked, generation) {
|
|
111
|
+
if (lockGeneration(mailboxUid) !== generation) {
|
|
112
|
+
destroyObject(unlocked);
|
|
113
|
+
throw new KeysLockedError();
|
|
114
|
+
}
|
|
115
|
+
unlocked.shared = true;
|
|
116
|
+
sessions.set(mailboxUid, unlocked);
|
|
117
|
+
const objects = issued.get(mailboxUid) ?? new Set();
|
|
118
|
+
objects.add(weakHandle(unlocked));
|
|
119
|
+
issued.set(mailboxUid, objects);
|
|
120
|
+
notify({ mailboxUid, state: "unlocked" });
|
|
121
|
+
}
|
|
122
|
+
/** The lock generation to pass `installSharedSession()`, taken before a shared unlock starts its requests. */
|
|
123
|
+
export function sharedUnlockGeneration(mailboxUid) {
|
|
124
|
+
return lockGeneration(mailboxUid);
|
|
125
|
+
}
|
|
126
|
+
/** Every mailbox unlocked this session with its own keys (not a shared one opened through member key wraps). */
|
|
127
|
+
export function personallyUnlockedKeys() {
|
|
128
|
+
return personalSessions().map((session) => session.unlocked);
|
|
129
|
+
}
|
|
130
|
+
/** `personallyUnlockedKeys()` with each mailbox's uid. */
|
|
131
|
+
export function personalSessions() {
|
|
132
|
+
return [...sessions].filter(([, unlocked]) => !unlocked.shared).map(([mailboxUid, unlocked]) => ({ mailboxUid, unlocked }));
|
|
133
|
+
}
|
|
92
134
|
/** Reads back a mailbox's already-unlocked keys this session, or `undefined` if it hasn't been
|
|
93
135
|
* unlocked yet (or was destroyed). Never triggers an unlock itself. */
|
|
94
136
|
export function getUnlockedKeys(mailboxUid) {
|
|
@@ -132,6 +174,14 @@ export function destroyUnlockedKeys(mailboxUid) {
|
|
|
132
174
|
sessions.delete(uid);
|
|
133
175
|
notify({ mailboxUid: uid, state: "locked" });
|
|
134
176
|
}
|
|
177
|
+
// The shared mailboxes opened with this mailbox's keys go with it.
|
|
178
|
+
if (mailboxUid) {
|
|
179
|
+
for (const [uid, unlocked] of [...sessions]) {
|
|
180
|
+
if (unlocked.shared && unlocked.openedWith?.includes(mailboxUid)) {
|
|
181
|
+
destroyUnlockedKeys(uid);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
135
185
|
}
|
|
136
186
|
function destroyObject(unlocked) {
|
|
137
187
|
unlocked.masterKey.fill(0);
|
|
@@ -143,6 +193,10 @@ function destroyObject(unlocked) {
|
|
|
143
193
|
unlocked.retainedEncryptionKeys.length = 0;
|
|
144
194
|
delete unlocked.retainedEncryptionKeys;
|
|
145
195
|
}
|
|
196
|
+
if (unlocked.zeroAccessKeys) {
|
|
197
|
+
unlocked.zeroAccessKeys.length = 0;
|
|
198
|
+
delete unlocked.zeroAccessKeys;
|
|
199
|
+
}
|
|
146
200
|
}
|
|
147
201
|
/** Finds the wrapped private key whose fingerprint matches a given published public key. */
|
|
148
202
|
function findWrappedPrivateKey(vault, fingerprint) {
|
|
@@ -207,6 +261,33 @@ async function openRetainedEncryptionKeys(mailboxUid, mailboxKeys, vault, unlock
|
|
|
207
261
|
}
|
|
208
262
|
}
|
|
209
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Opens the mailbox's zero-access private keys into `unlocked.zeroAccessKeys`: the vault's `zeroAccessKeys` that have a
|
|
266
|
+
* wrapped private key, the unrevoked one with the latest `notBefore` first, then the rest newest first, at most
|
|
267
|
+
* `MAX_RETAINED_ENCRYPTION_KEYS + 1`. A key that won't open is listed in `unopenableKeys` and skipped - zero-access mail
|
|
268
|
+
* encrypted to it doesn't decrypt on this device, but nothing else about the unlock depends on it.
|
|
269
|
+
*/
|
|
270
|
+
async function openZeroAccessKeys(mailboxUid, vault, unlocked, unopenableKeys) {
|
|
271
|
+
const ordered = [...(vault.zeroAccessKeys ?? [])].sort((a, b) => Number(!!a.revokedAt) - Number(!!b.revokedAt) || b.notBefore - a.notBefore);
|
|
272
|
+
const aad = buildAad(mailboxUid, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE);
|
|
273
|
+
let opened = 0;
|
|
274
|
+
for (const publicKey of ordered) {
|
|
275
|
+
const wrapped = findWrappedPrivateKey(vault, publicKey.fingerprint);
|
|
276
|
+
if (!wrapped || wrapped.useType !== "zero-access") {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
const privateKey = await openPrivateKey(unlocked.masterKey, wrapped, aad, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
|
|
281
|
+
(unlocked.zeroAccessKeys ?? (unlocked.zeroAccessKeys = [])).push({ fingerprint: publicKey.fingerprint, certDer: fromBase64(publicKey.publicKey), privateKey });
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
unopenableKeys.push(publicKey.fingerprint);
|
|
285
|
+
}
|
|
286
|
+
if (++opened === MAX_RETAINED_ENCRYPTION_KEYS + 1) {
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
210
291
|
/**
|
|
211
292
|
* Unlocks a mailbox's key vault with its password unlock method: fetches the vault, derives the
|
|
212
293
|
* wrapping key from `password` using the *exact* KDF parameters that wrap was created with, unwraps
|
|
@@ -225,7 +306,16 @@ async function openRetainedEncryptionKeys(mailboxUid, mailboxKeys, vault, unlock
|
|
|
225
306
|
*/
|
|
226
307
|
export async function unlockWithPassword(mailboxUid, mailboxKeys, password) {
|
|
227
308
|
const generation = lockGeneration(mailboxUid);
|
|
228
|
-
const vault = await
|
|
309
|
+
const { vault, masterKey } = await openMasterKeyWithPassword(mailboxUid, password);
|
|
310
|
+
return openSession(mailboxUid, mailboxKeys, vault, masterKey, generation);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Fetches `mailboxUid`'s vault and opens its master key with the password wrap, touching no session - for an operation that
|
|
314
|
+
* must re-prove the password even while the mailbox is unlocked (`keyExport.ts`). Rejects exactly as `unlockWithPassword()`
|
|
315
|
+
* does on a missing wrap, an unknown KDF or a wrong password. The caller owns the returned master key and must zero it.
|
|
316
|
+
*/
|
|
317
|
+
export async function openMasterKeyWithPassword(mailboxUid, password, client) {
|
|
318
|
+
const vault = await getKeyVault(mailboxUid, client);
|
|
229
319
|
const passwordWrap = vault.masterKeyWraps.find((w) => w.method === "password");
|
|
230
320
|
if (!passwordWrap) {
|
|
231
321
|
throw new Error("This mailbox has no password unlock method enrolled.");
|
|
@@ -237,7 +327,7 @@ export async function unlockWithPassword(mailboxUid, mailboxKeys, password) {
|
|
|
237
327
|
const salt = fromBase64(passwordWrap.salt);
|
|
238
328
|
const { wrappingKey } = await deriveFromPassword(password, salt, kdfParams);
|
|
239
329
|
const masterKey = await openWithKey(wrappingKey, { ciphertext: passwordWrap.ciphertext, nonce: passwordWrap.nonce }, buildAad(mailboxUid, MASTER_KEY_AAD_PURPOSE));
|
|
240
|
-
return
|
|
330
|
+
return { vault, masterKey };
|
|
241
331
|
}
|
|
242
332
|
/** The one rejection for a recovery code that opens nothing - a wrong code and a vault with no recovery wraps
|
|
243
333
|
* alike, so a UI can say "that recovery code didn't work" without learning (or revealing) which it was. */
|
|
@@ -337,6 +427,7 @@ async function openSession(mailboxUid, mailboxKeys, vault, masterKey, generation
|
|
|
337
427
|
unlocked.encryptionFingerprint = encryptionPublicKey.fingerprint;
|
|
338
428
|
}
|
|
339
429
|
await openRetainedEncryptionKeys(mailboxUid, mailboxKeys, vault, unlocked, encryptionPublicKey?.fingerprint, unopenableKeys);
|
|
430
|
+
await openZeroAccessKeys(mailboxUid, vault, unlocked, unopenableKeys);
|
|
340
431
|
if (lockGeneration(mailboxUid) !== generation) {
|
|
341
432
|
// Locked (e.g. logout) while this unlock was awaiting - never restore keys after that.
|
|
342
433
|
throw new KeysLockedError();
|
|
@@ -41,7 +41,16 @@ export interface WrappedPrivateKey {
|
|
|
41
41
|
/** AEAD algorithm identifier (e.g. `AES-256-GCM`). */
|
|
42
42
|
algorithm: string;
|
|
43
43
|
fingerprint: string;
|
|
44
|
-
useType:
|
|
44
|
+
useType: KeyUseType;
|
|
45
|
+
}
|
|
46
|
+
/** What a mailbox's own key is used for. `sign`/`encrypt` keys are published (`Mailbox.keys`); `zero-access` keys never are.
|
|
47
|
+
* Mirrors `@rapidmx/restapi`'s `KeyUseType`. */
|
|
48
|
+
export type KeyUseType = "sign" | "encrypt" | "zero-access";
|
|
49
|
+
/** A mailbox's zero-access key (restapi `specs/search.md` §3): the certificate the server encrypts mail at rest to on
|
|
50
|
+
* arrival. Same shape as `PublicKey`, but never published - only the vault's reader gets it (`KeyVault.zeroAccessKeys`).
|
|
51
|
+
* Mirrors `@rapidmx/restapi`'s `ZeroAccessKey`. */
|
|
52
|
+
export interface ZeroAccessKey extends Omit<PublicKey, "useType" | "issuerCertificate"> {
|
|
53
|
+
useType: "zero-access";
|
|
45
54
|
}
|
|
46
55
|
/** One wrapped copy of the mailbox master key (MK), per unlock method. Mirrors `@rapidmx/restapi`'s
|
|
47
56
|
* `MasterKeyWrap` exactly. */
|
|
@@ -102,6 +111,9 @@ export interface KeyVault {
|
|
|
102
111
|
* (`enrollKey()`, `startSignEnrollment()`, `addMasterKeyWrap()`, `rekey()`), so restapi refuses it with `409` if
|
|
103
112
|
* another device rotated the master key meanwhile. Absent from servers that don't track it yet. */
|
|
104
113
|
masterKeyGeneration?: number;
|
|
114
|
+
/** The mailbox's zero-access certificates, newest last - returned with the vault because they are never published and
|
|
115
|
+
* an unlock needs them to decrypt zero-access mail. Absent from servers without zero-access encryption. */
|
|
116
|
+
zeroAccessKeys?: ZeroAccessKey[];
|
|
105
117
|
}
|
|
106
118
|
/** The optional optimistic check restapi applies to vault writes carrying key material sealed under the master key. */
|
|
107
119
|
export interface ExpectedMasterKeyGeneration {
|
|
@@ -147,8 +159,8 @@ export declare function isTrustedForVerification(key: PublicKey): boolean;
|
|
|
147
159
|
*/
|
|
148
160
|
export declare function signingKeyFingerprints(keys: PublicKey[] | undefined, previousKeys?: PreviousKey[]): string[];
|
|
149
161
|
export interface EnrollKeyInput extends ExpectedMasterKeyGeneration {
|
|
150
|
-
useType:
|
|
151
|
-
/** PEM-encoded PKCS#10 CSR — required (and only meaningful) for `useType: "encrypt"`; the server
|
|
162
|
+
useType: KeyUseType;
|
|
163
|
+
/** PEM-encoded PKCS#10 CSR — required (and only meaningful) for `useType: "encrypt"` and `"zero-access"`; the server
|
|
152
164
|
* calls its own internal CA against this CSR. */
|
|
153
165
|
csr?: string;
|
|
154
166
|
/** An already-issued PEM certificate — required (and only meaningful) for `useType: "sign"`. */
|
|
@@ -298,6 +310,75 @@ export declare function addMasterKeyWrap(mailboxUid: string, wrap: MasterKeyWrap
|
|
|
298
310
|
* than one wrap could share the same `method` (e.g. multiple passkeys). This alone does NOT revoke
|
|
299
311
|
* access for anyone who already captured the wrapped blob — see `rekey()`. */
|
|
300
312
|
export declare function removeMasterKeyWrap(mailboxUid: string, method: string, methodId?: string, client?: ApiClient): Promise<KeyVault>;
|
|
313
|
+
/** One member's copy of a shared mailbox's zero-access private key: base64 CMS of its PKCS#8, to the member's own zero-access certificate. */
|
|
314
|
+
export interface MemberKeyWrap {
|
|
315
|
+
memberUserUid: string;
|
|
316
|
+
recipientFingerprint: string;
|
|
317
|
+
zeroAccessKeyFingerprint: string;
|
|
318
|
+
cms: string;
|
|
319
|
+
createdAt?: number;
|
|
320
|
+
}
|
|
321
|
+
/** One member in `SharedKeyStatus`. */
|
|
322
|
+
export interface SharedKeyMember {
|
|
323
|
+
userUid: string;
|
|
324
|
+
full: boolean;
|
|
325
|
+
/** Their own zero-access certificate - absent until they first sign in and get one. */
|
|
326
|
+
certificate?: ZeroAccessKey;
|
|
327
|
+
/** The mailbox's zero-access keys they hold a wrap of. */
|
|
328
|
+
wrapped: string[];
|
|
329
|
+
}
|
|
330
|
+
/** Where a shared mailbox's zero-access key stands with its members - restapi `GET /mail/mailboxes/:id/keyvault/members`. */
|
|
331
|
+
export interface SharedKeyStatus {
|
|
332
|
+
ownerless: boolean;
|
|
333
|
+
/** The caller: the mailbox's owner, one of its members, or neither (an administrator acting through a role). */
|
|
334
|
+
role?: "owner" | "member" | "other";
|
|
335
|
+
activeKeyFingerprint?: string;
|
|
336
|
+
zeroAccessKeys: ZeroAccessKey[];
|
|
337
|
+
members: SharedKeyMember[];
|
|
338
|
+
/** Members with a certificate but no wrap of the active key. */
|
|
339
|
+
pending: string[];
|
|
340
|
+
/** Users holding wraps who are no longer members. */
|
|
341
|
+
departed: string[];
|
|
342
|
+
/** A departed user holds the active key: a new key must be provisioned. */
|
|
343
|
+
rotationPending: boolean;
|
|
344
|
+
/** The caller may change the wraps. */
|
|
345
|
+
manager: boolean;
|
|
346
|
+
/** A mailbox without an owner assigned to an escrow scope: that scope. */
|
|
347
|
+
escrowScopeId?: string;
|
|
348
|
+
/** Its zero-access keys (fingerprints) with no escrow copy for `escrowScopeId` yet. Absent from servers that predate it. */
|
|
349
|
+
escrowPending?: string[];
|
|
350
|
+
}
|
|
351
|
+
/** An escrow copy of a mailbox-without-an-owner's zero-access private key, as `putZeroAccessEscrowWraps()` sends it: CMS of its PKCS#8 to
|
|
352
|
+
* the escrow scope's certificate (`getEscrowInfo()`). */
|
|
353
|
+
export interface ZeroAccessEscrowWrapInput {
|
|
354
|
+
escrowScopeId: string;
|
|
355
|
+
zeroAccessKeyFingerprint: string;
|
|
356
|
+
/** Base64 DER CMS `EnvelopedData`. */
|
|
357
|
+
cms: string;
|
|
358
|
+
}
|
|
359
|
+
export declare function getSharedKeyStatus(mailboxUid: string, client?: ApiClient): Promise<SharedKeyStatus>;
|
|
360
|
+
/** The caller's own member key wraps of `mailboxUid` (`[]` for its owner and for a member still waiting). */
|
|
361
|
+
export declare function getMyMemberKeyWraps(mailboxUid: string, client?: ApiClient): Promise<MemberKeyWrap[]>;
|
|
362
|
+
export declare function putMemberKeyWraps(mailboxUid: string, wraps: MemberKeyWrap[], client?: ApiClient): Promise<SharedKeyStatus>;
|
|
363
|
+
/** Removes every wrap of a user who is no longer a member - the last step of a rotation. */
|
|
364
|
+
export declare function removeMemberKeyWraps(mailboxUid: string, memberUserUid: string, client?: ApiClient): Promise<SharedKeyStatus>;
|
|
365
|
+
/** Provisions (or rotates) a mailbox-without-an-owner's zero-access key with its member wraps, in one write - and, for a mailbox assigned to
|
|
366
|
+
* an escrow scope, its escrow copy (required then). */
|
|
367
|
+
export declare function enrollSharedZeroAccessKey(mailboxUid: string, input: {
|
|
368
|
+
csr: string;
|
|
369
|
+
wraps: Omit<MemberKeyWrap, "zeroAccessKeyFingerprint">[];
|
|
370
|
+
escrowWrap?: Omit<ZeroAccessEscrowWrapInput, "zeroAccessKeyFingerprint">;
|
|
371
|
+
}, client?: ApiClient): Promise<SharedKeyStatus>;
|
|
372
|
+
/** Adds the escrow copies of a mailbox-without-an-owner's zero-access keys its escrow scope is missing (`SharedKeyStatus.escrowPending`). */
|
|
373
|
+
export declare function putZeroAccessEscrowWraps(mailboxUid: string, wraps: ZeroAccessEscrowWrapInput[], client?: ApiClient): Promise<SharedKeyStatus>;
|
|
374
|
+
/** One key a "Export keys for Outlook/phone" file held, as `recordKeyExport()` reports it. */
|
|
375
|
+
export interface ExportedKeyRecord {
|
|
376
|
+
kind: "zero-access" | "encrypt" | "sign";
|
|
377
|
+
fingerprint: string;
|
|
378
|
+
}
|
|
379
|
+
/** Records with restapi that the owner exported these private keys (`key_vault.export` in the audit log). `keyExport.ts`
|
|
380
|
+
* calls it before handing over the file, so an export the server refuses to record isn't handed over. Owner-only. */
|
|
381
|
+
export declare function recordKeyExport(mailboxUid: string, keys: ExportedKeyRecord[], client?: ApiClient): Promise<void>;
|
|
301
382
|
export interface RekeyInput extends ExpectedMasterKeyGeneration {
|
|
302
383
|
wrappedKeys: WrappedPrivateKey[];
|
|
303
384
|
/** Every wrap of the new master key. For a mailbox assigned an escrow scope, this must include a fresh escrow wrap
|
|
@@ -386,6 +467,10 @@ export interface EncryptionPolicy {
|
|
|
386
467
|
encryptSameOrg: PolicyState;
|
|
387
468
|
encryptFederated: PolicyState;
|
|
388
469
|
encryptExternal: PolicyState;
|
|
470
|
+
/** "Allow zero-access encryption": whether mailboxes may store mail encrypted at rest. Always present from a server that
|
|
471
|
+
* supports zero-access encryption (its stored value or the server default); absent from one that doesn't, which a
|
|
472
|
+
* caller treats as not allowed. */
|
|
473
|
+
allowZeroAccess?: boolean;
|
|
389
474
|
}
|
|
390
475
|
/** The system-wide encryption policy (readable by any authenticated user, used to decide what encryption
|
|
391
476
|
* controls a compose UI should offer). */
|
|
@@ -248,6 +248,38 @@ export function removeMasterKeyWrap(mailboxUid, method, methodId, client) {
|
|
|
248
248
|
method: "DELETE",
|
|
249
249
|
});
|
|
250
250
|
}
|
|
251
|
+
const vaultPath = (mailboxUid) => `/mail/mailboxes/${encodeURIComponent(mailboxUid)}/keyvault`;
|
|
252
|
+
export function getSharedKeyStatus(mailboxUid, client) {
|
|
253
|
+
return withClient(client, `${vaultPath(mailboxUid)}/members`);
|
|
254
|
+
}
|
|
255
|
+
/** The caller's own member key wraps of `mailboxUid` (`[]` for its owner and for a member still waiting). */
|
|
256
|
+
export function getMyMemberKeyWraps(mailboxUid, client) {
|
|
257
|
+
return withClient(client, `${vaultPath(mailboxUid)}/member-wraps`);
|
|
258
|
+
}
|
|
259
|
+
export function putMemberKeyWraps(mailboxUid, wraps, client) {
|
|
260
|
+
return withClient(client, `${vaultPath(mailboxUid)}/member-wraps`, { method: "PUT", body: JSON.stringify({ wraps }) });
|
|
261
|
+
}
|
|
262
|
+
/** Removes every wrap of a user who is no longer a member - the last step of a rotation. */
|
|
263
|
+
export function removeMemberKeyWraps(mailboxUid, memberUserUid, client) {
|
|
264
|
+
return withClient(client, `${vaultPath(mailboxUid)}/member-wraps/${encodeURIComponent(memberUserUid)}`, { method: "DELETE" });
|
|
265
|
+
}
|
|
266
|
+
/** Provisions (or rotates) a mailbox-without-an-owner's zero-access key with its member wraps, in one write - and, for a mailbox assigned to
|
|
267
|
+
* an escrow scope, its escrow copy (required then). */
|
|
268
|
+
export function enrollSharedZeroAccessKey(mailboxUid, input, client) {
|
|
269
|
+
return withClient(client, `${vaultPath(mailboxUid)}/shared-zero-access-key`, { method: "POST", body: JSON.stringify(input) });
|
|
270
|
+
}
|
|
271
|
+
/** Adds the escrow copies of a mailbox-without-an-owner's zero-access keys its escrow scope is missing (`SharedKeyStatus.escrowPending`). */
|
|
272
|
+
export function putZeroAccessEscrowWraps(mailboxUid, wraps, client) {
|
|
273
|
+
return withClient(client, `${vaultPath(mailboxUid)}/escrow-wraps`, { method: "PUT", body: JSON.stringify({ wraps }) });
|
|
274
|
+
}
|
|
275
|
+
/** Records with restapi that the owner exported these private keys (`key_vault.export` in the audit log). `keyExport.ts`
|
|
276
|
+
* calls it before handing over the file, so an export the server refuses to record isn't handed over. Owner-only. */
|
|
277
|
+
export async function recordKeyExport(mailboxUid, keys, client) {
|
|
278
|
+
await withClient(client, `/mail/mailboxes/${encodeURIComponent(mailboxUid)}/keyvault/exports`, {
|
|
279
|
+
method: "POST",
|
|
280
|
+
body: JSON.stringify({ keys }),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
251
283
|
/** Full, atomic replacement of the mailbox's key-vault contents — the only real revocation mechanism
|
|
252
284
|
* for a captured wrap. Restricted server-side to the mailbox's actual owner. Refused (409) while a signing enrollment
|
|
253
285
|
* holding a wrapped key is in flight (see `cancelSignEnrollment()`), or when an escrowed mailbox's request carries no
|