@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
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes to an event of a calendar zero-access encryption protects (restapi `specs/search.md` §3 *What It Covers*, *Calendar invitations*).
|
|
3
|
+
* The server stores its title, location and description sealed, with placeholders in their place, so it can't tell what changed or invite
|
|
4
|
+
* with them by itself. A write therefore sends only the content fields that differ from what this device shows, and - when the event was
|
|
5
|
+
* opened here - the whole content as `invitationContent`, which the server invites with and never stores. A content field of an opened
|
|
6
|
+
* event that held a value and is emptied is named in `clearedFields`, the only way restapi applies an empty sealed field as a clear.
|
|
7
|
+
*/
|
|
8
|
+
import type { CalendarEvent } from "./calendarApi.js";
|
|
9
|
+
/** The fields zero-access encryption seals on an event. */
|
|
10
|
+
export declare const EVENT_CONTENT_FIELDS: readonly ["title", "location", "description", "descriptionHtml"];
|
|
11
|
+
/** What the server composes a protected event's invitations with. */
|
|
12
|
+
export interface InvitationContent {
|
|
13
|
+
title: string;
|
|
14
|
+
location?: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
descriptionHtml?: string;
|
|
17
|
+
}
|
|
18
|
+
/** `event` marked as holding its opened `sealedContent` (its real title, location and description). */
|
|
19
|
+
export declare function markSealedContentOpened<T extends CalendarEvent>(event: T): T;
|
|
20
|
+
/** Whether `event`'s `sealedContent` was opened on this device: its title, location and description are the real ones. */
|
|
21
|
+
export declare function isSealedContentOpened(event: CalendarEvent | undefined): boolean;
|
|
22
|
+
/** `current`'s content with `changes` applied, for the server to invite (or name a reply or change request) with - `undefined` unless
|
|
23
|
+
* `current` is protected (`sealedFields`) or made from a zero-access invitation (`sealedContent`) and was opened on this device (its values
|
|
24
|
+
* are otherwise placeholders). */
|
|
25
|
+
export declare function invitationContentOf(current: CalendarEvent | undefined, changes?: Record<string, unknown>): InvitationContent | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* `input` as written to `current`: unchanged when `current` isn't protected. Otherwise without the content fields whose values `current`
|
|
28
|
+
* already shows (a placeholder is "unchanged" too, so a locked device's write never overwrites the sealed value), and with
|
|
29
|
+
* `invitationContent` when `current` was opened - and `clearedFields` naming the sealed ones it empties.
|
|
30
|
+
*/
|
|
31
|
+
export declare function protectedEventWrite<T extends object>(current: CalendarEvent | undefined, input: T): T;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { isOpenedRecord, isProtectedRecord } from "../zeroAccess/sealedRecords.js";
|
|
2
|
+
import { KeysLockedError } from "../crypto/masterKey.js";
|
|
3
|
+
/** The fields zero-access encryption seals on an event. */
|
|
4
|
+
export const EVENT_CONTENT_FIELDS = ["title", "location", "description", "descriptionHtml"];
|
|
5
|
+
const text = (value) => (typeof value === "string" ? value : "");
|
|
6
|
+
/** Set on an event made from a zero-access invitation (`sealedContent`) whose sealed content was opened on this device
|
|
7
|
+
* (`apps/shared/calendar/sealedEvents.ts`). A symbol, so it survives a spread copy (an occurrence) and is never sent. */
|
|
8
|
+
const CONTENT_OPENED = Symbol("openedSealedContent");
|
|
9
|
+
/** `event` marked as holding its opened `sealedContent` (its real title, location and description). */
|
|
10
|
+
export function markSealedContentOpened(event) {
|
|
11
|
+
return { ...event, [CONTENT_OPENED]: true };
|
|
12
|
+
}
|
|
13
|
+
/** Whether `event`'s `sealedContent` was opened on this device: its title, location and description are the real ones. */
|
|
14
|
+
export function isSealedContentOpened(event) {
|
|
15
|
+
return !!event && event[CONTENT_OPENED] === true;
|
|
16
|
+
}
|
|
17
|
+
/** Whether `event` shows its real content: nothing of it is sealed, or every sealed part (`sealedFields`, `sealedContent`) was opened. */
|
|
18
|
+
function shownOpened(event) {
|
|
19
|
+
return (!isProtectedRecord(event) || isOpenedRecord(event)) && (!event.sealedContent || isSealedContentOpened(event));
|
|
20
|
+
}
|
|
21
|
+
/** `current`'s content with `changes` applied, for the server to invite (or name a reply or change request) with - `undefined` unless
|
|
22
|
+
* `current` is protected (`sealedFields`) or made from a zero-access invitation (`sealedContent`) and was opened on this device (its values
|
|
23
|
+
* are otherwise placeholders). */
|
|
24
|
+
export function invitationContentOf(current, changes = {}) {
|
|
25
|
+
if (!current || !(isProtectedRecord(current) || current.sealedContent) || !shownOpened(current)) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const value = (field) => text(field in changes ? changes[field] : current[field]);
|
|
29
|
+
const title = value("title");
|
|
30
|
+
if (!title) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
const optional = (field) => value(field) || undefined;
|
|
34
|
+
return { title, location: optional("location"), description: optional("description"), descriptionHtml: optional("descriptionHtml") };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* `input` as written to `current`: unchanged when `current` isn't protected. Otherwise without the content fields whose values `current`
|
|
38
|
+
* already shows (a placeholder is "unchanged" too, so a locked device's write never overwrites the sealed value), and with
|
|
39
|
+
* `invitationContent` when `current` was opened - and `clearedFields` naming the sealed ones it empties.
|
|
40
|
+
*/
|
|
41
|
+
export function protectedEventWrite(current, input) {
|
|
42
|
+
if (current?.sealedContent) {
|
|
43
|
+
return sealedContentWrite(current, input);
|
|
44
|
+
}
|
|
45
|
+
if (!isProtectedRecord(current)) {
|
|
46
|
+
return input;
|
|
47
|
+
}
|
|
48
|
+
const changes = { ...input };
|
|
49
|
+
const clearedFields = [];
|
|
50
|
+
for (const field of EVENT_CONTENT_FIELDS) {
|
|
51
|
+
if (!(field in changes)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (text(changes[field]) === text(current[field])) {
|
|
55
|
+
delete changes[field];
|
|
56
|
+
}
|
|
57
|
+
else if (!text(changes[field]) && current.sealedFields[field] !== undefined && isOpenedRecord(current)) {
|
|
58
|
+
clearedFields.push(field);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (clearedFields.length > 0) {
|
|
62
|
+
changes.clearedFields = clearedFields;
|
|
63
|
+
}
|
|
64
|
+
const invitationContent = invitationContentOf(current, changes);
|
|
65
|
+
return (invitationContent ? { ...changes, invitationContent } : changes);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* `input` as written to `current`, an event made from a zero-access invitation (`sealedContent`). restapi seals its whole content again
|
|
69
|
+
* from the content fields a write sends (`BaseCalendarEventRoute.keepSealed()`), dropping one the write leaves out, since it can't read the
|
|
70
|
+
* sealed value to keep it. So a write that sends any of them sends them all: those it leaves out (the event dialog sends an unchanged
|
|
71
|
+
* description not at all) are filled in with what `current` shows opened - the description and its HTML as one, as the dialog sends them.
|
|
72
|
+
* This holds too for a row that also keeps its title sealed field by field (`sealedFields.title`), whose sealed content's title is empty.
|
|
73
|
+
* Shown with placeholders (its mailbox is locked), the placeholders it would send back are left out, so nothing is sealed again, and a
|
|
74
|
+
* changed content field is refused (`KeysLockedError`) before anything is sent: the rest of the content can't be sent with it. Opened, a
|
|
75
|
+
* write whose location, description or description HTML differs from the one shown raises `sequence` past `current`'s, the only way
|
|
76
|
+
* restapi learns it changed.
|
|
77
|
+
*/
|
|
78
|
+
function sealedContentWrite(current, input) {
|
|
79
|
+
const given = input;
|
|
80
|
+
if (!EVENT_CONTENT_FIELDS.some((field) => given[field] !== undefined)) {
|
|
81
|
+
return input;
|
|
82
|
+
}
|
|
83
|
+
const changes = { ...given };
|
|
84
|
+
if (!shownOpened(current)) {
|
|
85
|
+
for (const field of EVENT_CONTENT_FIELDS) {
|
|
86
|
+
if (field in changes && text(changes[field]) === text(current[field])) {
|
|
87
|
+
delete changes[field];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (EVENT_CONTENT_FIELDS.some((field) => changes[field] !== undefined)) {
|
|
91
|
+
throw new KeysLockedError("This event is protected and your mailbox is locked. Unlock it to change its title, location or description.");
|
|
92
|
+
}
|
|
93
|
+
return changes;
|
|
94
|
+
}
|
|
95
|
+
for (const field of ["title", "location"]) {
|
|
96
|
+
if (changes[field] === undefined) {
|
|
97
|
+
changes[field] = current[field] ?? null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (changes.description === undefined && changes.descriptionHtml === undefined) {
|
|
101
|
+
changes.description = current.description ?? null;
|
|
102
|
+
changes.descriptionHtml = current.descriptionHtml ?? null;
|
|
103
|
+
}
|
|
104
|
+
// restapi can't compare a location or description sealed whole (it holds placeholders), so a real change raises the sequence here: an
|
|
105
|
+
// organizer-owned meeting then invites again, as for a readable event's new location or description. A description field the write
|
|
106
|
+
// doesn't send is compared no more than restapi compares it for a readable event. An unchanged one leaves it alone.
|
|
107
|
+
const descriptionChanged = ["description", "descriptionHtml"].some((field) => changes[field] !== undefined && text(changes[field]) !== text(current[field]));
|
|
108
|
+
if (descriptionChanged || text(changes.location) !== text(current.location)) {
|
|
109
|
+
changes.sequence = (current.sequence ?? 0) + 1;
|
|
110
|
+
}
|
|
111
|
+
const invitationContent = isProtectedRecord(current) ? invitationContentOf(current, changes) : undefined;
|
|
112
|
+
return (invitationContent ? { ...changes, invitationContent } : changes);
|
|
113
|
+
}
|
|
@@ -58,3 +58,5 @@ export declare function expandAllOccurrences(events: CalendarEvent[], rangeStart
|
|
|
58
58
|
export declare function describeRecurrence(rule: RecurrenceRule): string;
|
|
59
59
|
export declare const WEEKDAY_CODES: WeekdayCode[];
|
|
60
60
|
export declare const WEEKDAY_LABELS: Record<WeekdayCode, string>;
|
|
61
|
+
/** When `event`'s last occurrence ends: its own end, or a series' last occurrence's - far ahead for a series without an end. */
|
|
62
|
+
export declare function seriesEndDate(event: CalendarEvent): Date;
|
|
@@ -271,3 +271,19 @@ export const WEEKDAY_LABELS = {
|
|
|
271
271
|
SA: "Sat",
|
|
272
272
|
SU: "Sun",
|
|
273
273
|
};
|
|
274
|
+
/** When `event`'s last occurrence ends: its own end, or a series' last occurrence's - far ahead for a series without an end. */
|
|
275
|
+
export function seriesEndDate(event) {
|
|
276
|
+
const rule = event.recurrenceRule;
|
|
277
|
+
const end = new Date(event.endDate);
|
|
278
|
+
if (!rule) {
|
|
279
|
+
return end;
|
|
280
|
+
}
|
|
281
|
+
if (!rule.until && !rule.count) {
|
|
282
|
+
return new Date(8.64e15);
|
|
283
|
+
}
|
|
284
|
+
const duration = end.valueOf() - new Date(event.startDate).valueOf();
|
|
285
|
+
const horizon = new Date(rule.until ? new Date(rule.until).valueOf() + 86400000 : Date.now() + 5 * 365 * 86400000);
|
|
286
|
+
const occurrences = expandOccurrences(event, new Date(event.startDate), horizon);
|
|
287
|
+
const last = occurrences[occurrences.length - 1];
|
|
288
|
+
return last ? new Date(new Date(last.startDate).valueOf() + duration) : end;
|
|
289
|
+
}
|
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*/
|
|
6
|
-
/** Remembers the decrypted calendar file of zero-access message `messageUid`. */
|
|
7
|
-
export declare function rememberSealedInvite(messageUid: string, ics: string): void;
|
|
8
|
-
/** The decrypted calendar file of zero-access message `messageUid`, when this device has read it. */
|
|
1
|
+
/** Remembers the decrypted calendar file of zero-access message `messageUid` of `mailboxUid` - not while that mailbox is locked (a
|
|
2
|
+
* pane still drawing what it decrypted before the lock). */
|
|
3
|
+
export declare function rememberSealedInvite(messageUid: string, ics: string, mailboxUid: string): void;
|
|
4
|
+
/** The decrypted calendar file of zero-access message `messageUid`, when this device has read it and its mailbox is unlocked. */
|
|
9
5
|
export declare function sealedInviteCalendar(messageUid: string): string | undefined;
|
|
6
|
+
/** Calls `listener` with the messages whose files a mailbox's lock forgot. Returns the unsubscribe. */
|
|
7
|
+
export declare function onSealedInvitesForgotten(listener: (messageUids: string[]) => void): () => void;
|
|
10
8
|
/** Forgets every remembered calendar file (sign-out). */
|
|
11
9
|
export declare function clearSealedInvites(): void;
|
|
@@ -5,22 +5,59 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* The calendar files of zero-access messages, as decrypted on this device (`MessageSecurityResult.calendar`). The server holds only
|
|
7
7
|
* the sealed copy of such a message, so every invitation call for it (`inviteApi.ts`) sends the file along. Kept in memory for the
|
|
8
|
-
* messages read most recently,
|
|
8
|
+
* messages read most recently, only while their mailbox is unlocked: its lock forgets them (telling `onSealedInvitesForgotten()`
|
|
9
|
+
* listeners, so what was read with them goes too), and so does sign-out (`clearSealedInvites()`).
|
|
9
10
|
*/
|
|
11
|
+
import { getUnlockedKeys, subscribeKeySession } from "../crypto/keySession.js";
|
|
10
12
|
/** How many messages' calendar files are kept. */
|
|
11
13
|
const MAX_SEALED_INVITES = 100;
|
|
12
14
|
const calendars = new Map();
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
const forgottenListeners = new Set();
|
|
16
|
+
let watchingKeys = false;
|
|
17
|
+
/** A mailbox's files go the moment it locks, so nothing opened outlives its key session. Subscribed on the first file, not on import. */
|
|
18
|
+
function forgetOnLock() {
|
|
19
|
+
if (watchingKeys) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
watchingKeys = true;
|
|
23
|
+
subscribeKeySession(({ mailboxUid, state }) => {
|
|
24
|
+
if (state !== "locked") {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const forgotten = [];
|
|
28
|
+
for (const [messageUid, entry] of calendars) {
|
|
29
|
+
if (entry.mailboxUid === mailboxUid) {
|
|
30
|
+
calendars.delete(messageUid);
|
|
31
|
+
forgotten.push(messageUid);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (forgotten.length) {
|
|
35
|
+
forgottenListeners.forEach((listener) => listener(forgotten));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** Remembers the decrypted calendar file of zero-access message `messageUid` of `mailboxUid` - not while that mailbox is locked (a
|
|
40
|
+
* pane still drawing what it decrypted before the lock). */
|
|
41
|
+
export function rememberSealedInvite(messageUid, ics, mailboxUid) {
|
|
42
|
+
if (!getUnlockedKeys(mailboxUid)) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
forgetOnLock();
|
|
15
46
|
calendars.delete(messageUid);
|
|
16
|
-
calendars.set(messageUid, ics);
|
|
47
|
+
calendars.set(messageUid, { mailboxUid, ics });
|
|
17
48
|
while (calendars.size > MAX_SEALED_INVITES) {
|
|
18
49
|
calendars.delete(calendars.keys().next().value);
|
|
19
50
|
}
|
|
20
51
|
}
|
|
21
|
-
/** The decrypted calendar file of zero-access message `messageUid`, when this device has read it. */
|
|
52
|
+
/** The decrypted calendar file of zero-access message `messageUid`, when this device has read it and its mailbox is unlocked. */
|
|
22
53
|
export function sealedInviteCalendar(messageUid) {
|
|
23
|
-
|
|
54
|
+
const entry = calendars.get(messageUid);
|
|
55
|
+
return entry && getUnlockedKeys(entry.mailboxUid) ? entry.ics : undefined;
|
|
56
|
+
}
|
|
57
|
+
/** Calls `listener` with the messages whose files a mailbox's lock forgot. Returns the unsubscribe. */
|
|
58
|
+
export function onSealedInvitesForgotten(listener) {
|
|
59
|
+
forgottenListeners.add(listener);
|
|
60
|
+
return () => forgottenListeners.delete(listener);
|
|
24
61
|
}
|
|
25
62
|
/** Forgets every remembered calendar file (sign-out). */
|
|
26
63
|
export function clearSealedInvites() {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* comment for the shared ACL/authorization model every wrapper file here follows. */
|
|
3
3
|
import { ApiClient } from "../util/api.js";
|
|
4
4
|
import { ListParams } from "../util/apiQuery.js";
|
|
5
|
-
import { type EncryptionPreference, type KeyConflict, type PreviousKey, type PublicKey, type RejectedKey } from "../crypto/keyvaultApi.js";
|
|
5
|
+
import { type EncryptionPreference, type EndToEndFormat, type KeyConflict, type PreviousKey, type PublicKey, type RejectedKey } from "../crypto/keyvaultApi.js";
|
|
6
6
|
export type ContactAddressKind = "home" | "work" | "other";
|
|
7
7
|
export interface ContactEmail {
|
|
8
8
|
address: string;
|
|
@@ -51,6 +51,18 @@ export interface Contact {
|
|
|
51
51
|
* via `crypto/keyvaultApi.ts`'s `lookupKeys()` at compose time, never fetched on message receipt
|
|
52
52
|
* (that would leak read timing to the sender's server). */
|
|
53
53
|
encryptPreference?: EncryptionPreference;
|
|
54
|
+
/** The protected details sealed to the mailbox's zero-access key while it protects contacts (`displayName` then shows the first email
|
|
55
|
+
* address, the other protected fields are empty) - opened with `openSealedRecords()`. Server-written. */
|
|
56
|
+
sealedFields?: Record<string, string> | null;
|
|
57
|
+
/** The photo (`photoBlobKey`) is sealed to the mailbox's zero-access key: it is fetched and opened, never shown by URL. Server-written. */
|
|
58
|
+
photoSealed?: boolean | null;
|
|
59
|
+
/** The end-to-end format the user chose for this contact ("Preferred format"); absent or null for automatic. */
|
|
60
|
+
preferredFormat?: EndToEndFormat | null;
|
|
61
|
+
/** The format this address was last seen using, from authenticated mail it sent (server-written). */
|
|
62
|
+
observedFormat?: {
|
|
63
|
+
format: EndToEndFormat;
|
|
64
|
+
at: number;
|
|
65
|
+
} | null;
|
|
54
66
|
/** This contact's pinned public keys. Trust is TOFU (trust-on-first-use) — see `keyConflicts` for what
|
|
55
67
|
* happens when a newly observed key differs from one of these. */
|
|
56
68
|
keys?: PublicKey[];
|
|
@@ -67,12 +79,21 @@ export interface Contact {
|
|
|
67
79
|
}
|
|
68
80
|
/** Lists a folder's contacts, alphabetically by display name. Never includes soft-deleted contacts — see
|
|
69
81
|
* `listDeletedContacts()` for those. */
|
|
82
|
+
/** The protected contact fields a list of contacts shows - all a list opens (`openSealedRecords(contacts, CONTACT_LIST_FIELDS)`); a view of
|
|
83
|
+
* one contact opens the rest. */
|
|
84
|
+
export declare const CONTACT_LIST_FIELDS: readonly string[];
|
|
85
|
+
/** The protected fields a duplicate check compares (an address-less card is matched by name and phone). */
|
|
86
|
+
export declare const CONTACT_MATCH_FIELDS: readonly string[];
|
|
70
87
|
export declare function listContacts(folderUid: string, params?: ListParams, client?: ApiClient): Promise<Contact[]>;
|
|
71
88
|
/** The trusted signing-key fingerprints of every contact in `contacts` with an email equal (case-insensitively) to
|
|
72
89
|
* `address`: each contact's pinned `keys` and its `previousKeys`, so mail signed before a key rotation still verifies -
|
|
73
90
|
* see `keyvaultApi.ts`'s `signingKeyFingerprints()` for the rules (expired and superseded keys kept, compromised or reasonless revoked keys excluded). De-duplicated;
|
|
74
91
|
* `[]` when no contact matches or none has a trusted signing key. */
|
|
75
92
|
export declare function pinnedSigningFingerprintsFor(contacts: Contact[], address: string): string[];
|
|
93
|
+
/** The OpenPGP certificates (base64, `PublicKey.publicKey`) pinned on every contact in `contacts` for `address` - current and
|
|
94
|
+
* previous ones still trusted for verification (`isTrustedForVerification()`) - which an OpenPGP signature from that sender is
|
|
95
|
+
* checked against (an OpenPGP signature carries only a key ID, so the key itself is needed). De-duplicated by fingerprint. */
|
|
96
|
+
export declare function pinnedOpenPgpKeysFor(contacts: Contact[], address: string): string[];
|
|
76
97
|
/** Page size and page cap for `fetchPinnedSigningFingerprints()` and `fetchSignerKeyState()` (500 is restapi's own
|
|
77
98
|
* `limit` cap). */
|
|
78
99
|
export declare const PINNED_FINGERPRINT_PAGE_SIZE = 500;
|
|
@@ -99,6 +120,9 @@ export interface SignerKeyState {
|
|
|
99
120
|
previous: PreviousKey[];
|
|
100
121
|
/** The first unresolved signing-key conflict found, if any. */
|
|
101
122
|
conflict?: KeyConflict;
|
|
123
|
+
/** The pinned OpenPGP certificates (which sign as well as encrypt), de-duplicated by fingerprint - what an OpenPGP signer's
|
|
124
|
+
* key-changed comparison shows. Absent when there are none. */
|
|
125
|
+
openPgp?: PublicKey[];
|
|
102
126
|
}
|
|
103
127
|
/** The signing-key state of every contact in `contacts` with an email equal (case-insensitively) to `address`, merged -
|
|
104
128
|
* what a UI needs to compare a `signer_key_changed` message's certificate with the pinned and previous keys (their
|
|
@@ -161,7 +185,9 @@ export interface UpdateContactInput extends ContactInput {
|
|
|
161
185
|
* (`dateCreated`/`dateModified`/etc.), so prefer sending only what actually changed over a whole
|
|
162
186
|
* fetched `Contact`. An `UpdateContactInput` is also a valid `ContactPatch`. */
|
|
163
187
|
export type ContactPatch = Partial<Contact> & Pick<Contact, "uid" | "version">;
|
|
164
|
-
|
|
188
|
+
/** Saves `patch`. A patch moving the contact to another mailbox (`mailboxUid`) is refused with `SealedMoveError` when the contact is
|
|
189
|
+
* protected - before sending anything when `current` (the contact as this device has it) says so, else from the server's refusal. */
|
|
190
|
+
export declare function updateContact(patch: ContactPatch, client?: ApiClient, current?: Contact): Promise<Contact>;
|
|
165
191
|
/** Thin `updateContact()` wrapper for toggling favorite/star status — same pattern as `mailApi.ts`'s
|
|
166
192
|
* `setMessageRead()`/`tasksApi.ts`'s `setTaskCompleted()`. Sends only `uid`/`version`/`favorite`, never
|
|
167
193
|
* the rest of `contact` (the server rejects its managed fields). */
|
|
@@ -178,7 +204,7 @@ export declare const CONTACT_PHOTO_MAX_BYTES: number;
|
|
|
178
204
|
/** Sets a contact's own picture from an image file (the file's own bytes go up, not JSON) and returns the updated contact. */
|
|
179
205
|
export declare function uploadContactPhoto(uid: string, version: number, file: Blob, client?: ApiClient): Promise<Contact>;
|
|
180
206
|
/** The bytes of a contact's own picture through an explicit `client` - for one that authenticates with a token, whose `<img>` cannot (see `contactPhotoUrl()`). */
|
|
181
|
-
export declare function fetchContactPhoto(uid: string, version: number, client
|
|
207
|
+
export declare function fetchContactPhoto(uid: string, version: number, client?: ApiClient): Promise<Blob>;
|
|
182
208
|
/** Removes a contact's own picture and returns the updated contact. */
|
|
183
209
|
export declare function deleteContactPhoto(uid: string, version: number, client?: ApiClient): Promise<Contact>;
|
|
184
210
|
export declare function deleteContact(uid: string, version: number, client?: ApiClient): Promise<void>;
|
|
@@ -4,14 +4,20 @@
|
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
/** Typed wrappers over `@rapidmx/restapi`'s `/mail/contacts` REST surface — see `mailApi.ts`'s own header
|
|
6
6
|
* comment for the shared ACL/authorization model every wrapper file here follows. */
|
|
7
|
-
import { apiUrl, withClient, withClientRaw } from "../util/api.js";
|
|
7
|
+
import { apiUrl, withClient, withClientBlob, withClientRaw } from "../util/api.js";
|
|
8
8
|
import { SETTINGS_LIST_LIMIT, buildQuery } from "../util/apiQuery.js";
|
|
9
|
-
import {
|
|
9
|
+
import { guardSealedMove } from "../zeroAccess/sealedMoves.js";
|
|
10
|
+
import { signingKeyFingerprints, isOpenPgpKey, isTrustedForVerification } from "../crypto/keyvaultApi.js";
|
|
10
11
|
/** The order a contacts list is read in: alphabetical, with the `uid` as the tie-breaker so contacts sharing a name keep one fixed order and
|
|
11
12
|
* a page boundary between them can neither skip nor repeat one. */
|
|
12
13
|
const CONTACT_SORT = JSON.stringify({ displayName: "ASC", uid: "ASC" });
|
|
13
14
|
/** Lists a folder's contacts, alphabetically by display name. Never includes soft-deleted contacts — see
|
|
14
15
|
* `listDeletedContacts()` for those. */
|
|
16
|
+
/** The protected contact fields a list of contacts shows - all a list opens (`openSealedRecords(contacts, CONTACT_LIST_FIELDS)`); a view of
|
|
17
|
+
* one contact opens the rest. */
|
|
18
|
+
export const CONTACT_LIST_FIELDS = ["displayName", "phones", "categories"];
|
|
19
|
+
/** The protected fields a duplicate check compares (an address-less card is matched by name and phone). */
|
|
20
|
+
export const CONTACT_MATCH_FIELDS = ["displayName", "phones"];
|
|
15
21
|
export function listContacts(folderUid, params = {}, client) {
|
|
16
22
|
return withClient(client, `/mail/contacts?${buildQuery(params, { folderUid, sort: CONTACT_SORT })}`);
|
|
17
23
|
}
|
|
@@ -26,6 +32,13 @@ function contactsMatching(contacts, address) {
|
|
|
26
32
|
export function pinnedSigningFingerprintsFor(contacts, address) {
|
|
27
33
|
return [...new Set(contactsMatching(contacts, address).flatMap((contact) => signingKeyFingerprints(contact.keys, contact.previousKeys)))];
|
|
28
34
|
}
|
|
35
|
+
/** The OpenPGP certificates (base64, `PublicKey.publicKey`) pinned on every contact in `contacts` for `address` - current and
|
|
36
|
+
* previous ones still trusted for verification (`isTrustedForVerification()`) - which an OpenPGP signature from that sender is
|
|
37
|
+
* checked against (an OpenPGP signature carries only a key ID, so the key itself is needed). De-duplicated by fingerprint. */
|
|
38
|
+
export function pinnedOpenPgpKeysFor(contacts, address) {
|
|
39
|
+
const keys = contactsMatching(contacts, address).flatMap((contact) => [...(contact.keys ?? []), ...(contact.previousKeys ?? [])]);
|
|
40
|
+
return uniqueByFingerprint(keys.filter((key) => isOpenPgpKey(key) && isTrustedForVerification(key))).map((key) => key.publicKey);
|
|
41
|
+
}
|
|
29
42
|
/** Page size and page cap for `fetchPinnedSigningFingerprints()` and `fetchSignerKeyState()` (500 is restapi's own
|
|
30
43
|
* `limit` cap). */
|
|
31
44
|
export const PINNED_FINGERPRINT_PAGE_SIZE = 500;
|
|
@@ -77,7 +90,8 @@ export function signerKeyStateFor(contacts, address) {
|
|
|
77
90
|
const pinned = uniqueByFingerprint(matching.flatMap((contact) => (contact.keys ?? []).filter((key) => key.useType === "sign")));
|
|
78
91
|
const previous = uniqueByFingerprint(matching.flatMap((contact) => (contact.previousKeys ?? []).filter((key) => key.useType === "sign")).sort((a, b) => b.replacedAt - a.replacedAt));
|
|
79
92
|
const conflict = matching.flatMap((contact) => contact.keyConflicts ?? []).find((entry) => entry.useType === "sign");
|
|
80
|
-
|
|
93
|
+
const openPgp = uniqueByFingerprint(matching.flatMap((contact) => (contact.keys ?? []).filter((key) => isOpenPgpKey(key))));
|
|
94
|
+
return { pinned, previous, ...(conflict ? { conflict } : {}), ...(openPgp.length > 0 ? { openPgp } : {}) };
|
|
81
95
|
}
|
|
82
96
|
/** Fetches `signerKeyStateFor()` for `address` from the contacts in `folderUids`, paging exactly like
|
|
83
97
|
* `fetchPinnedSigningFingerprints()`. Reads only stored contacts - never triggers a Discovery lookup. */
|
|
@@ -117,11 +131,13 @@ export function createContact(input, client) {
|
|
|
117
131
|
body: JSON.stringify({ emails: [], phones: [], addresses: [], ...input }),
|
|
118
132
|
});
|
|
119
133
|
}
|
|
120
|
-
|
|
121
|
-
|
|
134
|
+
/** Saves `patch`. A patch moving the contact to another mailbox (`mailboxUid`) is refused with `SealedMoveError` when the contact is
|
|
135
|
+
* protected - before sending anything when `current` (the contact as this device has it) says so, else from the server's refusal. */
|
|
136
|
+
export function updateContact(patch, client, current) {
|
|
137
|
+
return guardSealedMove(current, patch.mailboxUid, () => withClient(client, `/mail/contacts/${encodeURIComponent(patch.uid)}`, {
|
|
122
138
|
method: "PUT",
|
|
123
139
|
body: JSON.stringify(patch),
|
|
124
|
-
});
|
|
140
|
+
}));
|
|
125
141
|
}
|
|
126
142
|
/** Thin `updateContact()` wrapper for toggling favorite/star status — same pattern as `mailApi.ts`'s
|
|
127
143
|
* `setMessageRead()`/`tasksApi.ts`'s `setTaskCompleted()`. Sends only `uid`/`version`/`favorite`, never
|
|
@@ -146,7 +162,7 @@ export function uploadContactPhoto(uid, version, file, client) {
|
|
|
146
162
|
}
|
|
147
163
|
/** The bytes of a contact's own picture through an explicit `client` - for one that authenticates with a token, whose `<img>` cannot (see `contactPhotoUrl()`). */
|
|
148
164
|
export function fetchContactPhoto(uid, version, client) {
|
|
149
|
-
return client
|
|
165
|
+
return withClientBlob(client, `/mail/contacts/${encodeURIComponent(uid)}/photo?v=${version}`, "Could not load the contact's picture.");
|
|
150
166
|
}
|
|
151
167
|
/** Removes a contact's own picture and returns the updated contact. */
|
|
152
168
|
export function deleteContactPhoto(uid, version, client) {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ApiClient } from "../util/api.js";
|
|
2
2
|
import { type Contact } from "./contactsApi.js";
|
|
3
|
+
/** What a photo is fetched by: the contact's identity, and whether (and for which mailbox) it is sealed. */
|
|
4
|
+
type PhotoContact = Pick<Contact, "uid" | "version" | "photoBlobKey"> & Partial<Pick<Contact, "mailboxUid" | "photoSealed">>;
|
|
3
5
|
/** Forgets every cached picture (and revokes its URL). For a test, or when an account is signed out. */
|
|
4
6
|
export declare function clearContactPhotoCache(): void;
|
|
5
7
|
/**
|
|
@@ -8,12 +10,13 @@ export declare function clearContactPhotoCache(): void;
|
|
|
8
10
|
* 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
|
|
9
11
|
* 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.
|
|
10
12
|
*/
|
|
11
|
-
export declare function useContactPhotoSrc(contact:
|
|
13
|
+
export declare function useContactPhotoSrc(contact: PhotoContact | undefined, client?: ApiClient): string | undefined;
|
|
12
14
|
/**
|
|
13
15
|
* `useContactPhotoSrc()`, also saying whether a picture that has to be fetched could not be (`failed`: no `src` is coming), and held back until `active` - an avatar
|
|
14
16
|
* that is not on screen yet has no reason to fetch its picture.
|
|
15
17
|
*/
|
|
16
|
-
export declare function useContactPhoto(contact:
|
|
18
|
+
export declare function useContactPhoto(contact: PhotoContact | undefined, client?: ApiClient, active?: boolean): {
|
|
17
19
|
src: string | undefined;
|
|
18
20
|
failed: boolean;
|
|
19
21
|
};
|
|
22
|
+
export {};
|
|
@@ -2,8 +2,24 @@
|
|
|
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
|
+
import { getUnlockedKeys, subscribeKeySession } from "../crypto/keySession.js";
|
|
7
|
+
import { openSealedBytes } from "../zeroAccess/sealedRecords.js";
|
|
6
8
|
import { contactPhotoUrl, fetchContactPhoto } from "./contactsApi.js";
|
|
9
|
+
/** The cache identity of the global (cookie) client, for a sealed photo fetched without an explicit client. */
|
|
10
|
+
const GLOBAL_CLIENT = {};
|
|
11
|
+
/** 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). */
|
|
12
|
+
async function loadPhoto(contact, client) {
|
|
13
|
+
const blob = await fetchContactPhoto(contact.uid, contact.version, client);
|
|
14
|
+
if (!contact.photoSealed) {
|
|
15
|
+
return blob;
|
|
16
|
+
}
|
|
17
|
+
const opened = await openSealedBytes(contact.mailboxUid ?? "", new Uint8Array(await blob.arrayBuffer()));
|
|
18
|
+
if (!opened) {
|
|
19
|
+
throw new Error("The contact's picture is protected and can't be opened here.");
|
|
20
|
+
}
|
|
21
|
+
return new Blob([opened]);
|
|
22
|
+
}
|
|
7
23
|
/** 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. */
|
|
8
24
|
const CACHE_LIMIT = 50;
|
|
9
25
|
/** Least recently used first (a `Map` iterates in insertion order, and a use moves the entry to the end). */
|
|
@@ -12,6 +28,29 @@ const entries = new Map();
|
|
|
12
28
|
const pending = new Map();
|
|
13
29
|
const clientIds = new WeakMap();
|
|
14
30
|
let nextClientId = 0;
|
|
31
|
+
let watchingKeys = false;
|
|
32
|
+
/** A sealed picture opened with a mailbox's keys goes when that mailbox locks, like every other opened value (`sealedRecords.ts`): its URL
|
|
33
|
+
* is revoked even while an avatar shows it - each one drops it itself (`useContactPhoto()`). Subscribed with the first sealed picture. */
|
|
34
|
+
function forgetOnLock() {
|
|
35
|
+
if (watchingKeys) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
watchingKeys = true;
|
|
39
|
+
subscribeKeySession(({ mailboxUid, state }) => {
|
|
40
|
+
if (state === "locked") {
|
|
41
|
+
forgetSealed(mailboxUid);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Revokes and forgets `mailboxUid`'s sealed pictures. */
|
|
46
|
+
function forgetSealed(mailboxUid) {
|
|
47
|
+
for (const [key, entry] of entries) {
|
|
48
|
+
if (entry.mailboxUid === mailboxUid) {
|
|
49
|
+
URL.revokeObjectURL(entry.url);
|
|
50
|
+
entries.delete(key);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
15
54
|
/** 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. */
|
|
16
55
|
function keyOf(client, uid, version) {
|
|
17
56
|
let id = clientIds.get(client);
|
|
@@ -63,8 +102,20 @@ function limited(task) {
|
|
|
63
102
|
}
|
|
64
103
|
});
|
|
65
104
|
}
|
|
66
|
-
/**
|
|
67
|
-
|
|
105
|
+
/**
|
|
106
|
+
* The `blob:` URL for `key`, fetched with `load` unless it is cached, and counted as shown until `release()`. Concurrent callers share one fetch.
|
|
107
|
+
* `sealedMailboxUid` is a sealed picture's mailbox: opened only while it is unlocked, it is neither fetched, handed out from the cache nor
|
|
108
|
+
* stored once that mailbox is locked (rejects).
|
|
109
|
+
*/
|
|
110
|
+
async function acquire(key, load, sealedMailboxUid) {
|
|
111
|
+
if (sealedMailboxUid !== undefined) {
|
|
112
|
+
forgetOnLock();
|
|
113
|
+
if (!getUnlockedKeys(sealedMailboxUid)) {
|
|
114
|
+
// Not fetched either: it couldn't be opened, and the avatar retries it once the mailbox unlocks (`useContactPhoto()`).
|
|
115
|
+
forgetSealed(sealedMailboxUid);
|
|
116
|
+
throw new Error("The contact's mailbox is locked.");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
68
119
|
const entry = entries.get(key);
|
|
69
120
|
if (entry) {
|
|
70
121
|
entry.refs++;
|
|
@@ -78,9 +129,13 @@ async function acquire(key, load) {
|
|
|
78
129
|
waiters: 0,
|
|
79
130
|
request: limited(load)
|
|
80
131
|
.then((blob) => {
|
|
132
|
+
// Opened just before a lock came: nothing opened is kept past it.
|
|
133
|
+
if (sealedMailboxUid !== undefined && !getUnlockedKeys(sealedMailboxUid)) {
|
|
134
|
+
throw new Error("The contact's mailbox locked while its picture was opened.");
|
|
135
|
+
}
|
|
81
136
|
const url = URL.createObjectURL(blob);
|
|
82
137
|
// Claimed by everyone waiting from the start: an entry nobody holds yet could be trimmed before its avatar got to it.
|
|
83
|
-
entries.set(key, { url, refs: created.waiters });
|
|
138
|
+
entries.set(key, { url, refs: created.waiters, mailboxUid: sealedMailboxUid });
|
|
84
139
|
return url;
|
|
85
140
|
})
|
|
86
141
|
.finally(() => pending.delete(key)),
|
|
@@ -121,16 +176,41 @@ export function useContactPhotoSrc(contact, client) {
|
|
|
121
176
|
export function useContactPhoto(contact, client, active = true) {
|
|
122
177
|
const [loaded, setLoaded] = useState();
|
|
123
178
|
const [failedKey, setFailedKey] = useState();
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
179
|
+
// 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).
|
|
180
|
+
const [attempt, setAttempt] = useState(0);
|
|
181
|
+
// A sealed photo is always fetched (and opened) here: a URL would show the browser ciphertext.
|
|
182
|
+
const fetched = !!client || !!contact?.photoSealed;
|
|
183
|
+
const key = active && fetched && contact?.photoBlobKey ? keyOf(client ?? GLOBAL_CLIENT, contact.uid, contact.version) : undefined;
|
|
184
|
+
const sealedMailboxUid = contact?.photoSealed ? (contact.mailboxUid ?? "") : undefined;
|
|
185
|
+
const failedRef = useRef(failedKey);
|
|
186
|
+
failedRef.current = failedKey;
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (!key || sealedMailboxUid === undefined) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
return subscribeKeySession(({ mailboxUid, state }) => {
|
|
192
|
+
if (mailboxUid !== sealedMailboxUid) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (state === "locked") {
|
|
196
|
+
// What the <img> decoded stays on screen until its src goes: drop it, then try again (it fails while locked: initials).
|
|
197
|
+
setLoaded(undefined);
|
|
198
|
+
setFailedKey(undefined);
|
|
199
|
+
setAttempt((n) => n + 1);
|
|
200
|
+
}
|
|
201
|
+
else if (failedRef.current === key) {
|
|
202
|
+
setFailedKey(undefined);
|
|
203
|
+
setAttempt((n) => n + 1);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}, [key, sealedMailboxUid]);
|
|
127
207
|
useEffect(() => {
|
|
128
|
-
if (!key
|
|
208
|
+
if (!key) {
|
|
129
209
|
return;
|
|
130
210
|
}
|
|
131
211
|
let cancelled = false;
|
|
132
212
|
let held = false;
|
|
133
|
-
acquire(key, () =>
|
|
213
|
+
acquire(key, () => loadPhoto(contact, client), sealedMailboxUid).then((url) => {
|
|
134
214
|
if (cancelled) {
|
|
135
215
|
release(key);
|
|
136
216
|
}
|
|
@@ -145,8 +225,8 @@ export function useContactPhoto(contact, client, active = true) {
|
|
|
145
225
|
release(key);
|
|
146
226
|
}
|
|
147
227
|
};
|
|
148
|
-
}, [key]);
|
|
149
|
-
if (
|
|
228
|
+
}, [key, attempt]);
|
|
229
|
+
if (fetched) {
|
|
150
230
|
return { src: key !== undefined && loaded?.key === key ? loaded.url : undefined, failed: key !== undefined && failedKey === key };
|
|
151
231
|
}
|
|
152
232
|
return { src: contact && contactPhotoUrl(contact), failed: false };
|
|
@@ -22,9 +22,15 @@ export declare function classifyRecipientTier(ownAddress: string, recipientAddre
|
|
|
22
22
|
export interface RecipientEncryptionStatus {
|
|
23
23
|
address: string;
|
|
24
24
|
tier: RecipientTier;
|
|
25
|
-
/** The recipient's currently-active encryption certificate, if they have published (and this device
|
|
25
|
+
/** The recipient's currently-active S/MIME encryption certificate, if they have published (and this device
|
|
26
26
|
* has discovered) one. */
|
|
27
27
|
encryptCert?: PublicKey;
|
|
28
|
+
/** The recipient's active OpenPGP certificate - only when they have no S/MIME certificate and the server allows OpenPGP
|
|
29
|
+
* (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*: S/MIME is preferred whenever it can be used). */
|
|
30
|
+
openPgpKey?: PublicKey;
|
|
31
|
+
/** Which format this recipient is encrypted in: `"smime"` whenever they have an S/MIME certificate, `"openpgp"` only for
|
|
32
|
+
* a recipient whose only key is an OpenPGP one. Absent when they can't be encrypted to. */
|
|
33
|
+
format?: "smime" | "openpgp";
|
|
28
34
|
/** Whether this recipient *can* be encrypted to at all right now - false when there's no usable key,
|
|
29
35
|
* or the tier's policy is `prohibited`. */
|
|
30
36
|
canEncrypt: boolean;
|
|
@@ -38,8 +44,10 @@ export interface RecipientEncryptionStatus {
|
|
|
38
44
|
}
|
|
39
45
|
/** Per-recipient encryption eligibility, given this device's own encryption preference and the
|
|
40
46
|
* system-wide policy. One entry per (address, lookup result) pair the caller already resolved via
|
|
41
|
-
* `lookupKeys()` - this function does no discovery itself.
|
|
42
|
-
|
|
47
|
+
* `lookupKeys()` - this function does no discovery itself. `ownCanUseOpenPgp` false: the sender has no OpenPGP key to use here
|
|
48
|
+
* (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
|
|
49
|
+
* preference never leaves them unencrypted (restapi `specs/end-to-end_encryption.md` *Choosing the format*). */
|
|
50
|
+
export declare function resolveRecipientEncryption(ownAddress: string, ownPrefersMutual: boolean, policy: EncryptionPolicy, recipientAddress: string, lookup: KeyLookupResult | undefined, ownCanUseOpenPgp?: boolean): RecipientEncryptionStatus;
|
|
43
51
|
export interface MessageEncryptionDecision {
|
|
44
52
|
/** Whether the compose window's encryption toggle should default to checked - true only when every
|
|
45
53
|
* recipient independently defaults to encrypted. Seeds the toggle's *initial* state only; the user
|