@rapidmx/web-client 0.36.0 → 0.37.1
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
|
@@ -32,8 +32,10 @@ import {
|
|
|
32
32
|
type ZeroAccessEscrowWrapInput,
|
|
33
33
|
type ZeroAccessKey,
|
|
34
34
|
} from "./keyvaultApi.js";
|
|
35
|
+
import { certificateHoldsKey } from "./keyPairMatch.js";
|
|
35
36
|
import { exportPrivateKeyPkcs8, generateKeyPairWithCsr, importPrivateKeyPkcs8 } from "./keys.js";
|
|
36
37
|
import {
|
|
38
|
+
addZeroAccessKey,
|
|
37
39
|
getUnlockedKeys,
|
|
38
40
|
installSharedSession,
|
|
39
41
|
personalSessions,
|
|
@@ -106,9 +108,13 @@ function keyOrder(status: Pick<SharedKeyStatus, "activeKeyFingerprint" | "zeroAc
|
|
|
106
108
|
/**
|
|
107
109
|
* Opens shared mailbox `mailboxUid` for its member, with no password: their member wraps, opened with the zero-access keys of a mailbox they
|
|
108
110
|
* own and have unlocked, become this session's keys for it (`installSharedSession()`). See `SharedUnlockOutcome` for when it can't.
|
|
111
|
+
*
|
|
112
|
+
* `refresh`: a shared session already open is opened again when it lacks the mailbox's active key and the member now holds a wrap of it -
|
|
113
|
+
* after a rotation, new mail is sealed to a key the session doesn't have. Its `masterKey` secret is kept, so the local index stays readable.
|
|
109
114
|
*/
|
|
110
|
-
export async function unlockSharedMailbox(mailboxUid: string, client?: ApiClient): Promise<SharedUnlockOutcome> {
|
|
111
|
-
|
|
115
|
+
export async function unlockSharedMailbox(mailboxUid: string, client?: ApiClient, refresh = false): Promise<SharedUnlockOutcome> {
|
|
116
|
+
const existing: UnlockedKeys | undefined = getUnlockedKeys(mailboxUid);
|
|
117
|
+
if (existing && !(refresh && existing.shared)) {
|
|
112
118
|
return "unlocked";
|
|
113
119
|
}
|
|
114
120
|
const generation: string = sharedUnlockGeneration(mailboxUid);
|
|
@@ -117,14 +123,22 @@ export async function unlockSharedMailbox(mailboxUid: string, client?: ApiClient
|
|
|
117
123
|
status = await getSharedKeyStatus(mailboxUid, client);
|
|
118
124
|
} catch (err) {
|
|
119
125
|
if (err instanceof ApiRequestError && (err.status === 403 || err.status === 404)) {
|
|
120
|
-
return "not_shared";
|
|
126
|
+
return existing ? "unlocked" : "not_shared";
|
|
121
127
|
}
|
|
122
128
|
throw err;
|
|
123
129
|
}
|
|
130
|
+
const holdsActive = (keys: { fingerprint: string }[] | undefined): boolean => !!keys?.some((key) => key.fingerprint === status.activeKeyFingerprint);
|
|
131
|
+
if (existing && (!status.activeKeyFingerprint || holdsActive(existing.zeroAccessKeys))) {
|
|
132
|
+
return "unlocked";
|
|
133
|
+
}
|
|
124
134
|
if (status.role !== "member") {
|
|
125
|
-
return "not_shared";
|
|
135
|
+
return existing ? "unlocked" : "not_shared";
|
|
126
136
|
}
|
|
127
137
|
const wraps: MemberKeyWrap[] = await getMyMemberKeyWraps(mailboxUid, client);
|
|
138
|
+
// Nothing new to open yet: the active key's wrap is still on its way.
|
|
139
|
+
if (existing && !holdsActive(wraps.map((wrap) => ({ fingerprint: wrap.zeroAccessKeyFingerprint })))) {
|
|
140
|
+
return "unlocked";
|
|
141
|
+
}
|
|
128
142
|
if (wraps.length === 0) {
|
|
129
143
|
return "pending";
|
|
130
144
|
}
|
|
@@ -141,18 +155,27 @@ export async function unlockSharedMailbox(mailboxUid: string, client?: ApiClient
|
|
|
141
155
|
}
|
|
142
156
|
const pkcs8 = unwrapped.pkcs8;
|
|
143
157
|
try {
|
|
158
|
+
// The certificate is the server's: one that isn't this key's must never be what this session seals to.
|
|
159
|
+
if (!(await certificateHoldsKey(fromBase64(certificate.publicKey), pkcs8))) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
144
162
|
secret ??= await sessionSecret(pkcs8);
|
|
145
163
|
const privateKey = await importPrivateKeyPkcs8(pkcs8, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"]);
|
|
146
164
|
opened.push({ fingerprint: wrap.zeroAccessKeyFingerprint, certDer: fromBase64(certificate.publicKey), privateKey });
|
|
147
165
|
openedWith.add(unwrapped.openedWith);
|
|
148
166
|
} catch {
|
|
149
|
-
// A wrap that doesn't hold a usable key is skipped; the others still open the mailbox.
|
|
167
|
+
// A wrap that doesn't hold a usable key (or one its certificate doesn't match) is skipped; the others still open the mailbox.
|
|
150
168
|
} finally {
|
|
151
169
|
pkcs8.fill(0);
|
|
152
170
|
}
|
|
153
171
|
}
|
|
154
172
|
if (!secret || opened.length === 0) {
|
|
155
|
-
return "own_keys_locked";
|
|
173
|
+
return existing ? "unlocked" : "own_keys_locked";
|
|
174
|
+
}
|
|
175
|
+
if (existing) {
|
|
176
|
+
// Opened again: the secret the local index key is derived from stays the one the session started with.
|
|
177
|
+
secret.fill(0);
|
|
178
|
+
secret = new Uint8Array(existing.masterKey);
|
|
156
179
|
}
|
|
157
180
|
const unlocked: UnlockedKeys = { masterKey: secret, zeroAccessKeys: opened, openedWith: [...openedWith] };
|
|
158
181
|
installSharedSession(mailboxUid, unlocked, generation);
|
|
@@ -160,7 +183,8 @@ export async function unlockSharedMailbox(mailboxUid: string, client?: ApiClient
|
|
|
160
183
|
}
|
|
161
184
|
|
|
162
185
|
/** The PKCS#8 of every zero-access key of `mailboxUid` the caller can open, by fingerprint - from the vault with the owner's unlocked master
|
|
163
|
-
* key, or from the caller's own member wraps.
|
|
186
|
+
* key, or from the caller's own member wraps. Only keys whose certificate in `status` (the server's) is really theirs: a key is wrapped
|
|
187
|
+
* for members and escrow under that certificate's fingerprint, so a mismatched one is left out. The caller zeroes them. */
|
|
164
188
|
async function openPrivateKeys(
|
|
165
189
|
mailboxUid: string,
|
|
166
190
|
status: SharedKeyStatus,
|
|
@@ -168,6 +192,14 @@ async function openPrivateKeys(
|
|
|
168
192
|
client?: ApiClient,
|
|
169
193
|
): Promise<Map<string, Uint8Array>> {
|
|
170
194
|
const keys = new Map<string, Uint8Array>();
|
|
195
|
+
const keep = async (fingerprint: string, pkcs8: Uint8Array): Promise<void> => {
|
|
196
|
+
const certificate = status.zeroAccessKeys.find((key) => key.fingerprint === fingerprint);
|
|
197
|
+
if (certificate && (await certificateHoldsKey(fromBase64(certificate.publicKey), pkcs8))) {
|
|
198
|
+
keys.set(fingerprint, pkcs8);
|
|
199
|
+
} else {
|
|
200
|
+
pkcs8.fill(0);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
171
203
|
if (ownerUnlocked) {
|
|
172
204
|
const vault = await getKeyVault(mailboxUid, client);
|
|
173
205
|
const aad = buildAad(mailboxUid, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE);
|
|
@@ -175,7 +207,7 @@ async function openPrivateKeys(
|
|
|
175
207
|
const wrapped = vault.wrappedKeys.find((w) => w.fingerprint === key.fingerprint && w.useType === "zero-access");
|
|
176
208
|
if (wrapped) {
|
|
177
209
|
try {
|
|
178
|
-
|
|
210
|
+
await keep(key.fingerprint, await openWithKey(ownerUnlocked.masterKey, wrapped, aad));
|
|
179
211
|
} catch {
|
|
180
212
|
// Sealed under a master key since replaced: not this device's to share.
|
|
181
213
|
}
|
|
@@ -187,7 +219,7 @@ async function openPrivateKeys(
|
|
|
187
219
|
for (const wrap of await getMyMemberKeyWraps(mailboxUid, client)) {
|
|
188
220
|
const unwrapped = await openWrap(wrap, own);
|
|
189
221
|
if (unwrapped) {
|
|
190
|
-
|
|
222
|
+
await keep(wrap.zeroAccessKeyFingerprint, unwrapped.pkcs8);
|
|
191
223
|
}
|
|
192
224
|
}
|
|
193
225
|
return keys;
|
|
@@ -201,9 +233,22 @@ async function wrapFor(member: { userUid: string; certificate: ZeroAccessKey },
|
|
|
201
233
|
};
|
|
202
234
|
}
|
|
203
235
|
|
|
204
|
-
/** The members with a certificate of their own - the ones a key can be wrapped for.
|
|
236
|
+
/** The members with a certificate of their own - the ones a key can be wrapped for. A certificate listed under the fingerprint of one of
|
|
237
|
+
* this user's own opened zero-access keys must be that key's certificate (checked against its private key at unlock): one the server
|
|
238
|
+
* swapped is left out, so a wrap meant for this user never goes to a key that isn't theirs. */
|
|
205
239
|
function wrappable(status: SharedKeyStatus): { userUid: string; certificate: ZeroAccessKey; wrapped: string[] }[] {
|
|
206
|
-
|
|
240
|
+
const own = ownZeroAccessKeys();
|
|
241
|
+
return status.members.filter((member): member is typeof member & { certificate: ZeroAccessKey } => {
|
|
242
|
+
if (!member.certificate) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
const mine = own.find((key) => key.fingerprint === member.certificate!.fingerprint);
|
|
246
|
+
return !mine || sameBytes(mine.certDer, fromBase64(member.certificate.publicKey));
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
|
251
|
+
return a.length === b.length && a.every((byte, index) => byte === b[index]);
|
|
207
252
|
}
|
|
208
253
|
|
|
209
254
|
/** `pkcs8` wrapped to the certificate of the escrow scope `escrow` describes. */
|
|
@@ -232,10 +277,17 @@ export interface SharedMaintenance {
|
|
|
232
277
|
rotated: boolean;
|
|
233
278
|
/** Wraps added for waiting members. */
|
|
234
279
|
wrapped: number;
|
|
280
|
+
/** Wraps of older keys the server refused for waiting members (tried again on the next pass). */
|
|
281
|
+
refused: number;
|
|
235
282
|
/** Departed users whose wraps were removed. */
|
|
236
283
|
removed: number;
|
|
237
284
|
/** Keys wrapped for the mailbox's escrow scope. */
|
|
238
285
|
escrowed: number;
|
|
286
|
+
/**
|
|
287
|
+
* The server's reason when it refused (400) the new key of a pending rotation - e.g. the vault couldn't hold the new key's member wraps
|
|
288
|
+
* next to those it holds - so the current key stays active and members keep reading; the rotation is tried again on the next pass.
|
|
289
|
+
*/
|
|
290
|
+
rotationRefused?: string;
|
|
239
291
|
}
|
|
240
292
|
|
|
241
293
|
/**
|
|
@@ -245,11 +297,13 @@ export interface SharedMaintenance {
|
|
|
245
297
|
* A mailbox without an owner and without a key gets one (unless the administrator disallows zero-access), wrapped for every member who has a
|
|
246
298
|
* certificate. Once a user who held the active key has left (`rotationPending`), a new key replaces it - enrolled by the owner with their
|
|
247
299
|
* master key (`provisionZeroAccessKey()`), or for a mailbox without an owner with the members' wraps - and then the departed users' wraps
|
|
248
|
-
* are removed. Members still waiting (`pending`) get wraps of every key this client can open,
|
|
249
|
-
* owner gets copies of the keys it is
|
|
300
|
+
* are removed. Members still waiting (`pending`) get wraps of every key this client can open, one write per key with the active key's
|
|
301
|
+
* first (a refused older key doesn't stop the rest), and the escrow scope of a mailbox without an owner gets copies of the keys it is
|
|
302
|
+
* missing (`escrowPending`). A rotation the server refuses (400) is reported in `rotationRefused`, not thrown: the current key stays active
|
|
303
|
+
* and the rest of the pass goes ahead.
|
|
250
304
|
*/
|
|
251
305
|
export async function maintainSharedMailbox(mailboxUid: string, mailboxAddress: string, client?: ApiClient): Promise<SharedMaintenance> {
|
|
252
|
-
const result: SharedMaintenance = { provisioned: false, rotated: false, wrapped: 0, removed: 0, escrowed: 0 };
|
|
306
|
+
const result: SharedMaintenance = { provisioned: false, rotated: false, wrapped: 0, refused: 0, removed: 0, escrowed: 0 };
|
|
253
307
|
let status: SharedKeyStatus = await getSharedKeyStatus(mailboxUid, client);
|
|
254
308
|
if (!status.manager || (status.members.length === 0 && status.departed.length === 0)) {
|
|
255
309
|
return result;
|
|
@@ -267,14 +321,25 @@ export async function maintainSharedMailbox(mailboxUid: string, mailboxAddress:
|
|
|
267
321
|
status = await enrollNewSharedKey(mailboxUid, mailboxAddress, status, client);
|
|
268
322
|
result.provisioned = true;
|
|
269
323
|
} else if (status.rotationPending) {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
324
|
+
try {
|
|
325
|
+
if (status.ownerless) {
|
|
326
|
+
status = await enrollNewSharedKey(mailboxUid, mailboxAddress, status, client);
|
|
327
|
+
} else {
|
|
328
|
+
const vault = await getKeyVault(mailboxUid, client);
|
|
329
|
+
const { key } = await provisionZeroAccessKey(mailboxUid, mailboxAddress, ownerUnlocked!.masterKey, vault.masterKeyGeneration, client);
|
|
330
|
+
// The owner's open session gets the new key as its active one, as `ensureZeroAccessKey()` does: new mail is sealed to it.
|
|
331
|
+
addZeroAccessKey(mailboxUid, ownerUnlocked!, key);
|
|
332
|
+
status = await getSharedKeyStatus(mailboxUid, client);
|
|
333
|
+
}
|
|
334
|
+
result.rotated = true;
|
|
335
|
+
} catch (err) {
|
|
336
|
+
// Refused before any key was issued (restapi: the vault can't hold the new key's member wraps next to those it holds): the
|
|
337
|
+
// current key stays active, so the rest of the pass - waiting members' wraps of it - still goes ahead. Anything else throws.
|
|
338
|
+
if (!(err instanceof ApiRequestError) || err.status !== 400) {
|
|
339
|
+
throw err;
|
|
340
|
+
}
|
|
341
|
+
result.rotationRefused = err.message;
|
|
276
342
|
}
|
|
277
|
-
result.rotated = true;
|
|
278
343
|
}
|
|
279
344
|
// The departed hold no wrap of the active key any more (or never did): their old wraps go.
|
|
280
345
|
if (!status.rotationPending) {
|
|
@@ -289,17 +354,30 @@ export async function maintainSharedMailbox(mailboxUid: string, mailboxAddress:
|
|
|
289
354
|
if (missing.length > 0 || escrowPending.length > 0) {
|
|
290
355
|
const keys = await openPrivateKeys(mailboxUid, status, ownerUnlocked, client);
|
|
291
356
|
try {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
357
|
+
// One write per key, the active key's first: it alone opens new mail, so a refused backfill of an older key (the server bounds
|
|
358
|
+
// each key's wraps and the vault's total) never keeps a member from it. A failed older key is left for the next pass.
|
|
359
|
+
const order = keyOrder(status);
|
|
360
|
+
for (const [fingerprint, pkcs8] of [...keys].sort(([a], [b]) => order(a) - order(b))) {
|
|
361
|
+
const wraps: MemberKeyWrap[] = [];
|
|
362
|
+
for (const member of missing) {
|
|
295
363
|
if (!member.wrapped.includes(fingerprint)) {
|
|
296
364
|
wraps.push({ ...(await wrapFor(member, pkcs8)), zeroAccessKeyFingerprint: fingerprint });
|
|
297
365
|
}
|
|
298
366
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
367
|
+
if (wraps.length === 0) {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (fingerprint === status.activeKeyFingerprint) {
|
|
371
|
+
await putMemberKeyWraps(mailboxUid, wraps, client);
|
|
372
|
+
result.wrapped += wraps.length;
|
|
373
|
+
} else {
|
|
374
|
+
try {
|
|
375
|
+
await putMemberKeyWraps(mailboxUid, wraps, client);
|
|
376
|
+
result.wrapped += wraps.length;
|
|
377
|
+
} catch {
|
|
378
|
+
result.refused += wraps.length;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
303
381
|
}
|
|
304
382
|
const held: string[] = escrowPending.filter((fingerprint) => keys.has(fingerprint));
|
|
305
383
|
if (held.length > 0) {
|
|
@@ -77,7 +77,8 @@ export function applyBaselineOuterHeaders(headers: ProtectedHeaders): ProtectedH
|
|
|
77
77
|
return { ...headers, subject: "[...]" };
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
/** @internal Shared with `pgpMessage.ts`. */
|
|
81
|
+
export function base64Wrapped(bytes: Uint8Array): string {
|
|
81
82
|
const encoded = toBase64(bytes);
|
|
82
83
|
const lines: string[] = [];
|
|
83
84
|
for (let i = 0; i < encoded.length; i += BASE64_LINE_WIDTH) {
|
|
@@ -86,7 +87,8 @@ function base64Wrapped(bytes: Uint8Array): string {
|
|
|
86
87
|
return lines.join(CRLF);
|
|
87
88
|
}
|
|
88
89
|
|
|
89
|
-
|
|
90
|
+
/** @internal Shared with `pgpMessage.ts`. */
|
|
91
|
+
export function generateBoundary(): string {
|
|
90
92
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
91
93
|
return `----=_RapidMX_${Array.from(bytes)
|
|
92
94
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
@@ -94,7 +96,7 @@ function generateBoundary(): string {
|
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
/** Strips CR/LF/NUL from a header value that has no further encoding (Date, Message-ID, Content-Type). */
|
|
97
|
-
function flattenHeaderValue(value: string): string {
|
|
99
|
+
export function flattenHeaderValue(value: string): string {
|
|
98
100
|
return value.replace(/[\r\n\0]+/g, " ");
|
|
99
101
|
}
|
|
100
102
|
|
|
@@ -116,7 +118,8 @@ function headerLines(headers: ProtectedHeaders, prefix = ""): string[] {
|
|
|
116
118
|
];
|
|
117
119
|
}
|
|
118
120
|
|
|
119
|
-
|
|
121
|
+
/** @internal Shared with `pgpMessage.ts`: the protected header block of an inner entity (with `HP-Outer:` copies of `hpOuter`). */
|
|
122
|
+
export function protectedHeaderLines(headers: ProtectedHeaders, hpOuter?: ProtectedHeaders): string {
|
|
120
123
|
const lines = headerLines(headers);
|
|
121
124
|
if (hpOuter) {
|
|
122
125
|
// RFC 9788 Section 2.2.1's `hp-outer` field: the literal string "HP-Outer:" followed by the
|
|
@@ -369,7 +372,7 @@ export type ComparableOuterHeaders = Partial<Pick<ProtectedHeaders, "from" | "to
|
|
|
369
372
|
* (a real bug if reused for this - JSON/`Record` keys aren't multi-valued). Scans the raw header block
|
|
370
373
|
* text directly instead. Returns `undefined` when no `HP-Outer:` lines are present at all, so a caller
|
|
371
374
|
* can distinguish "nothing to compare" from "compared and everything matched". */
|
|
372
|
-
function extractHpOuterHeaders(fields: MimeHeaderField[]): ComparableOuterHeaders | undefined {
|
|
375
|
+
export function extractHpOuterHeaders(fields: MimeHeaderField[]): ComparableOuterHeaders | undefined {
|
|
373
376
|
const result: ComparableOuterHeaders = {};
|
|
374
377
|
let found = false;
|
|
375
378
|
for (const { name, value } of fields) {
|
|
@@ -386,7 +389,7 @@ function extractHpOuterHeaders(fields: MimeHeaderField[]): ComparableOuterHeader
|
|
|
386
389
|
/** `true` when every field `hpOuter` actually carries matches `actualOuterHeaders`' value for that same
|
|
387
390
|
* field - a field `hpOuter` doesn't carry (e.g. no `Cc` on this message) is not compared, since RFC
|
|
388
391
|
* 9788 never claims coverage for it either way. */
|
|
389
|
-
function outerHeadersMatch(hpOuter: ComparableOuterHeaders, actualOuterHeaders: ComparableOuterHeaders): boolean {
|
|
392
|
+
export function outerHeadersMatch(hpOuter: ComparableOuterHeaders, actualOuterHeaders: ComparableOuterHeaders): boolean {
|
|
390
393
|
return (Object.keys(hpOuter) as (keyof ComparableOuterHeaders)[]).every((field) => hpOuter[field] === actualOuterHeaders[field]);
|
|
391
394
|
}
|
|
392
395
|
|
|
@@ -477,7 +480,8 @@ export function splitHeadersAndBody(entity: string): { headers: Record<string, s
|
|
|
477
480
|
return { headers, contentType: headers["content-type"], body, rawHeaderBlock };
|
|
478
481
|
}
|
|
479
482
|
|
|
480
|
-
|
|
483
|
+
/** @internal Shared with `pgpMessage.ts`. */
|
|
484
|
+
export function headersToProtectedHeaders(headers: Record<string, string>): ProtectedHeaders {
|
|
481
485
|
return {
|
|
482
486
|
from: headers["from"] ?? "",
|
|
483
487
|
to: headers["to"] ?? "",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import type { ApiClient } from "../util/api.js";
|
|
12
12
|
import { fromBase64 } from "./encoding.js";
|
|
13
13
|
import { type KeyVault, enrollKey, getKeyVault } from "./keyvaultApi.js";
|
|
14
|
+
import { certificateHoldsKey } from "./keyPairMatch.js";
|
|
14
15
|
import { exportPrivateKeyPkcs8, generateKeyPairWithCsr, importPrivateKeyPkcs8 } from "./keys.js";
|
|
15
16
|
import { addZeroAccessKey, getUnlockedKeys, type RetainedEncryptionKey, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE } from "./keySession.js";
|
|
16
17
|
import { buildAad, sealWithKey } from "./masterKey.js";
|
|
@@ -56,6 +57,10 @@ export async function provisionZeroAccessKey(
|
|
|
56
57
|
if (!enrolled || !certificate) {
|
|
57
58
|
throw new Error("The server did not return the enrolled zero-access key.");
|
|
58
59
|
}
|
|
60
|
+
// The certificate is the server's: this device seals to it (index segments, protected meetings' keys), so it must be the new key's.
|
|
61
|
+
if (!(await certificateHoldsKey(fromBase64(certificate.publicKey), raw))) {
|
|
62
|
+
throw new Error("The server returned a zero-access certificate that isn't the enrolled key's.");
|
|
63
|
+
}
|
|
59
64
|
const privateKey = await importPrivateKeyPkcs8(raw, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
|
|
60
65
|
return { vault, key: { fingerprint: enrolled.fingerprint, certDer: fromBase64(certificate.publicKey), privateKey } };
|
|
61
66
|
} finally {
|
package/lib/mail/mailApi.ts
CHANGED
|
@@ -135,6 +135,12 @@ export interface Mailbox {
|
|
|
135
135
|
/** "Enable zero-access encryption": unset means on. Only the mailbox's owner can change it - restapi drops anybody
|
|
136
136
|
* else's value. Has no effect while the server's `EncryptionPolicy.allowZeroAccess` is off. */
|
|
137
137
|
zeroAccessEnabled?: boolean;
|
|
138
|
+
/** Which data types zero-access encryption covers - see `ZeroAccessTypes`. Owner only. */
|
|
139
|
+
zeroAccessTypes?: ZeroAccessTypes;
|
|
140
|
+
/** The ActiveSync and Outlook clients that sync this mailbox, as the server last saw them. Server-written. */
|
|
141
|
+
syncClients?: SyncClients;
|
|
142
|
+
/** When the owner last exported a zero-access key for a phone or Outlook. Server-written. */
|
|
143
|
+
zeroAccessKeysExportedAt?: number;
|
|
138
144
|
/** How the caller reaches this mailbox: `"owner"` (theirs) or `"delegate"` (shared with them). Set by the plain list and read of
|
|
139
145
|
* mailboxes - not in an administration-scope answer - so a client can label the shared ones; see `isSharedWithMe()`. */
|
|
140
146
|
accessRole?: "owner" | "delegate";
|
|
@@ -263,6 +269,19 @@ export function autoProvisionMailbox(
|
|
|
263
269
|
* `null` clears them - `JSON.stringify()` drops an `undefined` value entirely, which would instead leave
|
|
264
270
|
* the old value in place.
|
|
265
271
|
*/
|
|
272
|
+
/** The data types zero-access encryption can cover (restapi `specs/search.md` §3 *What It Covers*). */
|
|
273
|
+
export type ZeroAccessDataType = "mail" | "calendar" | "contacts" | "tasks";
|
|
274
|
+
|
|
275
|
+
/** `Mailbox.zeroAccessTypes`: per data type, whether zero-access covers it. A type left out is undecided: mail counts as on, the others
|
|
276
|
+
* as off, until the server decides them from the owner's devices. */
|
|
277
|
+
export type ZeroAccessTypes = Partial<Record<ZeroAccessDataType, boolean>>;
|
|
278
|
+
|
|
279
|
+
/** `Mailbox.syncClients`: the ActiveSync devices (each with the data types it syncs) and Outlook. */
|
|
280
|
+
export interface SyncClients {
|
|
281
|
+
activesync?: { devices: { id: string; model?: string; types: ZeroAccessDataType[]; lastSeenAt: number }[] };
|
|
282
|
+
mapi?: { lastSeenAt: number };
|
|
283
|
+
}
|
|
284
|
+
|
|
266
285
|
export interface UpdateMailboxInput {
|
|
267
286
|
uid: string;
|
|
268
287
|
version: number;
|
|
@@ -289,6 +308,10 @@ export interface UpdateMailboxInput {
|
|
|
289
308
|
autoSendReceiptsExternal?: boolean;
|
|
290
309
|
/** Owner-only server-side (anybody else's value is dropped) - see `Mailbox.zeroAccessEnabled`. */
|
|
291
310
|
zeroAccessEnabled?: boolean;
|
|
311
|
+
/** Owner-only server-side; a partial choice is merged into the stored one - see `Mailbox.zeroAccessTypes`. */
|
|
312
|
+
zeroAccessTypes?: ZeroAccessTypes;
|
|
313
|
+
/** The mailbox's own encryption preference, replaced whole - see `Mailbox.encryptPreference`. */
|
|
314
|
+
encryptPreference?: EncryptionPreference;
|
|
292
315
|
/** Trusted-caller-only server-side. `null` (or `""`) unassigns the mailbox's escrow scope; the
|
|
293
316
|
* referenced `EscrowScope` must exist otherwise. See `Mailbox.escrowScopeId`. */
|
|
294
317
|
escrowScopeId?: string | null;
|
|
@@ -1094,6 +1117,10 @@ export interface AssembleDraftInput {
|
|
|
1094
1117
|
html: string;
|
|
1095
1118
|
/** See `AssembleIntent`. */
|
|
1096
1119
|
intent?: AssembleIntent;
|
|
1120
|
+
/** The addresses this copy is delivered to, when not all of `to`/`cc`/`bcc`: one copy of a message sent in two end-to-end
|
|
1121
|
+
* formats (an S/MIME and an OpenPGP copy, each listing every recipient) goes only to the recipients of its own format. Each
|
|
1122
|
+
* must be one of the recipients. */
|
|
1123
|
+
deliverTo?: string[];
|
|
1097
1124
|
}
|
|
1098
1125
|
|
|
1099
1126
|
/**
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { ApiClient, withClient, withClientRaw } from "../util/api.js";
|
|
13
13
|
import { RequestListParams, buildRequestListQuery } from "../util/apiQuery.js";
|
|
14
|
+
import type { Mailbox } from "./mailApi.js";
|
|
14
15
|
|
|
15
16
|
export type { RequestListParams };
|
|
16
17
|
|
|
@@ -79,3 +80,26 @@ export function listImportRequests(params: RequestListParams = {}, client?: ApiC
|
|
|
79
80
|
export function getImportRequest(uid: string, client?: ApiClient): Promise<MailboxImportRequest> {
|
|
80
81
|
return withClient(client, `/mail/mailbox-import-requests/${encodeURIComponent(uid)}`);
|
|
81
82
|
}
|
|
83
|
+
|
|
84
|
+
/** Why server-side import is refused for a mailbox whose mail is zero-access (restapi answers such an upload 409). */
|
|
85
|
+
export const ZERO_ACCESS_IMPORT_MESSAGE =
|
|
86
|
+
"Importing an archive isn't available while zero-access encryption protects this mailbox's mail: the server would have to keep " +
|
|
87
|
+
"the uploaded archive readable until the import ran, which zero-access encryption doesn't allow.";
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether `mailbox`'s mail is stored zero-access encrypted, so restapi refuses a server-side import into it (`specs/search.md` §3: the
|
|
91
|
+
* uploaded archive would wait readable in the blob store). Decided as restapi's `zeroAccessStatus()` decides it for mail: the
|
|
92
|
+
* administrator's "Allow zero-access encryption" (`allowZeroAccess`; unknown counts as allowed, so an import is never offered only to be
|
|
93
|
+
* refused), the owner's switch (unset is on), mail among the protected types (undecided is on), and a key that isn't revoked.
|
|
94
|
+
*/
|
|
95
|
+
export function mailIsZeroAccess(
|
|
96
|
+
mailbox: Pick<Mailbox, "zeroAccessEnabled" | "zeroAccessTypes" | "zeroAccessKeys">,
|
|
97
|
+
allowZeroAccess: boolean | undefined,
|
|
98
|
+
): boolean {
|
|
99
|
+
return (
|
|
100
|
+
allowZeroAccess !== false &&
|
|
101
|
+
mailbox.zeroAccessEnabled !== false &&
|
|
102
|
+
mailbox.zeroAccessTypes?.mail !== false &&
|
|
103
|
+
(mailbox.zeroAccessKeys ?? []).some((key) => !key.revokedAt)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -30,6 +30,10 @@ export interface IndexWeekEntries {
|
|
|
30
30
|
latestEntryAt: string;
|
|
31
31
|
/** The week has closed and its grace period has passed: a client may compact it. */
|
|
32
32
|
compactable: boolean;
|
|
33
|
+
/** When the week becomes (or became) compactable - where the platform stagger starts. Absent from a server before it. */
|
|
34
|
+
compactableAt?: string;
|
|
35
|
+
/** Size of the week's pending sealed entries (bytes of base64). Absent from a server before it. */
|
|
36
|
+
pendingBytes?: number;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
export interface SegmentManifest {
|
package/lib/tasks/tasksApi.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import { ApiClient, withClient } from "../util/api.js";
|
|
9
9
|
import { ListParams, SETTINGS_LIST_LIMIT, buildQuery } from "../util/apiQuery.js";
|
|
10
|
+
import { guardSealedMove } from "../zeroAccess/sealedMoves.js";
|
|
11
|
+
import { protectedRecordWrite } from "../zeroAccess/sealedRecords.js";
|
|
10
12
|
|
|
11
13
|
export type TaskPriority = "low" | "normal" | "high";
|
|
12
14
|
|
|
@@ -31,6 +33,9 @@ export interface Task {
|
|
|
31
33
|
myDay?: boolean;
|
|
32
34
|
/** The uid of the user this task is assigned to, if any. */
|
|
33
35
|
assignedTo?: string;
|
|
36
|
+
/** The title and notes sealed to the mailbox's zero-access key while it protects tasks (`title` is then a placeholder) - opened with
|
|
37
|
+
* `openSealedRecords()`. Server-written. */
|
|
38
|
+
sealedFields?: Record<string, string> | null;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
/** `client`, given by every function below, is an explicit `ApiClient` from `createApiClient()` (e.g. one
|
|
@@ -78,13 +83,22 @@ export interface UpdateTaskInput {
|
|
|
78
83
|
taskListUid?: string;
|
|
79
84
|
myDay?: boolean;
|
|
80
85
|
assignedTo?: string;
|
|
86
|
+
/** A move to another mailbox, with `folderUid` there - refused for a protected task (`SealedMoveError`). */
|
|
87
|
+
mailboxUid?: string;
|
|
88
|
+
folderUid?: string;
|
|
81
89
|
}
|
|
82
90
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
/** Saves `input`. One moving the task to another mailbox (`mailboxUid`) is refused with `SealedMoveError` when the task is protected -
|
|
92
|
+
* before sending anything when `current` (the task as this device has it) says so, else from the server's refusal. With `current`, a
|
|
93
|
+
* protected task's sealed fields go only when they changed from it, and an emptied one is named in `clearedFields` when `current` was
|
|
94
|
+
* opened here (`protectedRecordWrite()`). */
|
|
95
|
+
export function updateTask(input: UpdateTaskInput, client?: ApiClient, current?: Task): Promise<Task> {
|
|
96
|
+
return guardSealedMove(current, input.mailboxUid, () =>
|
|
97
|
+
withClient<Task>(client, `/mail/tasks/${encodeURIComponent(input.uid)}`, {
|
|
98
|
+
method: "PUT",
|
|
99
|
+
body: JSON.stringify(protectedRecordWrite(current, input)),
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
88
102
|
}
|
|
89
103
|
|
|
90
104
|
/** Toggles a task's `completed` flag in place — same pattern as `mailApi.ts`'s `setMessageRead`. */
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* What kind of client this is, for work that should prefer one over another - the search index's week compaction, which a phone
|
|
7
|
+
* should leave to a computer (restapi `specs/search.md` §8 *Who compacts*).
|
|
8
|
+
*
|
|
9
|
+
* - `"desktop"`: a native app on a computer (`tauri-client`, `electron-client`), which installs `window.rapidmx`.
|
|
10
|
+
* - `"mobile"`: a native app on a phone or tablet (the host sets `window.rapidmx.formFactor: "mobile"`), or a phone or tablet browser.
|
|
11
|
+
* - `"web"`: any other browser.
|
|
12
|
+
*/
|
|
13
|
+
export type ClientPlatform = "desktop" | "web" | "mobile";
|
|
14
|
+
|
|
15
|
+
interface NavigatorHints {
|
|
16
|
+
userAgent?: string;
|
|
17
|
+
userAgentData?: { mobile?: boolean };
|
|
18
|
+
connection?: { type?: string; saveData?: boolean };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MOBILE_USER_AGENT = /Android|iPhone|iPad|iPod|Mobile/i;
|
|
22
|
+
|
|
23
|
+
function navigatorHints(): NavigatorHints | undefined {
|
|
24
|
+
return typeof navigator === "undefined" ? undefined : navigator;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether this browser runs on a phone or tablet, by its User-Agent Client Hints, else its user agent string. */
|
|
28
|
+
function isMobileBrowser(): boolean {
|
|
29
|
+
const hints = navigatorHints();
|
|
30
|
+
if (typeof hints?.userAgentData?.mobile === "boolean") {
|
|
31
|
+
return hints.userAgentData.mobile;
|
|
32
|
+
}
|
|
33
|
+
return MOBILE_USER_AGENT.test(hints?.userAgent ?? "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** This client's platform (see the module comment). */
|
|
37
|
+
export function clientPlatform(): ClientPlatform {
|
|
38
|
+
const host = typeof window === "undefined" ? undefined : (window as { rapidmx?: { formFactor?: string } }).rapidmx;
|
|
39
|
+
if (host) {
|
|
40
|
+
return host.formFactor === "mobile" ? "mobile" : "desktop";
|
|
41
|
+
}
|
|
42
|
+
return isMobileBrowser() ? "mobile" : "web";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether the connection is one to spare: cellular, or the user asked to save data (Network Information API, where the browser
|
|
46
|
+
* has it - absent, the connection is taken as unconstrained). */
|
|
47
|
+
export function isConstrainedConnection(): boolean {
|
|
48
|
+
const connection = navigatorHints()?.connection;
|
|
49
|
+
return connection?.saveData === true || connection?.type === "cellular";
|
|
50
|
+
}
|