@rapidmx/web-client 0.36.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/shared/calendar/calendarReminders.ts +35 -0
- package/apps/shared/calendar/sealedEvents.ts +42 -9
- 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 +29 -1
- 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 +6 -3
- 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 +11 -4
- package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +37 -6
- package/apps/shared/components/mail/InviteCard.tsx +11 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +25 -14
- 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/protectedContactSuggestions.ts +121 -0
- package/apps/shared/components/mail/invite/inviteStore.ts +11 -1
- package/apps/shared/components/mail/pinnedSigners.ts +14 -0
- package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
- package/apps/shared/components/settings/KeyExportSetting.tsx +4 -1
- package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
- package/apps/shared/components/settings/ZeroAccessSetting.tsx +13 -2
- package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
- package/apps/shared/mail/outbox/sendJob.ts +349 -50
- package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
- package/apps/shared/search/LocalIndexLifecycle.tsx +62 -33
- package/apps/shared/search/localIndexBuilder.ts +35 -24
- package/apps/shared/search/localIndexRpcClient.ts +91 -11
- package/apps/shared/search/localIndexSchema.ts +18 -2
- package/apps/shared/search/localIndexTransport.ts +6 -3
- package/apps/shared/search/localIndexWorker.ts +35 -18
- package/apps/shared/search/searchTier2.ts +5 -0
- package/apps/shared/search/zeroAccessIndexSync.ts +232 -40
- 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 +49 -5
- package/apps/www/contacts/index.tsx +67 -11
- package/apps/www/index.tsx +10 -1
- package/apps/www/settings/encryption/index.tsx +16 -1
- package/apps/www/settings/privacy/index.tsx +87 -38
- package/apps/www/tasks/index.tsx +24 -6
- 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 +3 -2
- package/dist/apps/shared/calendar/sealedEvents.js +40 -9
- 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 +9 -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 +6 -3
- 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 +10 -4
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +3 -1
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +30 -4
- package/dist/apps/shared/components/mail/InviteCard.js +11 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +15 -11
- 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/protectedContactSuggestions.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.js +11 -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/settings/KeyExportSetting.js +3 -1
- 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/ZeroAccessSetting.d.ts +2 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.js +12 -3
- 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/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/search/LocalIndexLifecycle.js +55 -33
- package/dist/apps/shared/search/localIndexBuilder.d.ts +4 -1
- package/dist/apps/shared/search/localIndexBuilder.js +31 -20
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
- package/dist/apps/shared/search/localIndexRpcClient.js +126 -10
- package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
- package/dist/apps/shared/search/localIndexSchema.js +16 -2
- package/dist/apps/shared/search/localIndexTransport.d.ts +6 -3
- package/dist/apps/shared/search/localIndexWorker.d.ts +9 -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 +37 -6
- package/dist/apps/shared/search/zeroAccessIndexSync.js +199 -37
- 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 +47 -5
- package/dist/apps/www/contacts/index.js +64 -11
- package/dist/apps/www/index.js +10 -1
- package/dist/apps/www/settings/encryption/index.js +14 -4
- package/dist/apps/www/settings/privacy/index.js +46 -5
- package/dist/apps/www/tasks/index.js +21 -7
- package/dist/lib/admin/domainsApi.d.ts +1 -1
- package/dist/lib/calendar/calendarApi.d.ts +46 -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 +61 -4
- 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 +6 -8
- package/dist/lib/calendar/sealedInvites.js +43 -6
- 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.js +2 -2
- package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
- package/dist/lib/crypto/keyPairMatch.js +65 -0
- package/dist/lib/crypto/keySession.d.ts +19 -2
- package/dist/lib/crypto/keySession.js +89 -13
- package/dist/lib/crypto/keyvaultApi.d.ts +41 -5
- package/dist/lib/crypto/keyvaultApi.js +13 -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 +26 -2
- package/dist/lib/crypto/messageSecurity.js +122 -10
- 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 +15 -3
- package/dist/lib/crypto/sharedMailboxKeys.js +102 -29
- package/dist/lib/crypto/smimeMessage.d.ts +21 -0
- package/dist/lib/crypto/smimeMessage.js +11 -7
- package/dist/lib/crypto/zeroAccessKey.js +5 -0
- package/dist/lib/mail/mailApi.d.ts +33 -0
- package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
- package/dist/lib/mail/mailboxImportApi.js +15 -0
- package/dist/lib/search/zeroAccessIndexApi.d.ts +4 -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/calendar/calendarApi.ts +140 -17
- package/lib/calendar/calendarMutations.ts +79 -12
- package/lib/calendar/inviteApi.ts +75 -5
- package/lib/calendar/protectedEvents.ts +143 -0
- package/lib/calendar/recurrence.ts +17 -0
- package/lib/calendar/sealedInvites.ts +47 -7
- 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 +2 -2
- package/lib/crypto/keyPairMatch.ts +69 -0
- package/lib/crypto/keySession.ts +102 -12
- package/lib/crypto/keyvaultApi.ts +49 -7
- package/lib/crypto/meetingKeys.ts +118 -0
- package/lib/crypto/messageSecurity.ts +174 -13
- 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 +106 -28
- package/lib/crypto/smimeMessage.ts +11 -7
- package/lib/crypto/zeroAccessKey.ts +5 -0
- package/lib/mail/mailApi.ts +27 -0
- package/lib/mail/mailboxImportApi.ts +24 -0
- package/lib/search/zeroAccessIndexApi.ts +4 -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 +12 -3
|
@@ -2,10 +2,31 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import { useEffect, useState } from "react";
|
|
5
|
+
import { useEffect, useRef, useState } from "react";
|
|
6
6
|
import type { ApiClient } from "../util/api.js";
|
|
7
|
+
import { getUnlockedKeys, subscribeKeySession } from "../crypto/keySession.js";
|
|
8
|
+
import { openSealedBytes } from "../zeroAccess/sealedRecords.js";
|
|
7
9
|
import { type Contact, contactPhotoUrl, fetchContactPhoto } from "./contactsApi.js";
|
|
8
10
|
|
|
11
|
+
/** What a photo is fetched by: the contact's identity, and whether (and for which mailbox) it is sealed. */
|
|
12
|
+
type PhotoContact = Pick<Contact, "uid" | "version" | "photoBlobKey"> & Partial<Pick<Contact, "mailboxUid" | "photoSealed">>;
|
|
13
|
+
|
|
14
|
+
/** The cache identity of the global (cookie) client, for a sealed photo fetched without an explicit client. */
|
|
15
|
+
const GLOBAL_CLIENT = {} as ApiClient;
|
|
16
|
+
|
|
17
|
+
/** A contact's photo: the bytes as stored, or - sealed to the mailbox's zero-access key - opened (rejects when it can't be: initials show). */
|
|
18
|
+
async function loadPhoto(contact: PhotoContact, client: ApiClient | undefined): Promise<Blob> {
|
|
19
|
+
const blob = await fetchContactPhoto(contact.uid, contact.version, client);
|
|
20
|
+
if (!contact.photoSealed) {
|
|
21
|
+
return blob;
|
|
22
|
+
}
|
|
23
|
+
const opened = await openSealedBytes(contact.mailboxUid ?? "", new Uint8Array(await blob.arrayBuffer()));
|
|
24
|
+
if (!opened) {
|
|
25
|
+
throw new Error("The contact's picture is protected and can't be opened here.");
|
|
26
|
+
}
|
|
27
|
+
return new Blob([opened as BlobPart]);
|
|
28
|
+
}
|
|
29
|
+
|
|
9
30
|
/** How many pictures stay loaded (as `blob:` URLs) after nothing shows them any more, so a list scrolled back over does not fetch them all again. */
|
|
10
31
|
const CACHE_LIMIT = 50;
|
|
11
32
|
|
|
@@ -13,6 +34,8 @@ interface Entry {
|
|
|
13
34
|
url: string;
|
|
14
35
|
/** How many mounted avatars show this URL; it is never revoked while that is more than none. */
|
|
15
36
|
refs: number;
|
|
37
|
+
/** A sealed picture's mailbox, whose keys opened it: dropped when that mailbox locks, shown or not, and never handed out while it is locked. */
|
|
38
|
+
mailboxUid?: string;
|
|
16
39
|
}
|
|
17
40
|
|
|
18
41
|
/** Least recently used first (a `Map` iterates in insertion order, and a use moves the entry to the end). */
|
|
@@ -22,6 +45,32 @@ const pending = new Map<string, { request: Promise<string>; waiters: number }>()
|
|
|
22
45
|
const clientIds = new WeakMap<ApiClient, number>();
|
|
23
46
|
let nextClientId = 0;
|
|
24
47
|
|
|
48
|
+
let watchingKeys = false;
|
|
49
|
+
|
|
50
|
+
/** A sealed picture opened with a mailbox's keys goes when that mailbox locks, like every other opened value (`sealedRecords.ts`): its URL
|
|
51
|
+
* is revoked even while an avatar shows it - each one drops it itself (`useContactPhoto()`). Subscribed with the first sealed picture. */
|
|
52
|
+
function forgetOnLock(): void {
|
|
53
|
+
if (watchingKeys) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
watchingKeys = true;
|
|
57
|
+
subscribeKeySession(({ mailboxUid, state }) => {
|
|
58
|
+
if (state === "locked") {
|
|
59
|
+
forgetSealed(mailboxUid);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Revokes and forgets `mailboxUid`'s sealed pictures. */
|
|
65
|
+
function forgetSealed(mailboxUid: string): void {
|
|
66
|
+
for (const [key, entry] of entries) {
|
|
67
|
+
if (entry.mailboxUid === mailboxUid) {
|
|
68
|
+
URL.revokeObjectURL(entry.url);
|
|
69
|
+
entries.delete(key);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
25
74
|
/** One key per picture of one account: the same uid never collides across the clients of a multi-account app, and a replaced picture (new version) is a new key. */
|
|
26
75
|
function keyOf(client: ApiClient, uid: string, version: number): string {
|
|
27
76
|
let id = clientIds.get(client);
|
|
@@ -75,8 +124,20 @@ function limited<T>(task: () => Promise<T>): Promise<T> {
|
|
|
75
124
|
});
|
|
76
125
|
}
|
|
77
126
|
|
|
78
|
-
/**
|
|
79
|
-
|
|
127
|
+
/**
|
|
128
|
+
* The `blob:` URL for `key`, fetched with `load` unless it is cached, and counted as shown until `release()`. Concurrent callers share one fetch.
|
|
129
|
+
* `sealedMailboxUid` is a sealed picture's mailbox: opened only while it is unlocked, it is neither fetched, handed out from the cache nor
|
|
130
|
+
* stored once that mailbox is locked (rejects).
|
|
131
|
+
*/
|
|
132
|
+
async function acquire(key: string, load: () => Promise<Blob>, sealedMailboxUid?: string): Promise<string> {
|
|
133
|
+
if (sealedMailboxUid !== undefined) {
|
|
134
|
+
forgetOnLock();
|
|
135
|
+
if (!getUnlockedKeys(sealedMailboxUid)) {
|
|
136
|
+
// Not fetched either: it couldn't be opened, and the avatar retries it once the mailbox unlocks (`useContactPhoto()`).
|
|
137
|
+
forgetSealed(sealedMailboxUid);
|
|
138
|
+
throw new Error("The contact's mailbox is locked.");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
80
141
|
const entry = entries.get(key);
|
|
81
142
|
if (entry) {
|
|
82
143
|
entry.refs++;
|
|
@@ -90,9 +151,13 @@ async function acquire(key: string, load: () => Promise<Blob>): Promise<string>
|
|
|
90
151
|
waiters: 0,
|
|
91
152
|
request: limited(load)
|
|
92
153
|
.then((blob) => {
|
|
154
|
+
// Opened just before a lock came: nothing opened is kept past it.
|
|
155
|
+
if (sealedMailboxUid !== undefined && !getUnlockedKeys(sealedMailboxUid)) {
|
|
156
|
+
throw new Error("The contact's mailbox locked while its picture was opened.");
|
|
157
|
+
}
|
|
93
158
|
const url = URL.createObjectURL(blob);
|
|
94
159
|
// Claimed by everyone waiting from the start: an entry nobody holds yet could be trimmed before its avatar got to it.
|
|
95
|
-
entries.set(key, { url, refs: created.waiters });
|
|
160
|
+
entries.set(key, { url, refs: created.waiters, mailboxUid: sealedMailboxUid });
|
|
96
161
|
return url;
|
|
97
162
|
})
|
|
98
163
|
.finally(() => pending.delete(key)),
|
|
@@ -126,7 +191,7 @@ export function clearContactPhotoCache(): void {
|
|
|
126
191
|
* token-authenticated client (the desktop and mobile apps) cannot do that - an `<img>` sends no `Authorization` header - so the picture is fetched through the client
|
|
127
192
|
* and shown as a `blob:` URL, cached per client, contact and version with a small LRU and revoked once it falls out of it and nothing shows it.
|
|
128
193
|
*/
|
|
129
|
-
export function useContactPhotoSrc(contact:
|
|
194
|
+
export function useContactPhotoSrc(contact: PhotoContact | undefined, client?: ApiClient): string | undefined {
|
|
130
195
|
return useContactPhoto(contact, client).src;
|
|
131
196
|
}
|
|
132
197
|
|
|
@@ -135,23 +200,48 @@ export function useContactPhotoSrc(contact: Pick<Contact, "uid" | "version" | "p
|
|
|
135
200
|
* that is not on screen yet has no reason to fetch its picture.
|
|
136
201
|
*/
|
|
137
202
|
export function useContactPhoto(
|
|
138
|
-
contact:
|
|
203
|
+
contact: PhotoContact | undefined,
|
|
139
204
|
client?: ApiClient,
|
|
140
205
|
active = true,
|
|
141
206
|
): { src: string | undefined; failed: boolean } {
|
|
142
207
|
const [loaded, setLoaded] = useState<{ key: string; url: string } | undefined>();
|
|
143
208
|
const [failedKey, setFailedKey] = useState<string | undefined>();
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
209
|
+
// Bumped to load a sealed picture again: its mailbox locked (it can't be shown any more) or unlocked (one that failed can be now).
|
|
210
|
+
const [attempt, setAttempt] = useState(0);
|
|
211
|
+
// A sealed photo is always fetched (and opened) here: a URL would show the browser ciphertext.
|
|
212
|
+
const fetched = !!client || !!contact?.photoSealed;
|
|
213
|
+
const key = active && fetched && contact?.photoBlobKey ? keyOf(client ?? GLOBAL_CLIENT, contact.uid, contact.version) : undefined;
|
|
214
|
+
const sealedMailboxUid = contact?.photoSealed ? (contact.mailboxUid ?? "") : undefined;
|
|
215
|
+
const failedRef = useRef(failedKey);
|
|
216
|
+
failedRef.current = failedKey;
|
|
217
|
+
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
if (!key || sealedMailboxUid === undefined) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
return subscribeKeySession(({ mailboxUid, state }) => {
|
|
223
|
+
if (mailboxUid !== sealedMailboxUid) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (state === "locked") {
|
|
227
|
+
// What the <img> decoded stays on screen until its src goes: drop it, then try again (it fails while locked: initials).
|
|
228
|
+
setLoaded(undefined);
|
|
229
|
+
setFailedKey(undefined);
|
|
230
|
+
setAttempt((n) => n + 1);
|
|
231
|
+
} else if (failedRef.current === key) {
|
|
232
|
+
setFailedKey(undefined);
|
|
233
|
+
setAttempt((n) => n + 1);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}, [key, sealedMailboxUid]);
|
|
147
237
|
|
|
148
238
|
useEffect(() => {
|
|
149
|
-
if (!key
|
|
239
|
+
if (!key) {
|
|
150
240
|
return;
|
|
151
241
|
}
|
|
152
242
|
let cancelled = false;
|
|
153
243
|
let held = false;
|
|
154
|
-
acquire(key, () =>
|
|
244
|
+
acquire(key, () => loadPhoto(contact!, client), sealedMailboxUid).then(
|
|
155
245
|
(url) => {
|
|
156
246
|
if (cancelled) {
|
|
157
247
|
release(key);
|
|
@@ -168,9 +258,9 @@ export function useContactPhoto(
|
|
|
168
258
|
release(key);
|
|
169
259
|
}
|
|
170
260
|
};
|
|
171
|
-
}, [key]);
|
|
261
|
+
}, [key, attempt]);
|
|
172
262
|
|
|
173
|
-
if (
|
|
263
|
+
if (fetched) {
|
|
174
264
|
return { src: key !== undefined && loaded?.key === key ? loaded.url : undefined, failed: key !== undefined && failedKey === key };
|
|
175
265
|
}
|
|
176
266
|
return { src: contact && contactPhotoUrl(contact), failed: false };
|
|
@@ -47,9 +47,15 @@ function policyStateForTier(policy: EncryptionPolicy, tier: RecipientTier): Poli
|
|
|
47
47
|
export interface RecipientEncryptionStatus {
|
|
48
48
|
address: string;
|
|
49
49
|
tier: RecipientTier;
|
|
50
|
-
/** The recipient's currently-active encryption certificate, if they have published (and this device
|
|
50
|
+
/** The recipient's currently-active S/MIME encryption certificate, if they have published (and this device
|
|
51
51
|
* has discovered) one. */
|
|
52
52
|
encryptCert?: PublicKey;
|
|
53
|
+
/** The recipient's active OpenPGP certificate - only when they have no S/MIME certificate and the server allows OpenPGP
|
|
54
|
+
* (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*: S/MIME is preferred whenever it can be used). */
|
|
55
|
+
openPgpKey?: PublicKey;
|
|
56
|
+
/** Which format this recipient is encrypted in: `"smime"` whenever they have an S/MIME certificate, `"openpgp"` only for
|
|
57
|
+
* a recipient whose only key is an OpenPGP one. Absent when they can't be encrypted to. */
|
|
58
|
+
format?: "smime" | "openpgp";
|
|
53
59
|
/** Whether this recipient *can* be encrypted to at all right now - false when there's no usable key,
|
|
54
60
|
* or the tier's policy is `prohibited`. */
|
|
55
61
|
canEncrypt: boolean;
|
|
@@ -64,15 +70,24 @@ export interface RecipientEncryptionStatus {
|
|
|
64
70
|
|
|
65
71
|
/** Per-recipient encryption eligibility, given this device's own encryption preference and the
|
|
66
72
|
* system-wide policy. One entry per (address, lookup result) pair the caller already resolved via
|
|
67
|
-
* `lookupKeys()` - this function does no discovery itself.
|
|
73
|
+
* `lookupKeys()` - this function does no discovery itself. `ownCanUseOpenPgp` false: the sender has no OpenPGP key to use here
|
|
74
|
+
* (an OpenPGP copy is encrypted to the sender too), so a recipient who prefers OpenPGP but has an S/MIME certificate gets S/MIME - a
|
|
75
|
+
* preference never leaves them unencrypted (restapi `specs/end-to-end_encryption.md` *Choosing the format*). */
|
|
68
76
|
export function resolveRecipientEncryption(
|
|
69
77
|
ownAddress: string,
|
|
70
78
|
ownPrefersMutual: boolean,
|
|
71
79
|
policy: EncryptionPolicy,
|
|
72
80
|
recipientAddress: string,
|
|
73
81
|
lookup: KeyLookupResult | undefined,
|
|
82
|
+
ownCanUseOpenPgp: boolean = true,
|
|
74
83
|
): RecipientEncryptionStatus {
|
|
75
|
-
const
|
|
84
|
+
const smimeCert = lookup ? findActivePublicKey(lookup.keys, "encrypt") : undefined;
|
|
85
|
+
const usableOpenPgpKey = lookup && policy.allowOpenPgp === true ? findActivePublicKey(lookup.keys, "encrypt", "openpgp") : undefined;
|
|
86
|
+
// S/MIME, unless the recipient has no certificate or prefers OpenPGP (restapi *Choosing the format*: the user's choice for the
|
|
87
|
+
// contact, what the address advertises, what it was last seen using) - and OpenPGP only where the server allows it.
|
|
88
|
+
const useOpenPgp = !!usableOpenPgpKey && (!smimeCert || (lookup?.preferredFormat === "openpgp" && ownCanUseOpenPgp));
|
|
89
|
+
const encryptCert = useOpenPgp ? undefined : smimeCert;
|
|
90
|
+
const openPgpKey = useOpenPgp ? usableOpenPgpKey : undefined;
|
|
76
91
|
const tier = classifyRecipientTier(ownAddress, recipientAddress, !!lookup && lookup.keys.length > 0);
|
|
77
92
|
const policyState = policyStateForTier(policy, tier);
|
|
78
93
|
|
|
@@ -87,7 +102,7 @@ export function resolveRecipientEncryption(
|
|
|
87
102
|
};
|
|
88
103
|
}
|
|
89
104
|
|
|
90
|
-
if (!encryptCert) {
|
|
105
|
+
if (!encryptCert && !openPgpKey) {
|
|
91
106
|
return { address: recipientAddress, tier, canEncrypt: false, autoEncrypt: false };
|
|
92
107
|
}
|
|
93
108
|
|
|
@@ -96,7 +111,7 @@ export function resolveRecipientEncryption(
|
|
|
96
111
|
return {
|
|
97
112
|
address: recipientAddress,
|
|
98
113
|
tier,
|
|
99
|
-
encryptCert,
|
|
114
|
+
...(encryptCert ? { encryptCert, format: "smime" as const } : { openPgpKey, format: "openpgp" as const }),
|
|
100
115
|
canEncrypt: true,
|
|
101
116
|
autoEncrypt: policyState === "automatic" && mutual,
|
|
102
117
|
};
|
package/lib/crypto/keyExport.ts
CHANGED
|
@@ -15,7 +15,7 @@ import * as asn1js from "asn1js";
|
|
|
15
15
|
import * as pkijs from "pkijs";
|
|
16
16
|
import type { ApiClient } from "../util/api.js";
|
|
17
17
|
import { fromBase64 } from "./encoding.js";
|
|
18
|
-
import { type KeyVault, type PublicKey, findActivePublicKey, recordKeyExport, type WrappedPrivateKey } from "./keyvaultApi.js";
|
|
18
|
+
import { type KeyVault, type PublicKey, findActivePublicKey, isOpenPgpKey, recordKeyExport, type WrappedPrivateKey } from "./keyvaultApi.js";
|
|
19
19
|
import {
|
|
20
20
|
ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE,
|
|
21
21
|
MAX_RETAINED_ENCRYPTION_KEYS,
|
|
@@ -147,7 +147,7 @@ async function openExportedKeys(input: KeyExportInput, vault: KeyVault, masterKe
|
|
|
147
147
|
const active = findActivePublicKey(input.mailboxKeys, "encrypt");
|
|
148
148
|
const encryption = [
|
|
149
149
|
...(active ? [active] : []),
|
|
150
|
-
...input.mailboxKeys.filter((k) => k.useType === "encrypt" && k !== active).sort((a, b) => b.notBefore - a.notBefore),
|
|
150
|
+
...input.mailboxKeys.filter((k) => k.useType === "encrypt" && !isOpenPgpKey(k) && k !== active).sort((a, b) => b.notBefore - a.notBefore),
|
|
151
151
|
];
|
|
152
152
|
await take("encrypt", encryption, ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, MAX_RETAINED_ENCRYPTION_KEYS + 1);
|
|
153
153
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Whether a certificate the server lists is really the one of a private key this device opened. A zero-access key's private half comes
|
|
7
|
+
* from an authenticated seal (or a member's wrap), but its certificate comes from the server as it is: what this device then encrypts
|
|
8
|
+
* to that certificate - the search index segments it builds from end-to-end mail, a protected meeting's key - must not go to a key the
|
|
9
|
+
* server swapped in. Plain WebCrypto and a few lines of DER, so the unlock path loads no certificate library.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** One DER element of `der` at `offset`: where its header starts, where its content starts, where it ends. */
|
|
13
|
+
function element(der: Uint8Array, offset: number): { tag: number; header: number; content: number; end: number } {
|
|
14
|
+
const tag = der[offset];
|
|
15
|
+
let length = der[offset + 1];
|
|
16
|
+
let content = offset + 2;
|
|
17
|
+
if (length & 0x80) {
|
|
18
|
+
const bytes = length & 0x7f;
|
|
19
|
+
if (bytes === 0 || bytes > 4) {
|
|
20
|
+
throw new Error("Unsupported DER length.");
|
|
21
|
+
}
|
|
22
|
+
length = 0;
|
|
23
|
+
for (let i = 0; i < bytes; i++) {
|
|
24
|
+
length = length * 256 + der[content + i];
|
|
25
|
+
}
|
|
26
|
+
content += bytes;
|
|
27
|
+
}
|
|
28
|
+
const end = content + length;
|
|
29
|
+
if (tag === undefined || end > der.length) {
|
|
30
|
+
throw new Error("Truncated DER.");
|
|
31
|
+
}
|
|
32
|
+
return { tag, header: offset, content, end };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The children of the constructed element `parent`. */
|
|
36
|
+
function children(der: Uint8Array, parent: { content: number; end: number }): ReturnType<typeof element>[] {
|
|
37
|
+
const found: ReturnType<typeof element>[] = [];
|
|
38
|
+
for (let offset = parent.content; offset < parent.end; offset = found[found.length - 1].end) {
|
|
39
|
+
found.push(element(der, offset));
|
|
40
|
+
}
|
|
41
|
+
return found;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The SubjectPublicKeyInfo of X.509 certificate `certDer` (RFC 5280: the seventh field of `tbsCertificate`, the sixth when it has no
|
|
45
|
+
* explicit version). */
|
|
46
|
+
function subjectPublicKeyInfo(certDer: Uint8Array): Uint8Array {
|
|
47
|
+
const [tbs] = children(certDer, element(certDer, 0));
|
|
48
|
+
const fields = children(certDer, tbs);
|
|
49
|
+
const spki = fields[fields[0].tag === 0xa0 ? 6 : 5];
|
|
50
|
+
if (!spki || spki.tag !== 0x30) {
|
|
51
|
+
throw new Error("No subject public key.");
|
|
52
|
+
}
|
|
53
|
+
return certDer.subarray(spki.header, spki.end);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const P256_ECDH: EcKeyImportParams = { name: "ECDH", namedCurve: "P-256" };
|
|
57
|
+
|
|
58
|
+
/** Whether `certDer` (a P-256 certificate) carries the public half of `pkcs8` (a P-256 private key). Never rejects: anything that won't
|
|
59
|
+
* parse or import is `false`. */
|
|
60
|
+
export async function certificateHoldsKey(certDer: Uint8Array, pkcs8: Uint8Array): Promise<boolean> {
|
|
61
|
+
try {
|
|
62
|
+
const publicKey = await crypto.subtle.importKey("spki", subjectPublicKeyInfo(certDer) as BufferSource, P256_ECDH, true, []);
|
|
63
|
+
const privateKey = await crypto.subtle.importKey("pkcs8", pkcs8 as BufferSource, P256_ECDH, true, ["deriveBits"]);
|
|
64
|
+
const [certified, held] = await Promise.all([crypto.subtle.exportKey("jwk", publicKey), crypto.subtle.exportKey("jwk", privateKey)]);
|
|
65
|
+
return !!certified.x && certified.x === held.x && certified.y === held.y;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
package/lib/crypto/keySession.ts
CHANGED
|
@@ -21,13 +21,15 @@
|
|
|
21
21
|
* follow-up, not a silent gap: a mailbox enrolled *only* with a passkey has no way to unlock through this
|
|
22
22
|
* module today.
|
|
23
23
|
*/
|
|
24
|
-
import { type KeyVault, type PublicKey, type WrappedPrivateKey, findActivePublicKey, getKeyVault } from "./keyvaultApi.js";
|
|
24
|
+
import { type KeyVault, type PublicKey, type WrappedPrivateKey, findActivePublicKey, getKeyVault, isOpenPgpKey } from "./keyvaultApi.js";
|
|
25
25
|
import type { ApiClient } from "../util/api.js";
|
|
26
26
|
import { fromBase64 } from "./encoding.js";
|
|
27
|
+
import { certificateHoldsKey } from "./keyPairMatch.js";
|
|
27
28
|
import { importPrivateKeyPkcs8 } from "./keys.js";
|
|
28
29
|
import { KeysLockedError, buildAad, openWithKey } from "./masterKey.js";
|
|
29
30
|
import { deriveFromPassword, parseArgon2idKdfLabel } from "./passwordUnlock.js";
|
|
30
31
|
import { RECOVERY_KDF_LABEL, deriveFromRecoveryCode } from "./recoveryCode.js";
|
|
32
|
+
import type { OpenPgpPrivateKey } from "./openpgp.js";
|
|
31
33
|
|
|
32
34
|
/** AAD purpose labels — MUST exactly match what `KeyEnrollmentGate.tsx` used when it originally
|
|
33
35
|
* sealed each of these values, or `openWithKey()` fails (GCM authenticates the AAD, not just the
|
|
@@ -36,6 +38,7 @@ export const MASTER_KEY_AAD_PURPOSE = "master-key";
|
|
|
36
38
|
export const SIGNING_PRIVATE_KEY_AAD_PURPOSE = "sign-private-key";
|
|
37
39
|
export const ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE = "encrypt-private-key";
|
|
38
40
|
export const ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE = "zero-access-private-key";
|
|
41
|
+
export const OPENPGP_PRIVATE_KEY_AAD_PURPOSE = "openpgp-private-key";
|
|
39
42
|
|
|
40
43
|
export { KeysLockedError };
|
|
41
44
|
|
|
@@ -79,6 +82,10 @@ export interface UnlockedKeys {
|
|
|
79
82
|
* index entries and sealed summaries to - the active one first, then retained ones newest first, at most
|
|
80
83
|
* `MAX_RETAINED_ENCRYPTION_KEYS + 1`. Decryption only. Absent when the mailbox has none (or none opened). */
|
|
81
84
|
zeroAccessKeys?: RetainedEncryptionKey[];
|
|
85
|
+
/** The mailbox's OpenPGP secret keys (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*) - the active one
|
|
86
|
+
* first, then retained ones newest first, at most `MAX_RETAINED_ENCRYPTION_KEYS + 1`: what opens mail OpenPGP users encrypt to
|
|
87
|
+
* the mailbox, and signs what it sends them. Absent when the mailbox has none (or none opened). */
|
|
88
|
+
openPgpKeys?: { fingerprint: string; privateKey: OpenPgpPrivateKey }[];
|
|
82
89
|
/** A shared mailbox opened through this user's member key wraps (`sharedMailboxKeys.ts`), not with its own master key: it holds
|
|
83
90
|
* only `zeroAccessKeys`, and `masterKey` is a per-key secret derived from its zero-access private key (what the local search
|
|
84
91
|
* index's key is derived from), never a key that opens the mailbox's vault. */
|
|
@@ -172,6 +179,17 @@ export function addZeroAccessKey(mailboxUid: string, unlocked: UnlockedKeys, key
|
|
|
172
179
|
unlocked.zeroAccessKeys = [key, ...(unlocked.zeroAccessKeys ?? []).filter((k) => k.fingerprint !== key.fingerprint)];
|
|
173
180
|
}
|
|
174
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Makes an OpenPGP key this device has just enrolled (`openPgpKey.ts`'s `ensureOpenPgpKey()`) the active one of `unlocked`, so
|
|
184
|
+
* OpenPGP mail to it reads without another unlock - the same rules as `addZeroAccessKey()`.
|
|
185
|
+
*/
|
|
186
|
+
export function addOpenPgpKey(mailboxUid: string, unlocked: UnlockedKeys, key: { fingerprint: string; privateKey: OpenPgpPrivateKey }): void {
|
|
187
|
+
if (unlocked.destroyed || sessions.get(mailboxUid) !== unlocked) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
unlocked.openPgpKeys = [key, ...(unlocked.openPgpKeys ?? []).filter((k) => k.fingerprint !== key.fingerprint)];
|
|
191
|
+
}
|
|
192
|
+
|
|
175
193
|
/**
|
|
176
194
|
* Stores the keys of a shared mailbox opened through member key wraps (`sharedMailboxKeys.ts`'s `unlockSharedMailbox()`) as that mailbox's
|
|
177
195
|
* session, exactly as an unlock would - tracked for `destroyUnlockedKeys()`, and notified - unless the mailbox was locked since `generation`
|
|
@@ -272,6 +290,15 @@ function destroyObject(unlocked: UnlockedKeys): void {
|
|
|
272
290
|
unlocked.zeroAccessKeys.length = 0;
|
|
273
291
|
delete unlocked.zeroAccessKeys;
|
|
274
292
|
}
|
|
293
|
+
if (unlocked.openPgpKeys) {
|
|
294
|
+
// OpenPGP.js keeps a decrypted secret key's material in its own objects: wipe it there too.
|
|
295
|
+
for (const { privateKey } of unlocked.openPgpKeys) {
|
|
296
|
+
// Present at runtime on every `PrivateKey`, missing from OpenPGP.js's type declarations.
|
|
297
|
+
(privateKey as unknown as { clearPrivateParams(): void }).clearPrivateParams();
|
|
298
|
+
}
|
|
299
|
+
unlocked.openPgpKeys.length = 0;
|
|
300
|
+
delete unlocked.openPgpKeys;
|
|
301
|
+
}
|
|
275
302
|
}
|
|
276
303
|
|
|
277
304
|
/** Finds the wrapped private key whose fingerprint matches a given published public key. */
|
|
@@ -309,7 +336,9 @@ export class UnopenableEncryptionKeyError extends Error {
|
|
|
309
336
|
}
|
|
310
337
|
|
|
311
338
|
/** Opens one wrapped PKCS#8 private key under `masterKey` and imports it (non-extractable by default, like
|
|
312
|
-
* `importPrivateKeyPkcs8()` itself), zeroing the transient plaintext whether or not the import succeeds.
|
|
339
|
+
* `importPrivateKeyPkcs8()` itself), zeroing the transient plaintext whether or not the import succeeds. With `certDer` - the server's
|
|
340
|
+
* certificate for it, unauthenticated - it rejects a key that certificate isn't the one of (`certificateHoldsKey()`), so a swapped
|
|
341
|
+
* certificate never pairs with the opened key. */
|
|
313
342
|
async function openPrivateKey(
|
|
314
343
|
masterKey: Uint8Array,
|
|
315
344
|
wrapped: WrappedPrivateKey,
|
|
@@ -317,9 +346,13 @@ async function openPrivateKey(
|
|
|
317
346
|
algorithm: EcKeyImportParams,
|
|
318
347
|
usages: KeyUsage[],
|
|
319
348
|
extractable = false,
|
|
349
|
+
certDer?: Uint8Array,
|
|
320
350
|
): Promise<CryptoKey> {
|
|
321
351
|
const raw = await openWithKey(masterKey, wrapped, aad);
|
|
322
352
|
try {
|
|
353
|
+
if (certDer && !(await certificateHoldsKey(certDer, raw))) {
|
|
354
|
+
throw new Error("The certificate isn't the key's.");
|
|
355
|
+
}
|
|
323
356
|
return await importPrivateKeyPkcs8(raw, algorithm, usages, extractable);
|
|
324
357
|
} finally {
|
|
325
358
|
raw.fill(0);
|
|
@@ -330,7 +363,7 @@ async function openPrivateKey(
|
|
|
330
363
|
* Opens the retained encryption keys onto `unlocked.retainedEncryptionKeys`: every published `encrypt` key in
|
|
331
364
|
* `mailboxKeys` other than the active one (`activeFingerprint`), whatever its revocation or expiry, that has a wrapped
|
|
332
365
|
* private key in the vault - de-duplicated by fingerprint, newest `notBefore` first, at most
|
|
333
|
-
* `MAX_RETAINED_ENCRYPTION_KEYS`. A key that won't open or import, or whose certificate won't decode, is skipped and
|
|
366
|
+
* `MAX_RETAINED_ENCRYPTION_KEYS`. A key that won't open or import, or whose certificate won't decode or isn't the key's, is skipped and
|
|
334
367
|
* added to `unopenableKeys`. Retained keys are imported non-extractable. Each key is attached as it opens, so a failed
|
|
335
368
|
* unlock's `destroyObject()` drops those too.
|
|
336
369
|
*/
|
|
@@ -345,7 +378,8 @@ async function openRetainedEncryptionKeys(
|
|
|
345
378
|
const seen = new Set<string>(activeFingerprint !== undefined ? [activeFingerprint] : []);
|
|
346
379
|
const candidates: { publicKey: PublicKey; wrapped: WrappedPrivateKey }[] = [];
|
|
347
380
|
for (const publicKey of [...mailboxKeys].sort((a, b) => b.notBefore - a.notBefore)) {
|
|
348
|
-
const wrapped =
|
|
381
|
+
const wrapped =
|
|
382
|
+
publicKey.useType === "encrypt" && !isOpenPgpKey(publicKey) && !seen.has(publicKey.fingerprint) ? findWrappedPrivateKey(vault, publicKey.fingerprint) : undefined;
|
|
349
383
|
if (!wrapped) {
|
|
350
384
|
continue;
|
|
351
385
|
}
|
|
@@ -360,7 +394,7 @@ async function openRetainedEncryptionKeys(
|
|
|
360
394
|
for (const { publicKey, wrapped } of candidates) {
|
|
361
395
|
try {
|
|
362
396
|
const certDer = fromBase64(publicKey.publicKey);
|
|
363
|
-
const privateKey = await openPrivateKey(unlocked.masterKey, wrapped, aad, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
|
|
397
|
+
const privateKey = await openPrivateKey(unlocked.masterKey, wrapped, aad, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false, certDer);
|
|
364
398
|
(unlocked.retainedEncryptionKeys ??= []).push({ fingerprint: publicKey.fingerprint, certDer, privateKey });
|
|
365
399
|
} catch {
|
|
366
400
|
unopenableKeys.push(publicKey.fingerprint);
|
|
@@ -372,7 +406,9 @@ async function openRetainedEncryptionKeys(
|
|
|
372
406
|
* Opens the mailbox's zero-access private keys into `unlocked.zeroAccessKeys`: the vault's `zeroAccessKeys` that have a
|
|
373
407
|
* wrapped private key, the unrevoked one with the latest `notBefore` first, then the rest newest first, at most
|
|
374
408
|
* `MAX_RETAINED_ENCRYPTION_KEYS + 1`. A key that won't open is listed in `unopenableKeys` and skipped - zero-access mail
|
|
375
|
-
* encrypted to it doesn't decrypt on this device, but nothing else about the unlock depends on it.
|
|
409
|
+
* encrypted to it doesn't decrypt on this device, but nothing else about the unlock depends on it. So is one whose certificate (the
|
|
410
|
+
* server's, unauthenticated) isn't the private key's: this device seals to the active key's certificate - search index segments of
|
|
411
|
+
* end-to-end mail, protected meetings' keys - so a certificate swapped by the server must never become it.
|
|
376
412
|
*/
|
|
377
413
|
async function openZeroAccessKeys(mailboxUid: string, vault: KeyVault, unlocked: UnlockedKeys, unopenableKeys: string[]): Promise<void> {
|
|
378
414
|
const ordered = [...(vault.zeroAccessKeys ?? [])].sort(
|
|
@@ -386,8 +422,17 @@ async function openZeroAccessKeys(mailboxUid: string, vault: KeyVault, unlocked:
|
|
|
386
422
|
continue;
|
|
387
423
|
}
|
|
388
424
|
try {
|
|
389
|
-
const
|
|
390
|
-
|
|
425
|
+
const certDer = fromBase64(publicKey.publicKey);
|
|
426
|
+
const raw = await openWithKey(unlocked.masterKey, wrapped, aad);
|
|
427
|
+
try {
|
|
428
|
+
if (!(await certificateHoldsKey(certDer, raw))) {
|
|
429
|
+
throw new Error("The zero-access certificate isn't the key's.");
|
|
430
|
+
}
|
|
431
|
+
const privateKey = await importPrivateKeyPkcs8(raw, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
|
|
432
|
+
(unlocked.zeroAccessKeys ??= []).push({ fingerprint: publicKey.fingerprint, certDer, privateKey });
|
|
433
|
+
} finally {
|
|
434
|
+
raw.fill(0);
|
|
435
|
+
}
|
|
391
436
|
} catch {
|
|
392
437
|
unopenableKeys.push(publicKey.fingerprint);
|
|
393
438
|
}
|
|
@@ -397,6 +442,38 @@ async function openZeroAccessKeys(mailboxUid: string, vault: KeyVault, unlocked:
|
|
|
397
442
|
}
|
|
398
443
|
}
|
|
399
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Opens the mailbox's OpenPGP secret keys into `unlocked.openPgpKeys`: its published OpenPGP certificates (`type: "openpgp"`) that
|
|
447
|
+
* have a wrapped secret key, the active one first (unrevoked, unexpired, latest `notBefore`), then the rest newest first, at most
|
|
448
|
+
* `MAX_RETAINED_ENCRYPTION_KEYS + 1`. OpenPGP.js is loaded only for a mailbox that has one. A key that won't open is listed in
|
|
449
|
+
* `unopenableKeys` and skipped.
|
|
450
|
+
*/
|
|
451
|
+
async function openOpenPgpKeys(mailboxUid: string, mailboxKeys: PublicKey[], vault: KeyVault, unlocked: UnlockedKeys, unopenableKeys: string[]): Promise<void> {
|
|
452
|
+
const now = Date.now();
|
|
453
|
+
const active = (key: PublicKey): boolean => !key.revokedAt && key.notAfter > now;
|
|
454
|
+
const ordered = mailboxKeys
|
|
455
|
+
.filter((key) => isOpenPgpKey(key) && findWrappedPrivateKey(vault, key.fingerprint))
|
|
456
|
+
.sort((a, b) => Number(active(b)) - Number(active(a)) || b.notBefore - a.notBefore)
|
|
457
|
+
.slice(0, MAX_RETAINED_ENCRYPTION_KEYS + 1);
|
|
458
|
+
if (ordered.length === 0) {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const { readSecretKey } = await import("./openpgp.js");
|
|
462
|
+
const aad = buildAad(mailboxUid, OPENPGP_PRIVATE_KEY_AAD_PURPOSE);
|
|
463
|
+
for (const publicKey of ordered) {
|
|
464
|
+
try {
|
|
465
|
+
const raw = await openWithKey(unlocked.masterKey, findWrappedPrivateKey(vault, publicKey.fingerprint)!, aad);
|
|
466
|
+
try {
|
|
467
|
+
(unlocked.openPgpKeys ??= []).push({ fingerprint: publicKey.fingerprint, privateKey: await readSecretKey(raw) });
|
|
468
|
+
} finally {
|
|
469
|
+
raw.fill(0);
|
|
470
|
+
}
|
|
471
|
+
} catch {
|
|
472
|
+
unopenableKeys.push(publicKey.fingerprint);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
400
477
|
/**
|
|
401
478
|
* Unlocks a mailbox's key vault with its password unlock method: fetches the vault, derives the
|
|
402
479
|
* wrapping key from `password` using the *exact* KDF parameters that wrap was created with, unwraps
|
|
@@ -409,8 +486,8 @@ async function openZeroAccessKeys(mailboxUid: string, vault: KeyVault, unlocked:
|
|
|
409
486
|
* "incorrect password," not a generic error, but this module doesn't presume a specific UI's error copy.
|
|
410
487
|
* Once the master key has opened, the password is known to be right: an active signing key that then
|
|
411
488
|
* won't open is skipped and listed in `UnlockResult.unopenableKeys` (so is a retained encryption key); an active
|
|
412
|
-
* encryption key that won't
|
|
413
|
-
*
|
|
489
|
+
* encryption key that won't open, or whose certificate isn't its own,
|
|
490
|
+
* still fails the unlock, with `UnopenableEncryptionKeyError`. Throws `KeysLockedError` when
|
|
414
491
|
* `destroyUnlockedKeys()` locked this mailbox (or all mailboxes) while the unlock was in flight.
|
|
415
492
|
*/
|
|
416
493
|
export async function unlockWithPassword(mailboxUid: string, mailboxKeys: PublicKey[], password: string): Promise<UnlockResult> {
|
|
@@ -555,18 +632,31 @@ async function openSession(
|
|
|
555
632
|
const encryptionPublicKey = findActivePublicKey(mailboxKeys, "encrypt");
|
|
556
633
|
const wrappedEncryptionKey = encryptionPublicKey && findWrappedPrivateKey(vault, encryptionPublicKey.fingerprint);
|
|
557
634
|
if (encryptionPublicKey && wrappedEncryptionKey) {
|
|
635
|
+
let encryptionCertDer: Uint8Array;
|
|
558
636
|
try {
|
|
637
|
+
// The certificate is the server's: one that isn't this key's - what this device encrypts its own copies to, and what it
|
|
638
|
+
// hands out as its S/MIME certificate - makes the key unopenable, exactly as a wrap that won't open.
|
|
639
|
+
encryptionCertDer = fromBase64(encryptionPublicKey.publicKey);
|
|
559
640
|
const aad = buildAad(mailboxUid, ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE);
|
|
560
|
-
unlocked.encryptionPrivateKey = await openPrivateKey(
|
|
641
|
+
unlocked.encryptionPrivateKey = await openPrivateKey(
|
|
642
|
+
masterKey,
|
|
643
|
+
wrappedEncryptionKey,
|
|
644
|
+
aad,
|
|
645
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
646
|
+
["deriveBits"],
|
|
647
|
+
false,
|
|
648
|
+
encryptionCertDer,
|
|
649
|
+
);
|
|
561
650
|
} catch (err) {
|
|
562
651
|
throw new UnopenableEncryptionKeyError(encryptionPublicKey.fingerprint, err);
|
|
563
652
|
}
|
|
564
|
-
unlocked.encryptionCertDer =
|
|
653
|
+
unlocked.encryptionCertDer = encryptionCertDer;
|
|
565
654
|
unlocked.encryptionFingerprint = encryptionPublicKey.fingerprint;
|
|
566
655
|
}
|
|
567
656
|
|
|
568
657
|
await openRetainedEncryptionKeys(mailboxUid, mailboxKeys, vault, unlocked, encryptionPublicKey?.fingerprint, unopenableKeys);
|
|
569
658
|
await openZeroAccessKeys(mailboxUid, vault, unlocked, unopenableKeys);
|
|
659
|
+
await openOpenPgpKeys(mailboxUid, mailboxKeys, vault, unlocked, unopenableKeys);
|
|
570
660
|
if (lockGeneration(mailboxUid) !== generation) {
|
|
571
661
|
// Locked (e.g. logout) while this unlock was awaiting - never restore keys after that.
|
|
572
662
|
throw new KeysLockedError();
|