@rapidmx/web-client 0.35.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/escrow/matters/[uid].tsx +4 -3
- package/apps/shared/calendar/calendarReminders.ts +35 -0
- package/apps/shared/calendar/sealedEvents.ts +113 -0
- package/apps/shared/calendar/useCalendarReminders.ts +28 -9
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +89 -3
- package/apps/shared/components/calendar/EventDetails.tsx +13 -2
- package/apps/shared/components/calendar/EventEditor.tsx +124 -17
- package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
- package/apps/shared/components/calendar/EventModal.tsx +19 -2
- package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
- package/apps/shared/components/calendar/eventForm.ts +2 -0
- package/apps/shared/components/contacts/ContactCard.tsx +13 -1
- package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
- package/apps/shared/components/contacts/ContactForm.tsx +56 -7
- package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
- package/apps/shared/components/contacts/contactCardData.ts +8 -4
- package/apps/shared/components/contacts/contactKeys.ts +3 -3
- package/apps/shared/components/layout/AppShell.tsx +34 -5
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +30 -1
- package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +122 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
- package/apps/shared/components/mail/ConversationList.tsx +1 -1
- package/apps/shared/components/mail/InviteCard.tsx +11 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +59 -26
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
- package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
- package/apps/shared/components/mail/compose/draftResume.ts +28 -3
- package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
- package/apps/shared/components/mail/compose/quotedBody.ts +4 -3
- package/apps/shared/components/mail/invite/inviteStore.ts +17 -1
- package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
- package/apps/shared/components/mail/pinnedSigners.ts +14 -0
- package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
- package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
- package/apps/shared/components/settings/KeyExportSetting.tsx +173 -0
- package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
- package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
- package/apps/shared/components/settings/ZeroAccessSetting.tsx +128 -0
- package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
- package/apps/shared/mail/decryptedMessages.ts +11 -3
- package/apps/shared/mail/outbox/sendJob.ts +349 -50
- package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
- package/apps/shared/mail/reportNotices.ts +3 -1
- package/apps/shared/mail/zeroAccessSource.ts +27 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +84 -24
- package/apps/shared/search/localIndexBuilder.ts +57 -20
- package/apps/shared/search/localIndexRpcClient.ts +96 -12
- package/apps/shared/search/localIndexSchema.ts +20 -4
- package/apps/shared/search/localIndexTransport.ts +6 -2
- package/apps/shared/search/localIndexWorker.ts +37 -18
- package/apps/shared/search/searchTier2.ts +5 -0
- package/apps/shared/search/zeroAccessIndexSync.ts +679 -0
- package/apps/shared/search/zeroAccessSyncState.ts +70 -0
- package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
- package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
- package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
- package/apps/www/_shell.tsx +2 -0
- package/apps/www/calendar/index.tsx +225 -61
- package/apps/www/contacts/index.tsx +67 -11
- package/apps/www/index.tsx +19 -3
- package/apps/www/settings/encryption/index.tsx +21 -1
- package/apps/www/settings/privacy/index.tsx +89 -39
- package/apps/www/tasks/index.tsx +24 -6
- package/dist/apps/escrow/matters/[uid].js +2 -2
- package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
- package/dist/apps/shared/calendar/calendarReminders.js +26 -0
- package/dist/apps/shared/calendar/sealedEvents.d.ts +14 -0
- package/dist/apps/shared/calendar/sealedEvents.js +85 -0
- package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
- package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +25 -2
- package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
- package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
- package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
- package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
- package/dist/apps/shared/components/calendar/EventModal.js +15 -2
- package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
- package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
- package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
- package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
- package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
- package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
- package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
- package/dist/apps/shared/components/contacts/contactCardData.js +8 -4
- package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
- package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
- package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
- package/dist/apps/shared/components/layout/AppShell.js +17 -6
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +27 -2
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +17 -0
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +98 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
- package/dist/apps/shared/components/mail/ConversationList.js +1 -1
- package/dist/apps/shared/components/mail/InviteCard.js +11 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +46 -21
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
- package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
- package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
- package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +1 -1
- package/dist/apps/shared/components/mail/compose/quotedBody.js +4 -3
- package/dist/apps/shared/components/mail/invite/inviteStore.js +16 -1
- package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
- package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
- package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
- package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
- package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
- package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
- package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
- package/dist/apps/shared/components/settings/KeyExportSetting.js +90 -0
- package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
- package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
- package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
- package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +16 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.js +80 -0
- package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
- package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
- package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
- package/dist/apps/shared/mail/decryptedMessages.js +11 -3
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
- package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
- package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
- package/dist/apps/shared/mail/reportNotices.js +3 -1
- package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
- package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +74 -24
- package/dist/apps/shared/search/localIndexBuilder.d.ts +9 -1
- package/dist/apps/shared/search/localIndexBuilder.js +50 -17
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
- package/dist/apps/shared/search/localIndexRpcClient.js +129 -11
- package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
- package/dist/apps/shared/search/localIndexSchema.js +18 -4
- package/dist/apps/shared/search/localIndexTransport.d.ts +6 -2
- package/dist/apps/shared/search/localIndexWorker.d.ts +11 -0
- package/dist/apps/shared/search/localIndexWorker.js +22 -15
- package/dist/apps/shared/search/searchTier2.js +5 -0
- package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +102 -0
- package/dist/apps/shared/search/zeroAccessIndexSync.js +544 -0
- package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
- package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
- package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
- package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
- package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
- package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
- package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
- package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
- package/dist/apps/www/_shell.d.ts +1 -1
- package/dist/apps/www/_shell.js +2 -2
- package/dist/apps/www/calendar/index.js +88 -20
- package/dist/apps/www/contacts/index.js +64 -11
- package/dist/apps/www/index.js +19 -3
- package/dist/apps/www/settings/encryption/index.js +16 -4
- package/dist/apps/www/settings/privacy/index.js +47 -6
- package/dist/apps/www/tasks/index.js +21 -7
- package/dist/lib/admin/domainsApi.d.ts +1 -1
- package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
- package/dist/lib/calendar/calendarApi.d.ts +52 -6
- package/dist/lib/calendar/calendarApi.js +110 -17
- package/dist/lib/calendar/calendarMutations.d.ts +20 -7
- package/dist/lib/calendar/calendarMutations.js +73 -14
- package/dist/lib/calendar/inviteApi.d.ts +17 -3
- package/dist/lib/calendar/inviteApi.js +86 -7
- package/dist/lib/calendar/protectedEvents.d.ts +31 -0
- package/dist/lib/calendar/protectedEvents.js +113 -0
- package/dist/lib/calendar/recurrence.d.ts +2 -0
- package/dist/lib/calendar/recurrence.js +16 -0
- package/dist/lib/calendar/sealedInvites.d.ts +9 -0
- package/dist/lib/calendar/sealedInvites.js +65 -0
- package/dist/lib/contacts/contactsApi.d.ts +29 -3
- package/dist/lib/contacts/contactsApi.js +23 -7
- package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
- package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
- package/dist/lib/crypto/composeSecurity.d.ts +11 -3
- package/dist/lib/crypto/composeSecurity.js +13 -5
- package/dist/lib/crypto/keyExport.d.ts +54 -0
- package/dist/lib/crypto/keyExport.js +161 -0
- package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
- package/dist/lib/crypto/keyPairMatch.js +65 -0
- package/dist/lib/crypto/keySession.d.ts +64 -3
- package/dist/lib/crypto/keySession.js +179 -12
- package/dist/lib/crypto/keyvaultApi.d.ts +129 -8
- package/dist/lib/crypto/keyvaultApi.js +45 -3
- package/dist/lib/crypto/meetingKeys.d.ts +25 -0
- package/dist/lib/crypto/meetingKeys.js +87 -0
- package/dist/lib/crypto/messageSecurity.d.ts +56 -3
- package/dist/lib/crypto/messageSecurity.js +201 -13
- package/dist/lib/crypto/mime.d.ts +5 -0
- package/dist/lib/crypto/mime.js +25 -0
- package/dist/lib/crypto/openPgpKey.d.ts +39 -0
- package/dist/lib/crypto/openPgpKey.js +89 -0
- package/dist/lib/crypto/openpgp.d.ts +47 -0
- package/dist/lib/crypto/openpgp.js +88 -0
- package/dist/lib/crypto/pgpMessage.d.ts +51 -0
- package/dist/lib/crypto/pgpMessage.js +271 -0
- package/dist/lib/crypto/sharedMailboxKeys.d.ts +62 -0
- package/dist/lib/crypto/sharedMailboxKeys.js +337 -0
- package/dist/lib/crypto/smimeMessage.d.ts +21 -0
- package/dist/lib/crypto/smimeMessage.js +11 -7
- package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
- package/dist/lib/crypto/zeroAccessKey.js +71 -0
- package/dist/lib/mail/conversationsApi.d.ts +6 -0
- package/dist/lib/mail/mailApi.d.ts +63 -2
- package/dist/lib/mail/mailApi.js +5 -1
- package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
- package/dist/lib/mail/mailboxImportApi.js +15 -0
- package/dist/lib/search/zeroAccessIndexApi.d.ts +85 -0
- package/dist/lib/search/zeroAccessIndexApi.js +35 -0
- package/dist/lib/tasks/tasksApi.d.ts +11 -1
- package/dist/lib/tasks/tasksApi.js +10 -4
- package/dist/lib/util/clientPlatform.d.ts +14 -0
- package/dist/lib/util/clientPlatform.js +26 -0
- package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
- package/dist/lib/videoconf/protectedMeetings.js +181 -0
- package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
- package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
- package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
- package/dist/lib/zeroAccess/sealedMoves.js +63 -0
- package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
- package/dist/lib/zeroAccess/sealedRecords.js +136 -0
- package/lib/admin/domainsApi.ts +1 -1
- package/lib/admin/escrowAccessRequestsApi.ts +6 -0
- package/lib/calendar/calendarApi.ts +143 -17
- package/lib/calendar/calendarMutations.ts +79 -12
- package/lib/calendar/inviteApi.ts +101 -8
- package/lib/calendar/protectedEvents.ts +143 -0
- package/lib/calendar/recurrence.ts +17 -0
- package/lib/calendar/sealedInvites.ts +73 -0
- package/lib/contacts/contactsApi.ts +43 -17
- package/lib/contacts/useContactPhotoSrc.ts +103 -13
- package/lib/crypto/composeSecurity.ts +20 -5
- package/lib/crypto/keyExport.ts +229 -0
- package/lib/crypto/keyPairMatch.ts +69 -0
- package/lib/crypto/keySession.ts +216 -12
- package/lib/crypto/keyvaultApi.ts +174 -10
- package/lib/crypto/meetingKeys.ts +118 -0
- package/lib/crypto/messageSecurity.ts +284 -16
- package/lib/crypto/mime.ts +26 -0
- package/lib/crypto/openPgpKey.ts +118 -0
- package/lib/crypto/openpgp.ts +115 -0
- package/lib/crypto/pgpMessage.ts +336 -0
- package/lib/crypto/sharedMailboxKeys.ts +402 -0
- package/lib/crypto/smimeMessage.ts +11 -7
- package/lib/crypto/zeroAccessKey.ts +102 -0
- package/lib/mail/conversationsApi.ts +3 -0
- package/lib/mail/mailApi.ts +60 -3
- package/lib/mail/mailboxImportApi.ts +24 -0
- package/lib/search/zeroAccessIndexApi.ts +123 -0
- package/lib/tasks/tasksApi.ts +19 -5
- package/lib/util/clientPlatform.ts +50 -0
- package/lib/videoconf/protectedMeetings.ts +221 -0
- package/lib/videoconf/videoMeetingsApi.ts +15 -4
- package/lib/zeroAccess/sealedMoves.ts +79 -0
- package/lib/zeroAccess/sealedRecords.ts +153 -0
- package/package.json +14 -5
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Shared mailboxes' zero-access keys (restapi `specs/search.md` §3 *Shared Mailboxes*). A mailbox someone else owns, or that has no owner,
|
|
7
|
+
* has no master key its members know: its zero-access private key is instead wrapped to each member's own zero-access certificate
|
|
8
|
+
* (member key wraps), and a member opens it with the zero-access key of a mailbox they own - so a member unlocks it without a password,
|
|
9
|
+
* once their own mailbox is unlocked. Members get the ability to read, never the master key, signing key or encryption keys.
|
|
10
|
+
*
|
|
11
|
+
* `unlockSharedMailbox()` opens a shared mailbox for its member. `maintainSharedMailbox()` is what the client of the owner, or of a member
|
|
12
|
+
* with full access, does in the background: provisions a key for a mailbox without an owner, wraps the keys for members still waiting,
|
|
13
|
+
* and - once someone who held the key has left - rotates it, so new mail is encrypted to a key they never had. A mailbox without an owner
|
|
14
|
+
* has no master key for escrow to open, so when it is assigned to an escrow scope each of its keys is also wrapped to the scope's
|
|
15
|
+
* certificate: with a new key in the same write, and for keys the scope is still missing (`escrowPending`) in the background.
|
|
16
|
+
*/
|
|
17
|
+
import { type ApiClient, ApiRequestError } from "../util/api.js";
|
|
18
|
+
import { fromBase64, toBase64 } from "./encoding.js";
|
|
19
|
+
import {
|
|
20
|
+
enrollSharedZeroAccessKey,
|
|
21
|
+
type EscrowInfo,
|
|
22
|
+
getEncryptionPolicy,
|
|
23
|
+
getEscrowInfo,
|
|
24
|
+
getKeyVault,
|
|
25
|
+
getMyMemberKeyWraps,
|
|
26
|
+
getSharedKeyStatus,
|
|
27
|
+
type MemberKeyWrap,
|
|
28
|
+
putMemberKeyWraps,
|
|
29
|
+
putZeroAccessEscrowWraps,
|
|
30
|
+
removeMemberKeyWraps,
|
|
31
|
+
type SharedKeyStatus,
|
|
32
|
+
type ZeroAccessEscrowWrapInput,
|
|
33
|
+
type ZeroAccessKey,
|
|
34
|
+
} from "./keyvaultApi.js";
|
|
35
|
+
import { certificateHoldsKey } from "./keyPairMatch.js";
|
|
36
|
+
import { exportPrivateKeyPkcs8, generateKeyPairWithCsr, importPrivateKeyPkcs8 } from "./keys.js";
|
|
37
|
+
import {
|
|
38
|
+
addZeroAccessKey,
|
|
39
|
+
getUnlockedKeys,
|
|
40
|
+
installSharedSession,
|
|
41
|
+
personalSessions,
|
|
42
|
+
type RetainedEncryptionKey,
|
|
43
|
+
sharedUnlockGeneration,
|
|
44
|
+
type UnlockedKeys,
|
|
45
|
+
ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE,
|
|
46
|
+
} from "./keySession.js";
|
|
47
|
+
import { buildAad, openWithKey } from "./masterKey.js";
|
|
48
|
+
import { decryptEnvelopedDataWithKeys, encryptForRecipients } from "./smime.js";
|
|
49
|
+
import { provisionZeroAccessKey } from "./zeroAccessKey.js";
|
|
50
|
+
|
|
51
|
+
/** What `unlockSharedMailbox()` found. */
|
|
52
|
+
export type SharedUnlockOutcome =
|
|
53
|
+
/** Its keys are open in this session. */
|
|
54
|
+
| "unlocked"
|
|
55
|
+
/** The caller is a member but holds no wrap yet: another member's client shares the key with them shortly. */
|
|
56
|
+
| "pending"
|
|
57
|
+
/** The caller is a member, but none of their own mailboxes is unlocked to open their wraps with. */
|
|
58
|
+
| "own_keys_locked"
|
|
59
|
+
/** Not a shared mailbox of the caller's (they own it, or have no member access): its own unlock applies. */
|
|
60
|
+
| "not_shared";
|
|
61
|
+
|
|
62
|
+
/** Domain-separation prefix of the secret a shared session's `masterKey` stands for. */
|
|
63
|
+
const SESSION_SECRET_LABEL = new TextEncoder().encode("rapidmx-shared-mailbox-session-v1:");
|
|
64
|
+
|
|
65
|
+
async function sessionSecret(pkcs8: Uint8Array): Promise<Uint8Array> {
|
|
66
|
+
const input = new Uint8Array(SESSION_SECRET_LABEL.length + pkcs8.length);
|
|
67
|
+
input.set(SESSION_SECRET_LABEL);
|
|
68
|
+
input.set(pkcs8, SESSION_SECRET_LABEL.length);
|
|
69
|
+
try {
|
|
70
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", input));
|
|
71
|
+
} finally {
|
|
72
|
+
input.fill(0);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One of the user's own zero-access keys, with the mailbox it belongs to. */
|
|
77
|
+
type OwnKey = RetainedEncryptionKey & { mailboxUid: string };
|
|
78
|
+
|
|
79
|
+
/** The zero-access keys of the mailboxes this user has unlocked themselves - what their member wraps are addressed to. */
|
|
80
|
+
function ownZeroAccessKeys(): OwnKey[] {
|
|
81
|
+
return personalSessions().flatMap(({ mailboxUid, unlocked }) => (unlocked.zeroAccessKeys ?? []).map((key) => ({ ...key, mailboxUid })));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Opens `wrap` with the user's own keys: the wrapped PKCS#8 (the caller zeroes it) and the own mailbox whose key opened it, or `undefined`
|
|
85
|
+
* when no own key opens it. */
|
|
86
|
+
async function openWrap(wrap: MemberKeyWrap, own: OwnKey[]): Promise<{ pkcs8: Uint8Array; openedWith: string } | undefined> {
|
|
87
|
+
const recipient = own.find((key) => key.fingerprint === wrap.recipientFingerprint);
|
|
88
|
+
if (!recipient) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
return { pkcs8: await decryptEnvelopedDataWithKeys(fromBase64(wrap.cms), [recipient]), openedWith: recipient.mailboxUid };
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The active key first, then the others newest first - the order `UnlockedKeys.zeroAccessKeys` keeps. */
|
|
99
|
+
function keyOrder(status: Pick<SharedKeyStatus, "activeKeyFingerprint" | "zeroAccessKeys">): (fingerprint: string) => number {
|
|
100
|
+
const rank = new Map<string, number>(
|
|
101
|
+
[...status.zeroAccessKeys]
|
|
102
|
+
.sort((a, b) => Number(a.fingerprint !== status.activeKeyFingerprint) - Number(b.fingerprint !== status.activeKeyFingerprint) || b.notBefore - a.notBefore)
|
|
103
|
+
.map((key, index) => [key.fingerprint, index]),
|
|
104
|
+
);
|
|
105
|
+
return (fingerprint) => rank.get(fingerprint) ?? Number.MAX_SAFE_INTEGER;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Opens shared mailbox `mailboxUid` for its member, with no password: their member wraps, opened with the zero-access keys of a mailbox they
|
|
110
|
+
* own and have unlocked, become this session's keys for it (`installSharedSession()`). See `SharedUnlockOutcome` for when it can't.
|
|
111
|
+
*
|
|
112
|
+
* `refresh`: a shared session already open is opened again when it lacks the mailbox's active key and the member now holds a wrap of it -
|
|
113
|
+
* after a rotation, new mail is sealed to a key the session doesn't have. Its `masterKey` secret is kept, so the local index stays readable.
|
|
114
|
+
*/
|
|
115
|
+
export async function unlockSharedMailbox(mailboxUid: string, client?: ApiClient, refresh = false): Promise<SharedUnlockOutcome> {
|
|
116
|
+
const existing: UnlockedKeys | undefined = getUnlockedKeys(mailboxUid);
|
|
117
|
+
if (existing && !(refresh && existing.shared)) {
|
|
118
|
+
return "unlocked";
|
|
119
|
+
}
|
|
120
|
+
const generation: string = sharedUnlockGeneration(mailboxUid);
|
|
121
|
+
let status: SharedKeyStatus;
|
|
122
|
+
try {
|
|
123
|
+
status = await getSharedKeyStatus(mailboxUid, client);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (err instanceof ApiRequestError && (err.status === 403 || err.status === 404)) {
|
|
126
|
+
return existing ? "unlocked" : "not_shared";
|
|
127
|
+
}
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
const holdsActive = (keys: { fingerprint: string }[] | undefined): boolean => !!keys?.some((key) => key.fingerprint === status.activeKeyFingerprint);
|
|
131
|
+
if (existing && (!status.activeKeyFingerprint || holdsActive(existing.zeroAccessKeys))) {
|
|
132
|
+
return "unlocked";
|
|
133
|
+
}
|
|
134
|
+
if (status.role !== "member") {
|
|
135
|
+
return existing ? "unlocked" : "not_shared";
|
|
136
|
+
}
|
|
137
|
+
const wraps: MemberKeyWrap[] = await getMyMemberKeyWraps(mailboxUid, client);
|
|
138
|
+
// Nothing new to open yet: the active key's wrap is still on its way.
|
|
139
|
+
if (existing && !holdsActive(wraps.map((wrap) => ({ fingerprint: wrap.zeroAccessKeyFingerprint })))) {
|
|
140
|
+
return "unlocked";
|
|
141
|
+
}
|
|
142
|
+
if (wraps.length === 0) {
|
|
143
|
+
return "pending";
|
|
144
|
+
}
|
|
145
|
+
const own = ownZeroAccessKeys();
|
|
146
|
+
const rank = keyOrder(status);
|
|
147
|
+
const opened: RetainedEncryptionKey[] = [];
|
|
148
|
+
const openedWith: Set<string> = new Set();
|
|
149
|
+
let secret: Uint8Array | undefined;
|
|
150
|
+
for (const wrap of [...wraps].sort((a, b) => rank(a.zeroAccessKeyFingerprint) - rank(b.zeroAccessKeyFingerprint))) {
|
|
151
|
+
const certificate: ZeroAccessKey | undefined = status.zeroAccessKeys.find((key) => key.fingerprint === wrap.zeroAccessKeyFingerprint);
|
|
152
|
+
const unwrapped = certificate ? await openWrap(wrap, own) : undefined;
|
|
153
|
+
if (!certificate || !unwrapped) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const pkcs8 = unwrapped.pkcs8;
|
|
157
|
+
try {
|
|
158
|
+
// The certificate is the server's: one that isn't this key's must never be what this session seals to.
|
|
159
|
+
if (!(await certificateHoldsKey(fromBase64(certificate.publicKey), pkcs8))) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
secret ??= await sessionSecret(pkcs8);
|
|
163
|
+
const privateKey = await importPrivateKeyPkcs8(pkcs8, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"]);
|
|
164
|
+
opened.push({ fingerprint: wrap.zeroAccessKeyFingerprint, certDer: fromBase64(certificate.publicKey), privateKey });
|
|
165
|
+
openedWith.add(unwrapped.openedWith);
|
|
166
|
+
} catch {
|
|
167
|
+
// A wrap that doesn't hold a usable key (or one its certificate doesn't match) is skipped; the others still open the mailbox.
|
|
168
|
+
} finally {
|
|
169
|
+
pkcs8.fill(0);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (!secret || opened.length === 0) {
|
|
173
|
+
return existing ? "unlocked" : "own_keys_locked";
|
|
174
|
+
}
|
|
175
|
+
if (existing) {
|
|
176
|
+
// Opened again: the secret the local index key is derived from stays the one the session started with.
|
|
177
|
+
secret.fill(0);
|
|
178
|
+
secret = new Uint8Array(existing.masterKey);
|
|
179
|
+
}
|
|
180
|
+
const unlocked: UnlockedKeys = { masterKey: secret, zeroAccessKeys: opened, openedWith: [...openedWith] };
|
|
181
|
+
installSharedSession(mailboxUid, unlocked, generation);
|
|
182
|
+
return "unlocked";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The PKCS#8 of every zero-access key of `mailboxUid` the caller can open, by fingerprint - from the vault with the owner's unlocked master
|
|
186
|
+
* key, or from the caller's own member wraps. Only keys whose certificate in `status` (the server's) is really theirs: a key is wrapped
|
|
187
|
+
* for members and escrow under that certificate's fingerprint, so a mismatched one is left out. The caller zeroes them. */
|
|
188
|
+
async function openPrivateKeys(
|
|
189
|
+
mailboxUid: string,
|
|
190
|
+
status: SharedKeyStatus,
|
|
191
|
+
ownerUnlocked: UnlockedKeys | undefined,
|
|
192
|
+
client?: ApiClient,
|
|
193
|
+
): Promise<Map<string, Uint8Array>> {
|
|
194
|
+
const keys = new Map<string, Uint8Array>();
|
|
195
|
+
const keep = async (fingerprint: string, pkcs8: Uint8Array): Promise<void> => {
|
|
196
|
+
const certificate = status.zeroAccessKeys.find((key) => key.fingerprint === fingerprint);
|
|
197
|
+
if (certificate && (await certificateHoldsKey(fromBase64(certificate.publicKey), pkcs8))) {
|
|
198
|
+
keys.set(fingerprint, pkcs8);
|
|
199
|
+
} else {
|
|
200
|
+
pkcs8.fill(0);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
if (ownerUnlocked) {
|
|
204
|
+
const vault = await getKeyVault(mailboxUid, client);
|
|
205
|
+
const aad = buildAad(mailboxUid, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE);
|
|
206
|
+
for (const key of status.zeroAccessKeys) {
|
|
207
|
+
const wrapped = vault.wrappedKeys.find((w) => w.fingerprint === key.fingerprint && w.useType === "zero-access");
|
|
208
|
+
if (wrapped) {
|
|
209
|
+
try {
|
|
210
|
+
await keep(key.fingerprint, await openWithKey(ownerUnlocked.masterKey, wrapped, aad));
|
|
211
|
+
} catch {
|
|
212
|
+
// Sealed under a master key since replaced: not this device's to share.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return keys;
|
|
217
|
+
}
|
|
218
|
+
const own = ownZeroAccessKeys();
|
|
219
|
+
for (const wrap of await getMyMemberKeyWraps(mailboxUid, client)) {
|
|
220
|
+
const unwrapped = await openWrap(wrap, own);
|
|
221
|
+
if (unwrapped) {
|
|
222
|
+
await keep(wrap.zeroAccessKeyFingerprint, unwrapped.pkcs8);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return keys;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function wrapFor(member: { userUid: string; certificate: ZeroAccessKey }, pkcs8: Uint8Array): Promise<Omit<MemberKeyWrap, "zeroAccessKeyFingerprint">> {
|
|
229
|
+
return {
|
|
230
|
+
memberUserUid: member.userUid,
|
|
231
|
+
recipientFingerprint: member.certificate.fingerprint,
|
|
232
|
+
cms: toBase64(await encryptForRecipients(pkcs8, [fromBase64(member.certificate.publicKey)])),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The members with a certificate of their own - the ones a key can be wrapped for. A certificate listed under the fingerprint of one of
|
|
237
|
+
* this user's own opened zero-access keys must be that key's certificate (checked against its private key at unlock): one the server
|
|
238
|
+
* swapped is left out, so a wrap meant for this user never goes to a key that isn't theirs. */
|
|
239
|
+
function wrappable(status: SharedKeyStatus): { userUid: string; certificate: ZeroAccessKey; wrapped: string[] }[] {
|
|
240
|
+
const own = ownZeroAccessKeys();
|
|
241
|
+
return status.members.filter((member): member is typeof member & { certificate: ZeroAccessKey } => {
|
|
242
|
+
if (!member.certificate) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
const mine = own.find((key) => key.fingerprint === member.certificate!.fingerprint);
|
|
246
|
+
return !mine || sameBytes(mine.certDer, fromBase64(member.certificate.publicKey));
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
|
251
|
+
return a.length === b.length && a.every((byte, index) => byte === b[index]);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** `pkcs8` wrapped to the certificate of the escrow scope `escrow` describes. */
|
|
255
|
+
async function escrowWrapFor(escrow: EscrowInfo, pkcs8: Uint8Array): Promise<Omit<ZeroAccessEscrowWrapInput, "zeroAccessKeyFingerprint">> {
|
|
256
|
+
return { escrowScopeId: escrow.escrowScopeId, cms: toBase64(await encryptForRecipients(pkcs8, [fromBase64(escrow.publicKey.publicKey)])) };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A new keypair for a mailbox without an owner, enrolled together with a wrap of it for every member who can receive one, and for its
|
|
260
|
+
* escrow scope when it has one. */
|
|
261
|
+
async function enrollNewSharedKey(mailboxUid: string, mailboxAddress: string, status: SharedKeyStatus, client?: ApiClient): Promise<SharedKeyStatus> {
|
|
262
|
+
const escrow: EscrowInfo | undefined = status.escrowScopeId ? await getEscrowInfo(mailboxUid, client) : undefined;
|
|
263
|
+
const { keyPair, csrPem } = await generateKeyPairWithCsr(mailboxAddress, "encrypt");
|
|
264
|
+
const pkcs8 = await exportPrivateKeyPkcs8(keyPair.privateKey);
|
|
265
|
+
try {
|
|
266
|
+
const wraps = await Promise.all(wrappable(status).map((member) => wrapFor(member, pkcs8)));
|
|
267
|
+
const escrowWrap = escrow ? await escrowWrapFor(escrow, pkcs8) : undefined;
|
|
268
|
+
return await enrollSharedZeroAccessKey(mailboxUid, { csr: csrPem, wraps, ...(escrowWrap ? { escrowWrap } : {}) }, client);
|
|
269
|
+
} finally {
|
|
270
|
+
pkcs8.fill(0);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** What `maintainSharedMailbox()` did. */
|
|
275
|
+
export interface SharedMaintenance {
|
|
276
|
+
provisioned: boolean;
|
|
277
|
+
rotated: boolean;
|
|
278
|
+
/** Wraps added for waiting members. */
|
|
279
|
+
wrapped: number;
|
|
280
|
+
/** Wraps of older keys the server refused for waiting members (tried again on the next pass). */
|
|
281
|
+
refused: number;
|
|
282
|
+
/** Departed users whose wraps were removed. */
|
|
283
|
+
removed: number;
|
|
284
|
+
/** Keys wrapped for the mailbox's escrow scope. */
|
|
285
|
+
escrowed: number;
|
|
286
|
+
/**
|
|
287
|
+
* The server's reason when it refused (400) the new key of a pending rotation - e.g. the vault couldn't hold the new key's member wraps
|
|
288
|
+
* next to those it holds - so the current key stays active and members keep reading; the rotation is tried again on the next pass.
|
|
289
|
+
*/
|
|
290
|
+
rotationRefused?: string;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The background care of a shared mailbox, run by the client of its owner or of a member with full access (`status.manager`); nothing for
|
|
295
|
+
* anyone else, or while the keys it needs are locked:
|
|
296
|
+
*
|
|
297
|
+
* A mailbox without an owner and without a key gets one (unless the administrator disallows zero-access), wrapped for every member who has a
|
|
298
|
+
* certificate. Once a user who held the active key has left (`rotationPending`), a new key replaces it - enrolled by the owner with their
|
|
299
|
+
* master key (`provisionZeroAccessKey()`), or for a mailbox without an owner with the members' wraps - and then the departed users' wraps
|
|
300
|
+
* are removed. Members still waiting (`pending`) get wraps of every key this client can open, one write per key with the active key's
|
|
301
|
+
* first (a refused older key doesn't stop the rest), and the escrow scope of a mailbox without an owner gets copies of the keys it is
|
|
302
|
+
* missing (`escrowPending`). A rotation the server refuses (400) is reported in `rotationRefused`, not thrown: the current key stays active
|
|
303
|
+
* and the rest of the pass goes ahead.
|
|
304
|
+
*/
|
|
305
|
+
export async function maintainSharedMailbox(mailboxUid: string, mailboxAddress: string, client?: ApiClient): Promise<SharedMaintenance> {
|
|
306
|
+
const result: SharedMaintenance = { provisioned: false, rotated: false, wrapped: 0, refused: 0, removed: 0, escrowed: 0 };
|
|
307
|
+
let status: SharedKeyStatus = await getSharedKeyStatus(mailboxUid, client);
|
|
308
|
+
if (!status.manager || (status.members.length === 0 && status.departed.length === 0)) {
|
|
309
|
+
return result;
|
|
310
|
+
}
|
|
311
|
+
const ownerUnlocked = status.role === "owner" ? getUnlockedKeys(mailboxUid) : undefined;
|
|
312
|
+
if (status.role === "owner" && (!ownerUnlocked || ownerUnlocked.shared)) {
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (status.ownerless && !status.activeKeyFingerprint) {
|
|
317
|
+
const policy = await getEncryptionPolicy(client).catch(() => undefined);
|
|
318
|
+
if (policy?.allowZeroAccess === false || ownZeroAccessKeys().length === 0) {
|
|
319
|
+
return result;
|
|
320
|
+
}
|
|
321
|
+
status = await enrollNewSharedKey(mailboxUid, mailboxAddress, status, client);
|
|
322
|
+
result.provisioned = true;
|
|
323
|
+
} else if (status.rotationPending) {
|
|
324
|
+
try {
|
|
325
|
+
if (status.ownerless) {
|
|
326
|
+
status = await enrollNewSharedKey(mailboxUid, mailboxAddress, status, client);
|
|
327
|
+
} else {
|
|
328
|
+
const vault = await getKeyVault(mailboxUid, client);
|
|
329
|
+
const { key } = await provisionZeroAccessKey(mailboxUid, mailboxAddress, ownerUnlocked!.masterKey, vault.masterKeyGeneration, client);
|
|
330
|
+
// The owner's open session gets the new key as its active one, as `ensureZeroAccessKey()` does: new mail is sealed to it.
|
|
331
|
+
addZeroAccessKey(mailboxUid, ownerUnlocked!, key);
|
|
332
|
+
status = await getSharedKeyStatus(mailboxUid, client);
|
|
333
|
+
}
|
|
334
|
+
result.rotated = true;
|
|
335
|
+
} catch (err) {
|
|
336
|
+
// Refused before any key was issued (restapi: the vault can't hold the new key's member wraps next to those it holds): the
|
|
337
|
+
// current key stays active, so the rest of the pass - waiting members' wraps of it - still goes ahead. Anything else throws.
|
|
338
|
+
if (!(err instanceof ApiRequestError) || err.status !== 400) {
|
|
339
|
+
throw err;
|
|
340
|
+
}
|
|
341
|
+
result.rotationRefused = err.message;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// The departed hold no wrap of the active key any more (or never did): their old wraps go.
|
|
345
|
+
if (!status.rotationPending) {
|
|
346
|
+
for (const departed of status.departed) {
|
|
347
|
+
status = await removeMemberKeyWraps(mailboxUid, departed, client);
|
|
348
|
+
result.removed++;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const missing = wrappable(status).filter((member) => status.zeroAccessKeys.some((key) => !member.wrapped.includes(key.fingerprint)));
|
|
353
|
+
const escrowPending: string[] = status.ownerless && status.escrowScopeId ? (status.escrowPending ?? []) : [];
|
|
354
|
+
if (missing.length > 0 || escrowPending.length > 0) {
|
|
355
|
+
const keys = await openPrivateKeys(mailboxUid, status, ownerUnlocked, client);
|
|
356
|
+
try {
|
|
357
|
+
// One write per key, the active key's first: it alone opens new mail, so a refused backfill of an older key (the server bounds
|
|
358
|
+
// each key's wraps and the vault's total) never keeps a member from it. A failed older key is left for the next pass.
|
|
359
|
+
const order = keyOrder(status);
|
|
360
|
+
for (const [fingerprint, pkcs8] of [...keys].sort(([a], [b]) => order(a) - order(b))) {
|
|
361
|
+
const wraps: MemberKeyWrap[] = [];
|
|
362
|
+
for (const member of missing) {
|
|
363
|
+
if (!member.wrapped.includes(fingerprint)) {
|
|
364
|
+
wraps.push({ ...(await wrapFor(member, pkcs8)), zeroAccessKeyFingerprint: fingerprint });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (wraps.length === 0) {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (fingerprint === status.activeKeyFingerprint) {
|
|
371
|
+
await putMemberKeyWraps(mailboxUid, wraps, client);
|
|
372
|
+
result.wrapped += wraps.length;
|
|
373
|
+
} else {
|
|
374
|
+
try {
|
|
375
|
+
await putMemberKeyWraps(mailboxUid, wraps, client);
|
|
376
|
+
result.wrapped += wraps.length;
|
|
377
|
+
} catch {
|
|
378
|
+
result.refused += wraps.length;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const held: string[] = escrowPending.filter((fingerprint) => keys.has(fingerprint));
|
|
383
|
+
if (held.length > 0) {
|
|
384
|
+
const escrow: EscrowInfo = await getEscrowInfo(mailboxUid, client);
|
|
385
|
+
// Assigned to another scope since the status was read: the next pass sees it.
|
|
386
|
+
if (escrow.escrowScopeId === status.escrowScopeId) {
|
|
387
|
+
const escrowWraps: ZeroAccessEscrowWrapInput[] = [];
|
|
388
|
+
for (const fingerprint of held) {
|
|
389
|
+
escrowWraps.push({ ...(await escrowWrapFor(escrow, keys.get(fingerprint)!)), zeroAccessKeyFingerprint: fingerprint });
|
|
390
|
+
}
|
|
391
|
+
await putZeroAccessEscrowWraps(mailboxUid, escrowWraps, client);
|
|
392
|
+
result.escrowed = escrowWraps.length;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
} finally {
|
|
396
|
+
for (const pkcs8 of keys.values()) {
|
|
397
|
+
pkcs8.fill(0);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return result;
|
|
402
|
+
}
|
|
@@ -77,7 +77,8 @@ export function applyBaselineOuterHeaders(headers: ProtectedHeaders): ProtectedH
|
|
|
77
77
|
return { ...headers, subject: "[...]" };
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
/** @internal Shared with `pgpMessage.ts`. */
|
|
81
|
+
export function base64Wrapped(bytes: Uint8Array): string {
|
|
81
82
|
const encoded = toBase64(bytes);
|
|
82
83
|
const lines: string[] = [];
|
|
83
84
|
for (let i = 0; i < encoded.length; i += BASE64_LINE_WIDTH) {
|
|
@@ -86,7 +87,8 @@ function base64Wrapped(bytes: Uint8Array): string {
|
|
|
86
87
|
return lines.join(CRLF);
|
|
87
88
|
}
|
|
88
89
|
|
|
89
|
-
|
|
90
|
+
/** @internal Shared with `pgpMessage.ts`. */
|
|
91
|
+
export function generateBoundary(): string {
|
|
90
92
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
91
93
|
return `----=_RapidMX_${Array.from(bytes)
|
|
92
94
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
@@ -94,7 +96,7 @@ function generateBoundary(): string {
|
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
/** Strips CR/LF/NUL from a header value that has no further encoding (Date, Message-ID, Content-Type). */
|
|
97
|
-
function flattenHeaderValue(value: string): string {
|
|
99
|
+
export function flattenHeaderValue(value: string): string {
|
|
98
100
|
return value.replace(/[\r\n\0]+/g, " ");
|
|
99
101
|
}
|
|
100
102
|
|
|
@@ -116,7 +118,8 @@ function headerLines(headers: ProtectedHeaders, prefix = ""): string[] {
|
|
|
116
118
|
];
|
|
117
119
|
}
|
|
118
120
|
|
|
119
|
-
|
|
121
|
+
/** @internal Shared with `pgpMessage.ts`: the protected header block of an inner entity (with `HP-Outer:` copies of `hpOuter`). */
|
|
122
|
+
export function protectedHeaderLines(headers: ProtectedHeaders, hpOuter?: ProtectedHeaders): string {
|
|
120
123
|
const lines = headerLines(headers);
|
|
121
124
|
if (hpOuter) {
|
|
122
125
|
// RFC 9788 Section 2.2.1's `hp-outer` field: the literal string "HP-Outer:" followed by the
|
|
@@ -369,7 +372,7 @@ export type ComparableOuterHeaders = Partial<Pick<ProtectedHeaders, "from" | "to
|
|
|
369
372
|
* (a real bug if reused for this - JSON/`Record` keys aren't multi-valued). Scans the raw header block
|
|
370
373
|
* text directly instead. Returns `undefined` when no `HP-Outer:` lines are present at all, so a caller
|
|
371
374
|
* can distinguish "nothing to compare" from "compared and everything matched". */
|
|
372
|
-
function extractHpOuterHeaders(fields: MimeHeaderField[]): ComparableOuterHeaders | undefined {
|
|
375
|
+
export function extractHpOuterHeaders(fields: MimeHeaderField[]): ComparableOuterHeaders | undefined {
|
|
373
376
|
const result: ComparableOuterHeaders = {};
|
|
374
377
|
let found = false;
|
|
375
378
|
for (const { name, value } of fields) {
|
|
@@ -386,7 +389,7 @@ function extractHpOuterHeaders(fields: MimeHeaderField[]): ComparableOuterHeader
|
|
|
386
389
|
/** `true` when every field `hpOuter` actually carries matches `actualOuterHeaders`' value for that same
|
|
387
390
|
* field - a field `hpOuter` doesn't carry (e.g. no `Cc` on this message) is not compared, since RFC
|
|
388
391
|
* 9788 never claims coverage for it either way. */
|
|
389
|
-
function outerHeadersMatch(hpOuter: ComparableOuterHeaders, actualOuterHeaders: ComparableOuterHeaders): boolean {
|
|
392
|
+
export function outerHeadersMatch(hpOuter: ComparableOuterHeaders, actualOuterHeaders: ComparableOuterHeaders): boolean {
|
|
390
393
|
return (Object.keys(hpOuter) as (keyof ComparableOuterHeaders)[]).every((field) => hpOuter[field] === actualOuterHeaders[field]);
|
|
391
394
|
}
|
|
392
395
|
|
|
@@ -477,7 +480,8 @@ export function splitHeadersAndBody(entity: string): { headers: Record<string, s
|
|
|
477
480
|
return { headers, contentType: headers["content-type"], body, rawHeaderBlock };
|
|
478
481
|
}
|
|
479
482
|
|
|
480
|
-
|
|
483
|
+
/** @internal Shared with `pgpMessage.ts`. */
|
|
484
|
+
export function headersToProtectedHeaders(headers: Record<string, string>): ProtectedHeaders {
|
|
481
485
|
return {
|
|
482
486
|
from: headers["from"] ?? "",
|
|
483
487
|
to: headers["to"] ?? "",
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Zero-access keys (restapi `specs/search.md` §3): the per-mailbox keypair the server encrypts mail at rest to on arrival.
|
|
7
|
+
* Generated here like the encryption key - P-256, its certificate issued by restapi's internal CA from a CSR, its PKCS#8
|
|
8
|
+
* private key sealed under the mailbox master key - but never published: restapi keeps the certificate on
|
|
9
|
+
* `Mailbox.zeroAccessKeys` and returns it only with the vault.
|
|
10
|
+
*/
|
|
11
|
+
import type { ApiClient } from "../util/api.js";
|
|
12
|
+
import { fromBase64 } from "./encoding.js";
|
|
13
|
+
import { type KeyVault, enrollKey, getKeyVault } from "./keyvaultApi.js";
|
|
14
|
+
import { certificateHoldsKey } from "./keyPairMatch.js";
|
|
15
|
+
import { exportPrivateKeyPkcs8, generateKeyPairWithCsr, importPrivateKeyPkcs8 } from "./keys.js";
|
|
16
|
+
import { addZeroAccessKey, getUnlockedKeys, type RetainedEncryptionKey, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE } from "./keySession.js";
|
|
17
|
+
import { buildAad, sealWithKey } from "./masterKey.js";
|
|
18
|
+
|
|
19
|
+
/** What `provisionZeroAccessKey()` resolves with. */
|
|
20
|
+
export interface ProvisionedZeroAccessKey {
|
|
21
|
+
/** The vault as restapi returned it after the enrollment. */
|
|
22
|
+
vault: KeyVault;
|
|
23
|
+
/** The new key, ready to decrypt with (its private key imported non-extractable for ECDH). */
|
|
24
|
+
key: RetainedEncryptionKey;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Generates a zero-access keypair for `mailboxUid`, seals its private key under `masterKey` and enrolls it. The key becomes
|
|
29
|
+
* the mailbox's active zero-access key (restapi supersedes any earlier one, whose private key stays in the vault). Pass the
|
|
30
|
+
* vault's `masterKeyGeneration` read alongside `masterKey` as `expectedMasterKeyGeneration`, so restapi refuses (`409`)
|
|
31
|
+
* the key if another device rotated the master key meanwhile.
|
|
32
|
+
*/
|
|
33
|
+
export async function provisionZeroAccessKey(
|
|
34
|
+
mailboxUid: string,
|
|
35
|
+
mailboxAddress: string,
|
|
36
|
+
masterKey: Uint8Array,
|
|
37
|
+
expectedMasterKeyGeneration?: number,
|
|
38
|
+
client?: ApiClient,
|
|
39
|
+
): Promise<ProvisionedZeroAccessKey> {
|
|
40
|
+
const { keyPair, csrPem } = await generateKeyPairWithCsr(mailboxAddress, "encrypt");
|
|
41
|
+
const raw = await exportPrivateKeyPkcs8(keyPair.privateKey);
|
|
42
|
+
try {
|
|
43
|
+
const sealed = await sealWithKey(masterKey, raw, buildAad(mailboxUid, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE));
|
|
44
|
+
const vault = await enrollKey(
|
|
45
|
+
mailboxUid,
|
|
46
|
+
{
|
|
47
|
+
useType: "zero-access",
|
|
48
|
+
csr: csrPem,
|
|
49
|
+
wrappedKey: { ciphertext: sealed.ciphertext, nonce: sealed.nonce, algorithm: "AES-256-GCM" },
|
|
50
|
+
...(expectedMasterKeyGeneration !== undefined ? { expectedMasterKeyGeneration } : {}),
|
|
51
|
+
},
|
|
52
|
+
client,
|
|
53
|
+
);
|
|
54
|
+
// restapi appends the new wrapped key last.
|
|
55
|
+
const enrolled = [...vault.wrappedKeys].reverse().find((k) => k.useType === "zero-access");
|
|
56
|
+
const certificate = enrolled && vault.zeroAccessKeys?.find((k) => k.fingerprint === enrolled.fingerprint);
|
|
57
|
+
if (!enrolled || !certificate) {
|
|
58
|
+
throw new Error("The server did not return the enrolled zero-access key.");
|
|
59
|
+
}
|
|
60
|
+
// The certificate is the server's: this device seals to it (index segments, protected meetings' keys), so it must be the new key's.
|
|
61
|
+
if (!(await certificateHoldsKey(fromBase64(certificate.publicKey), raw))) {
|
|
62
|
+
throw new Error("The server returned a zero-access certificate that isn't the enrolled key's.");
|
|
63
|
+
}
|
|
64
|
+
const privateKey = await importPrivateKeyPkcs8(raw, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
|
|
65
|
+
return { vault, key: { fingerprint: enrolled.fingerprint, certDer: fromBase64(certificate.publicKey), privateKey } };
|
|
66
|
+
} finally {
|
|
67
|
+
raw.fill(0);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Provisions already in flight, per mailbox - a second caller (another effect, a re-render) joins the first. */
|
|
72
|
+
const inFlight = new Map<string, Promise<boolean>>();
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Gives an unlocked mailbox a zero-access key when it has none yet - how a mailbox set up before zero-access encryption
|
|
76
|
+
* existed gets one, on its owner's next unlock. Resolves `true` when it enrolled a key (and added it to the session), `false`
|
|
77
|
+
* when there was nothing to do: the mailbox isn't unlocked, or its vault already holds an unrevoked zero-access key.
|
|
78
|
+
*
|
|
79
|
+
* Only the mailbox's owner may call this (restapi refuses anybody else's enrollment); a caller decides that, e.g. from
|
|
80
|
+
* `KeyEnrollmentGate`'s `canProvision`.
|
|
81
|
+
*/
|
|
82
|
+
export function ensureZeroAccessKey(mailboxUid: string, mailboxAddress: string, client?: ApiClient): Promise<boolean> {
|
|
83
|
+
const pending = inFlight.get(mailboxUid);
|
|
84
|
+
if (pending) {
|
|
85
|
+
return pending;
|
|
86
|
+
}
|
|
87
|
+
const run = (async (): Promise<boolean> => {
|
|
88
|
+
const unlocked = getUnlockedKeys(mailboxUid);
|
|
89
|
+
if (!unlocked || unlocked.zeroAccessKeys?.length) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
const vault = await getKeyVault(mailboxUid, client);
|
|
93
|
+
if ((vault.zeroAccessKeys ?? []).some((k) => !k.revokedAt) || vault.wrappedKeys.length === 0) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
const { key } = await provisionZeroAccessKey(mailboxUid, mailboxAddress, unlocked.masterKey, vault.masterKeyGeneration, client);
|
|
97
|
+
addZeroAccessKey(mailboxUid, unlocked, key);
|
|
98
|
+
return true;
|
|
99
|
+
})().finally(() => inFlight.delete(mailboxUid));
|
|
100
|
+
inFlight.set(mailboxUid, run);
|
|
101
|
+
return run;
|
|
102
|
+
}
|
|
@@ -37,6 +37,9 @@ export interface ConversationSummary {
|
|
|
37
37
|
latestFrom: Recipient;
|
|
38
38
|
/** The most recent message's `bodyPreview` — the snippet a collapsed row shows. */
|
|
39
39
|
latestPreview: string;
|
|
40
|
+
/** Set when the latest message is sealed at rest (zero-access): its subject and preview are only in `sealedSummary`, which
|
|
41
|
+
* `messageSecurity.ts`'s `openZeroAccessSummary()` opens with the mailbox's zero-access keys. */
|
|
42
|
+
latestZeroAccess?: { keyFingerprint: string; sealedSummary: string };
|
|
40
43
|
/** The most recent message's folder. */
|
|
41
44
|
latestFolderUid: string;
|
|
42
45
|
/** The iTIP `METHOD` of the calendar file the most recent message carries (`Message.meetingMethod`) - `"REQUEST"`, `"REPLY"`,
|