@rapidmx/web-client 0.3.1 → 0.5.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/admin/audit-log/index.tsx +35 -8
- package/apps/admin/branding/index.tsx +39 -404
- package/apps/admin/data-requests/index.tsx +480 -485
- package/apps/admin/distribution-lists/[uid].tsx +19 -1
- package/apps/admin/domains/[uid].tsx +92 -259
- package/apps/admin/encryption-policy/index.tsx +19 -0
- package/apps/admin/escrow-scopes/[uid].tsx +348 -237
- package/apps/admin/escrow-scopes/new/index.tsx +8 -2
- package/apps/admin/index.tsx +118 -82
- package/apps/admin/ingest-queue/index.tsx +44 -5
- package/apps/admin/mailbox-policy/index.tsx +19 -0
- package/apps/admin/mailboxes/[uid].tsx +211 -184
- package/apps/admin/mailboxes/new/index.tsx +28 -291
- package/apps/admin/plugins/index.tsx +15 -0
- package/apps/admin/quarantine/index.tsx +46 -14
- package/apps/admin/retention-policy/index.tsx +39 -132
- package/apps/admin/setup/index.tsx +15 -0
- package/apps/admin/transport-rules/[uid].tsx +30 -1
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
- package/apps/admin/transport-rules/index.tsx +24 -6
- package/apps/admin/transport-rules/new/index.tsx +30 -1
- package/apps/escrow/_layout.tsx +2 -3
- package/apps/escrow/audit-log/index.tsx +196 -168
- package/apps/escrow/matters/[uid].tsx +621 -521
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
- package/apps/shared/components/admin/layout/AdminShell.tsx +251 -210
- package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
- package/apps/shared/components/admin/settings/BrandingForm.tsx +385 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +187 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +112 -0
- package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +308 -0
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +152 -0
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -0
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -0
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +290 -0
- package/apps/shared/components/admin/setup/SetupWizard.tsx +416 -0
- package/apps/shared/components/admin/signOut.ts +79 -0
- package/apps/shared/components/admin/usePagedList.tsx +129 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
- package/apps/shared/components/calendar/EventModal.tsx +717 -513
- package/apps/shared/components/calendar/MonthView.tsx +173 -139
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
- package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
- package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
- package/apps/shared/components/calendar/allDay.ts +124 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
- package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
- package/apps/shared/components/contacts/ContactForm.tsx +377 -314
- package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
- package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
- package/apps/shared/components/contacts/contactKeys.ts +78 -0
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
- package/apps/shared/components/layout/AppShell.tsx +270 -169
- package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +150 -26
- package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
- package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
- package/apps/shared/components/mail/MessageDetailPane.tsx +799 -76
- package/apps/shared/components/mail/compose/ComposeContext.tsx +26 -16
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1652 -765
- package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +418 -302
- package/apps/shared/components/mail/pinnedSigners.ts +124 -0
- package/apps/shared/components/mail/verificationSeals.ts +125 -0
- package/apps/shared/components/mail/writableMailboxes.ts +101 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
- package/apps/shared/mail/listAllPages.ts +39 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +98 -0
- package/apps/shared/search/localIndexBlockCipher.ts +83 -0
- package/apps/shared/search/localIndexBuilder.ts +481 -0
- package/apps/shared/search/localIndexKey.ts +27 -0
- package/apps/shared/search/localIndexRpcClient.ts +316 -0
- package/apps/shared/search/localIndexSchema.ts +236 -0
- package/apps/shared/search/localIndexSizePreference.ts +88 -0
- package/apps/shared/search/localIndexStorage.ts +98 -0
- package/apps/shared/search/localIndexVFS.ts +266 -0
- package/apps/shared/search/localIndexWorker.ts +926 -0
- package/apps/shared/search/searchTier2.ts +79 -0
- package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
- package/apps/www/calendar/index.tsx +438 -407
- package/apps/www/contacts/[uid].tsx +21 -2
- package/apps/www/contacts/index.tsx +148 -17
- package/apps/www/index.tsx +1314 -486
- package/apps/www/messages/[uid].tsx +23 -7
- package/apps/www/settings/auto-reply/index.tsx +134 -129
- package/apps/www/settings/booking-types/[uid].tsx +306 -300
- package/apps/www/settings/encryption/index.tsx +800 -215
- package/apps/www/settings/filters/[uid].tsx +171 -155
- package/apps/www/settings/filters/new/index.tsx +8 -1
- package/apps/www/settings/focused-inbox/index.tsx +150 -148
- package/apps/www/settings/privacy/index.tsx +494 -436
- package/apps/www/settings/read-receipts/index.tsx +148 -125
- package/apps/www/settings/sharing/index.tsx +274 -0
- package/apps/www/tasks/index.tsx +638 -530
- package/dist/apps/admin/audit-log/index.js +38 -8
- package/dist/apps/admin/branding/index.js +4 -98
- package/dist/apps/admin/data-requests/index.js +52 -72
- package/dist/apps/admin/distribution-lists/[uid].js +1 -1
- package/dist/apps/admin/domains/[uid].js +5 -68
- package/dist/apps/admin/encryption-policy/index.js +8 -0
- package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
- package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
- package/dist/apps/admin/index.js +17 -1
- package/dist/apps/admin/ingest-queue/index.js +24 -6
- package/dist/apps/admin/mailbox-policy/index.js +8 -0
- package/dist/apps/admin/mailboxes/[uid].js +20 -4
- package/dist/apps/admin/mailboxes/new/index.js +4 -89
- package/dist/apps/admin/plugins/index.js +6 -0
- package/dist/apps/admin/quarantine/index.js +20 -11
- package/dist/apps/admin/retention-policy/index.js +3 -36
- package/dist/apps/admin/setup/index.js +6 -0
- package/dist/apps/admin/transport-rules/[uid].js +18 -2
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
- package/dist/apps/admin/transport-rules/index.js +22 -6
- package/dist/apps/admin/transport-rules/new/index.js +18 -2
- package/dist/apps/escrow/_layout.js +3 -3
- package/dist/apps/escrow/audit-log/index.js +26 -1
- package/dist/apps/escrow/matters/[uid].js +77 -42
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
- package/dist/apps/shared/components/admin/layout/AdminShell.js +37 -4
- package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +116 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +83 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +62 -0
- package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +112 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +74 -0
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +572 -0
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +73 -0
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +147 -0
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +208 -0
- package/dist/apps/shared/components/admin/signOut.js +75 -0
- package/dist/apps/shared/components/admin/usePagedList.js +99 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
- package/dist/apps/shared/components/calendar/EventModal.js +154 -21
- package/dist/apps/shared/components/calendar/MonthView.js +12 -5
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
- package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
- package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
- package/dist/apps/shared/components/calendar/allDay.js +111 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
- package/dist/apps/shared/components/contacts/ContactForm.js +45 -12
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
- package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
- package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
- package/dist/apps/shared/components/layout/AppShell.js +100 -8
- package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +63 -14
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
- package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +136 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
- package/dist/apps/shared/components/mail/MessageDetailPane.js +464 -34
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +807 -116
- package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +116 -41
- package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
- package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
- package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
- package/dist/apps/shared/mail/listAllPages.js +25 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +80 -0
- package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
- package/dist/apps/shared/search/localIndexBuilder.js +408 -0
- package/dist/apps/shared/search/localIndexKey.js +25 -0
- package/dist/apps/shared/search/localIndexRpcClient.js +241 -0
- package/dist/apps/shared/search/localIndexSchema.js +185 -0
- package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
- package/dist/apps/shared/search/localIndexStorage.js +86 -0
- package/dist/apps/shared/search/localIndexVFS.js +258 -0
- package/dist/apps/shared/search/localIndexWorker.js +687 -0
- package/dist/apps/shared/search/searchTier2.js +38 -0
- package/dist/apps/www/calendar/index.js +38 -18
- package/dist/apps/www/contacts/[uid].js +13 -2
- package/dist/apps/www/contacts/index.js +94 -15
- package/dist/apps/www/index.js +770 -137
- package/dist/apps/www/messages/[uid].js +23 -7
- package/dist/apps/www/settings/auto-reply/index.js +9 -4
- package/dist/apps/www/settings/booking-types/[uid].js +17 -12
- package/dist/apps/www/settings/encryption/index.js +541 -96
- package/dist/apps/www/settings/filters/[uid].js +22 -7
- package/dist/apps/www/settings/filters/new/index.js +8 -2
- package/dist/apps/www/settings/focused-inbox/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.js +57 -22
- package/dist/apps/www/settings/read-receipts/index.js +10 -3
- package/dist/apps/www/settings/sharing/index.js +124 -0
- package/dist/apps/www/tasks/index.js +76 -14
- package/package.json +3 -2
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The Tier 2 local index's on-disk naming and bulk-removal helpers, shared by the Worker
|
|
7
|
+
* (`localIndexWorker.ts`) and the main thread (`localIndexRpcClient.ts`). Deliberately free of any
|
|
8
|
+
* wa-sqlite import so the main thread can use it without pulling the WASM build into its own bundle.
|
|
9
|
+
*
|
|
10
|
+
* Every mailbox's index lives in its own top-level OPFS directory named `rapidmx-localsearch-<mailboxUid>`
|
|
11
|
+
* (`AccessHandlePoolVFS` creates it from the VFS name). Enumerating that prefix is how sign-out destroys
|
|
12
|
+
* *every* index on this device - including ones for mailboxes this page load never opened, which the
|
|
13
|
+
* RPC client's own session-scoped bookkeeping can't know about - and how a sign-in prunes indexes left
|
|
14
|
+
* behind for mailboxes the current user can no longer access.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const LOCAL_INDEX_POOL_PREFIX = "rapidmx-localsearch-";
|
|
18
|
+
|
|
19
|
+
/** The OPFS directory name (and `EncryptingVFS` name) a mailbox's index lives under. */
|
|
20
|
+
export function poolNameFor(mailboxUid: string): string {
|
|
21
|
+
return `${LOCAL_INDEX_POOL_PREFIX}${mailboxUid}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Inverse of `poolNameFor()`; `undefined` for any OPFS entry that isn't a local index directory. */
|
|
25
|
+
export function mailboxUidFromPoolName(name: string): string | undefined {
|
|
26
|
+
return name.startsWith(LOCAL_INDEX_POOL_PREFIX) && name.length > LOCAL_INDEX_POOL_PREFIX.length
|
|
27
|
+
? name.slice(LOCAL_INDEX_POOL_PREFIX.length)
|
|
28
|
+
: undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `navigator.storage.getDirectory()`, or `undefined` where OPFS isn't available at all (older browsers,
|
|
32
|
+
* jsdom/node) - there is nothing on disk to remove in that case. */
|
|
33
|
+
async function getOpfsRoot(): Promise<FileSystemDirectoryHandle | undefined> {
|
|
34
|
+
const storage = typeof navigator === "undefined" ? undefined : navigator.storage;
|
|
35
|
+
if (!storage?.getDirectory) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
return storage.getDirectory();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isNotFound(err: unknown): boolean {
|
|
42
|
+
return (err as { name?: string } | undefined)?.name === "NotFoundError";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Removes one mailbox's index directory. Resolves quietly when it doesn't exist; rejects for any other
|
|
46
|
+
* failure (most commonly `NoModificationAllowedError` - another tab still holds its access handles). */
|
|
47
|
+
export async function removeLocalIndexDirectory(mailboxUid: string): Promise<void> {
|
|
48
|
+
const root = await getOpfsRoot();
|
|
49
|
+
if (!root) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
await root.removeEntry(poolNameFor(mailboxUid), { recursive: true });
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (!isNotFound(err)) {
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface LocalIndexRemovalResult {
|
|
62
|
+
removed: string[];
|
|
63
|
+
/** Mailbox uids whose directory could not be removed (e.g. still open in another tab). */
|
|
64
|
+
failed: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Every mailbox uid that has a local index directory on this origin. */
|
|
68
|
+
export async function listLocalIndexMailboxUids(): Promise<string[]> {
|
|
69
|
+
const root = await getOpfsRoot();
|
|
70
|
+
if (!root) {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
const mailboxUids: string[] = [];
|
|
74
|
+
// `keys()` is part of the OPFS spec and every browser that has `getDirectory()` at all, but isn't in
|
|
75
|
+
// this TypeScript version's DOM lib yet.
|
|
76
|
+
for await (const name of (root as unknown as { keys(): AsyncIterable<string> }).keys()) {
|
|
77
|
+
const mailboxUid = mailboxUidFromPoolName(name);
|
|
78
|
+
if (mailboxUid) {
|
|
79
|
+
mailboxUids.push(mailboxUid);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return mailboxUids;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Removes every local index directory on this origin except those whose mailbox uid is in `keep`. */
|
|
86
|
+
export async function removeLocalIndexDirectories(keep: ReadonlySet<string> = new Set()): Promise<LocalIndexRemovalResult> {
|
|
87
|
+
const result: LocalIndexRemovalResult = { removed: [], failed: [] };
|
|
88
|
+
const mailboxUids = (await listLocalIndexMailboxUids()).filter((mailboxUid) => !keep.has(mailboxUid));
|
|
89
|
+
for (const mailboxUid of mailboxUids) {
|
|
90
|
+
try {
|
|
91
|
+
await removeLocalIndexDirectory(mailboxUid);
|
|
92
|
+
result.removed.push(mailboxUid);
|
|
93
|
+
} catch {
|
|
94
|
+
result.failed.push(mailboxUid);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* `EncryptingVFS` - the Tier 2 local index's at-rest encryption layer (`specs/search.md` §11
|
|
7
|
+
* "Persistence and protection", §13 "Encryption at Rest": "no official SQLCipher WASM build exists...
|
|
8
|
+
* encryption at rest requires a custom VFS that encrypts pages before they reach OPFS").
|
|
9
|
+
*
|
|
10
|
+
* Wraps (composes, does not subclass - `AccessHandlePoolVFS`'s own file-table state is private `#`
|
|
11
|
+
* fields) an `AccessHandlePoolVFS` instance and delegates every `FacadeVFS` method straight through
|
|
12
|
+
* except `jRead`/`jWrite`, which it intercepts to decrypt/encrypt pages.
|
|
13
|
+
*
|
|
14
|
+
* ## Page layout
|
|
15
|
+
*
|
|
16
|
+
* SQLite is opened with a fixed 4096-byte page size (`PRAGMA page_size=4096` - set by
|
|
17
|
+
* `localIndexWorker.ts` before the first write, since SQLite fixes a database's page size on creation).
|
|
18
|
+
* Each *logical* 4096-byte page is stored *physically* as `nonce(12) || AES-256-GCM(ciphertext(4096) ||
|
|
19
|
+
* tag(16))` = 4124 bytes, at a remapped offset (`physicalOffset(page) = page * 4124`). This block size
|
|
20
|
+
* is this class's own fixed constant, independent of whatever byte range a given `jRead`/`jWrite` call
|
|
21
|
+
* actually requests - reads/writes are handled generically over whichever physical blocks the requested
|
|
22
|
+
* logical range overlaps (including a read-modify-write for a sub-block write), not by assuming SQLite
|
|
23
|
+
* only ever issues page-aligned, page-sized I/O. That assumption holds for the *main database file* once
|
|
24
|
+
* its page size is fixed, but handling the general case costs little and removes the need to rely on it.
|
|
25
|
+
*
|
|
26
|
+
* **A fresh random nonce is generated on every write, never reused or derived from a counter** - the
|
|
27
|
+
* simplest way to make an AES-GCM (key, nonce) pair never repeat across different plaintexts, which is
|
|
28
|
+
* the one hard requirement GCM has. The nonce travels with its block, so decryption never needs any
|
|
29
|
+
* external state (a lost/corrupted counter, a persisted salt) to reconstruct it.
|
|
30
|
+
*
|
|
31
|
+
* **AAD binds each block to its logical filename and page index** (not just its own ciphertext) - GCM
|
|
32
|
+
* authenticates a block's own content but not its *position*; without this, an attacker able to write
|
|
33
|
+
* directly into this origin's OPFS storage (outside this codebase's own threat model per spec §4, which
|
|
34
|
+
* excludes a compromised client, but cheap to close off anyway) could silently swap two blocks and each
|
|
35
|
+
* would still decrypt "successfully" on its own, corrupting data instead of failing loudly. Binding to
|
|
36
|
+
* position turns a swap into a caught decryption failure - see `PageCorruptedError` below - the same
|
|
37
|
+
* "invalidate and rebuild" path a schema-version mismatch already takes (spec §11 "Invalidation").
|
|
38
|
+
*
|
|
39
|
+
* **No WAL, no rollback journal.** `localIndexWorker.ts` opens the database with `journal_mode=OFF`. A
|
|
40
|
+
* page cipher for the main database file is one encryption surface; WAL frames (their own header +
|
|
41
|
+
* checksum format) and rollback-journal records (their own, different record format) would each be a
|
|
42
|
+
* *second* one. This index has no durability requirement to justify that cost - spec §11 already
|
|
43
|
+
* requires discarding and rebuilding it on corruption, schema change, key rotation, or platform storage
|
|
44
|
+
* eviction, and eviction "MUST NOT block search" - so an interrupted write in the worst case is just
|
|
45
|
+
* caught by the same GCM-auth-failure -> rebuild path as any other corruption, never partial/torn state
|
|
46
|
+
* silently trusted.
|
|
47
|
+
*
|
|
48
|
+
* Every `jRead`/`jWrite` here is `async` (declared `async` specifically so `FacadeVFS.hasAsyncMethod()`
|
|
49
|
+
* detects it via `instanceof AsyncFunction` and awaits it) because `crypto.subtle.encrypt`/`decrypt` has
|
|
50
|
+
* no synchronous form in a browser - this is *why* `localIndexWorker.ts` boots the Asyncify SQLite build
|
|
51
|
+
* (`dist/wa-sqlite-async.mjs`), not the plain synchronous one `AccessHandlePoolVFS`'s own doc comment
|
|
52
|
+
* says it's designed for: that claim is about `AccessHandlePoolVFS`'s *own* methods (real synchronous
|
|
53
|
+
* OPFS access-handle calls), which stay synchronous and work fine wrapped underneath an async outer VFS
|
|
54
|
+
* on an Asyncify build - the build choice is driven by this class's needs, not the inner VFS's.
|
|
55
|
+
*/
|
|
56
|
+
import { FacadeVFS } from "@journeyapps/wa-sqlite/src/FacadeVFS.js";
|
|
57
|
+
import { AccessHandlePoolVFS } from "@journeyapps/wa-sqlite/src/examples/AccessHandlePoolVFS.js";
|
|
58
|
+
import * as VFS from "@journeyapps/wa-sqlite/src/VFS.js";
|
|
59
|
+
import {
|
|
60
|
+
LOGICAL_BLOCK_SIZE,
|
|
61
|
+
PHYSICAL_BLOCK_SIZE,
|
|
62
|
+
PageCorruptedError,
|
|
63
|
+
decryptBlock,
|
|
64
|
+
encryptBlock,
|
|
65
|
+
importAesGcmKey,
|
|
66
|
+
} from "./localIndexBlockCipher.js";
|
|
67
|
+
|
|
68
|
+
export { PageCorruptedError } from "./localIndexBlockCipher.js";
|
|
69
|
+
|
|
70
|
+
export class EncryptingVFS extends FacadeVFS {
|
|
71
|
+
#inner: AccessHandlePoolVFS;
|
|
72
|
+
#key: CryptoKey | undefined;
|
|
73
|
+
/** `jOpen`'s `filename` is stable across the life of a fileId; `fileId` itself is only valid for one
|
|
74
|
+
* open handle, never persisted - this map lets `jRead`/`jWrite` recover the stable filename an AAD
|
|
75
|
+
* needs to bind to, from the ephemeral fileId SQLite actually passes them. */
|
|
76
|
+
#filenamesByFileId = new Map<number, string>();
|
|
77
|
+
|
|
78
|
+
private constructor(name: string, module: unknown, inner: AccessHandlePoolVFS) {
|
|
79
|
+
super(name, module);
|
|
80
|
+
this.#inner = inner;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `rawKey` MUST be exactly 32 bytes (AES-256) - see `localIndexKey.ts`'s `deriveLocalIndexKey()`,
|
|
84
|
+
* the only intended source of this value. */
|
|
85
|
+
static async create(name: string, module: unknown, rawKey: Uint8Array): Promise<EncryptingVFS> {
|
|
86
|
+
const inner = await AccessHandlePoolVFS.create(name, module);
|
|
87
|
+
const vfs = new EncryptingVFS(name, module, inner);
|
|
88
|
+
vfs.#key = await importAesGcmKey(rawKey);
|
|
89
|
+
return vfs;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Releases every pooled OPFS sync access handle `AccessHandlePoolVFS` opened and holds open for its
|
|
94
|
+
* entire lifetime (not per SQLite-file-open/close - `jOpen`/`jClose` above only associate/disassociate
|
|
95
|
+
* a SQLite fileId with an already-open handle, they never open or close the handles themselves). MUST
|
|
96
|
+
* be called before creating another VFS instance against the same OPFS pool `name` - confirmed by
|
|
97
|
+
* direct reproduction: skipping this and calling `create()` again with the same `name` throws
|
|
98
|
+
* "Access Handles cannot be created if there is another open Access Handle," since the previous
|
|
99
|
+
* instance's handles are still live. `localIndexWorker.ts` calls this alongside `sqlite3.close(db)`
|
|
100
|
+
* (which closes the SQLite *connection*, a separate, shorter-lived thing from the VFS itself) whenever
|
|
101
|
+
* it tears down a mailbox's connection - on `destroy()` and in `selfTest()`'s own close/reopen check.
|
|
102
|
+
*/
|
|
103
|
+
close(): void | Promise<void> {
|
|
104
|
+
return this.#inner.close();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Every method below except jRead/jWrite is a pure passthrough to the inner (real storage) VFS -
|
|
108
|
+
// this class's only job is to sit in the read/write path.
|
|
109
|
+
jOpen(filename: string | null, pFile: number, flags: number, pOutFlags: DataView): number | Promise<number> {
|
|
110
|
+
const result = this.#inner.jOpen(filename, pFile, flags, pOutFlags);
|
|
111
|
+
this.#filenamesByFileId.set(pFile, filename ?? `(anon:${pFile})`);
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
jClose(pFile: number): number | Promise<number> {
|
|
115
|
+
this.#filenamesByFileId.delete(pFile);
|
|
116
|
+
return this.#inner.jClose(pFile);
|
|
117
|
+
}
|
|
118
|
+
jDelete(filename: string, syncDir: number): number | Promise<number> {
|
|
119
|
+
return this.#inner.jDelete(filename, syncDir);
|
|
120
|
+
}
|
|
121
|
+
jAccess(filename: string, flags: number, pResOut: DataView): number | Promise<number> {
|
|
122
|
+
return this.#inner.jAccess(filename, flags, pResOut);
|
|
123
|
+
}
|
|
124
|
+
jFullPathname(filename: string, zOut: Uint8Array): number | Promise<number> {
|
|
125
|
+
return this.#inner.jFullPathname(filename, zOut);
|
|
126
|
+
}
|
|
127
|
+
jSync(pFile: number, flags: number): number | Promise<number> {
|
|
128
|
+
return this.#inner.jSync(pFile, flags);
|
|
129
|
+
}
|
|
130
|
+
jSectorSize(pFile: number): number {
|
|
131
|
+
return LOGICAL_BLOCK_SIZE;
|
|
132
|
+
}
|
|
133
|
+
jDeviceCharacteristics(pFile: number): number {
|
|
134
|
+
return this.#inner.jDeviceCharacteristics(pFile);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Logical file size = physical size scaled back down to the logical block size - the inner VFS's
|
|
138
|
+
* own `jFileSize` reports the *physical* (post-remap) byte count, which is always an exact multiple
|
|
139
|
+
* of `PHYSICAL_BLOCK_SIZE` since every write here always fills whole physical blocks. */
|
|
140
|
+
async jFileSize(pFile: number, pSize64: DataView): Promise<number> {
|
|
141
|
+
const buf = new DataView(new ArrayBuffer(8));
|
|
142
|
+
const rc = await this.#inner.jFileSize(pFile, buf);
|
|
143
|
+
if (rc !== VFS.SQLITE_OK) return rc;
|
|
144
|
+
const physicalSize = Number(buf.getBigInt64(0, true));
|
|
145
|
+
const logicalSize = Math.floor(physicalSize / PHYSICAL_BLOCK_SIZE) * LOGICAL_BLOCK_SIZE;
|
|
146
|
+
pSize64.setBigInt64(0, BigInt(logicalSize), true);
|
|
147
|
+
return VFS.SQLITE_OK;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** `iSize` is a logical byte size - truncates to the smallest whole number of *physical* blocks that
|
|
151
|
+
* still covers it, so a partially-truncated trailing block is never left half-written. */
|
|
152
|
+
async jTruncate(pFile: number, iSize: number): Promise<number> {
|
|
153
|
+
const blocks = Math.ceil(iSize / LOGICAL_BLOCK_SIZE);
|
|
154
|
+
return this.#inner.jTruncate(pFile, blocks * PHYSICAL_BLOCK_SIZE);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Reads one physical block and decrypts it. `absent: true` means nothing has ever been written at
|
|
159
|
+
* this block (the inner VFS's own read came back short) - `plaintext` is a zero-filled logical block
|
|
160
|
+
* in that case, matching what SQLite expects for a region it has never written, and it is
|
|
161
|
+
* deliberately never handed to `crypto.subtle.decrypt()` at all (there aren't enough physical bytes
|
|
162
|
+
* present to contain a real nonce+tag).
|
|
163
|
+
*
|
|
164
|
+
* `absent` is determined *only* from the inner VFS's own return code, never inferred from whether the
|
|
165
|
+
* decrypted plaintext happens to be all-zero - a real, fully-written SQLite page is routinely
|
|
166
|
+
* all-zero (an unused/freelist page, or a page beyond a freshly created database's real content), so
|
|
167
|
+
* that content shape means nothing about whether the block is actually present.
|
|
168
|
+
*/
|
|
169
|
+
async #readBlock(pFile: number, filename: string, blockIndex: number): Promise<{ plaintext: Uint8Array; absent: boolean }> {
|
|
170
|
+
const physical = new Uint8Array(PHYSICAL_BLOCK_SIZE);
|
|
171
|
+
const rc = await this.#inner.jRead(pFile, physical, blockIndex * PHYSICAL_BLOCK_SIZE);
|
|
172
|
+
if (rc === VFS.SQLITE_IOERR_SHORT_READ) {
|
|
173
|
+
// #writeBlock() never writes fewer than PHYSICAL_BLOCK_SIZE bytes at a time, so a short read
|
|
174
|
+
// here only ever means "this block was never written," not "partially written."
|
|
175
|
+
return { plaintext: new Uint8Array(LOGICAL_BLOCK_SIZE), absent: true };
|
|
176
|
+
}
|
|
177
|
+
if (rc !== VFS.SQLITE_OK) {
|
|
178
|
+
throw new PageCorruptedError(filename, blockIndex, new Error(`inner VFS read failed: rc=${rc}`));
|
|
179
|
+
}
|
|
180
|
+
const plaintext = await decryptBlock(this.#key!, filename, blockIndex, physical);
|
|
181
|
+
return { plaintext, absent: false };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async #writeBlock(pFile: number, filename: string, blockIndex: number, plaintext: Uint8Array): Promise<number> {
|
|
185
|
+
const physical = await encryptBlock(this.#key!, filename, blockIndex, plaintext);
|
|
186
|
+
return this.#inner.jWrite(pFile, physical, blockIndex * PHYSICAL_BLOCK_SIZE);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Set once any block has failed to decrypt/authenticate (or the inner VFS failed a read/write outright)
|
|
190
|
+
* - `localIndexWorker.ts` checks this after a failed SQLite call to tell real corruption (discard and
|
|
191
|
+
* rebuild, spec §11 "Invalidation") apart from an ordinary error such as a malformed FTS5 query. */
|
|
192
|
+
corruptionDetected = false;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* `jRead`/`jWrite` MUST NOT throw: with the Asyncify build, an exception escaping an async VFS method
|
|
196
|
+
* never reaches the awaiting `sqlite3.*` call - it surfaces only as an unhandled rejection and the
|
|
197
|
+
* SQLite call hangs forever (confirmed by direct reproduction in node against the real wa-sqlite build).
|
|
198
|
+
* A thrown `PageCorruptedError` therefore used to wedge the whole connection instead of triggering a
|
|
199
|
+
* rebuild. Errors are converted to an I/O error return code here, which SQLite does propagate as a
|
|
200
|
+
* normal `SQLiteError`, and recorded on `corruptionDetected`.
|
|
201
|
+
*/
|
|
202
|
+
async jRead(pFile: number, pData: Uint8Array, iOffset: number): Promise<number> {
|
|
203
|
+
try {
|
|
204
|
+
return await this.#jReadBlocks(pFile, pData, iOffset);
|
|
205
|
+
} catch {
|
|
206
|
+
this.corruptionDetected = true;
|
|
207
|
+
return VFS.SQLITE_IOERR_READ;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async jWrite(pFile: number, pData: Uint8Array, iOffset: number): Promise<number> {
|
|
212
|
+
try {
|
|
213
|
+
return await this.#jWriteBlocks(pFile, pData, iOffset);
|
|
214
|
+
} catch {
|
|
215
|
+
this.corruptionDetected = true;
|
|
216
|
+
return VFS.SQLITE_IOERR_WRITE;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async #jReadBlocks(pFile: number, pData: Uint8Array, iOffset: number): Promise<number> {
|
|
221
|
+
const filename = this.#filenamesByFileId.get(pFile) ?? `(unknown:${pFile})`;
|
|
222
|
+
const startBlock = Math.floor(iOffset / LOGICAL_BLOCK_SIZE);
|
|
223
|
+
const endBlock = Math.floor((iOffset + pData.length - 1) / LOGICAL_BLOCK_SIZE);
|
|
224
|
+
let anyAbsent = false;
|
|
225
|
+
for (let block = startBlock; block <= endBlock; block++) {
|
|
226
|
+
const { plaintext, absent } = await this.#readBlock(pFile, filename, block);
|
|
227
|
+
const blockStart = block * LOGICAL_BLOCK_SIZE;
|
|
228
|
+
const copyStart = Math.max(iOffset, blockStart);
|
|
229
|
+
const copyEnd = Math.min(iOffset + pData.length, blockStart + LOGICAL_BLOCK_SIZE);
|
|
230
|
+
pData.set(plaintext.subarray(copyStart - blockStart, copyEnd - blockStart), copyStart - iOffset);
|
|
231
|
+
// SQLITE_IOERR_SHORT_READ is how SQLite distinguishes "nothing here yet" from "here is real
|
|
232
|
+
// (possibly zero-filled) data," e.g. when probing whether a file exists at all - see
|
|
233
|
+
// #readBlock's own doc comment on why this is tracked explicitly, not inferred from content.
|
|
234
|
+
if (absent) {
|
|
235
|
+
anyAbsent = true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return anyAbsent ? VFS.SQLITE_IOERR_SHORT_READ : VFS.SQLITE_OK;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async #jWriteBlocks(pFile: number, pData: Uint8Array, iOffset: number): Promise<number> {
|
|
242
|
+
const filename = this.#filenamesByFileId.get(pFile) ?? `(unknown:${pFile})`;
|
|
243
|
+
const startBlock = Math.floor(iOffset / LOGICAL_BLOCK_SIZE);
|
|
244
|
+
const endBlock = Math.floor((iOffset + pData.length - 1) / LOGICAL_BLOCK_SIZE);
|
|
245
|
+
for (let block = startBlock; block <= endBlock; block++) {
|
|
246
|
+
const blockStart = block * LOGICAL_BLOCK_SIZE;
|
|
247
|
+
const writeStart = Math.max(iOffset, blockStart);
|
|
248
|
+
const writeEnd = Math.min(iOffset + pData.length, blockStart + LOGICAL_BLOCK_SIZE);
|
|
249
|
+
// Whole-block write (the common case once SQLite's page size is fixed): no need to read the
|
|
250
|
+
// old block first. Anything narrower (a sub-page write, or this block only partially
|
|
251
|
+
// overlaps the requested range) needs the existing content as a base - a real
|
|
252
|
+
// read-modify-write - since the physical block is re-encrypted as a single AEAD unit.
|
|
253
|
+
const plaintext =
|
|
254
|
+
writeStart === blockStart && writeEnd === blockStart + LOGICAL_BLOCK_SIZE
|
|
255
|
+
? pData.subarray(writeStart - iOffset, writeEnd - iOffset)
|
|
256
|
+
: await this.#readBlock(pFile, filename, block).then(({ plaintext: existing }) => {
|
|
257
|
+
const merged = existing.slice();
|
|
258
|
+
merged.set(pData.subarray(writeStart - iOffset, writeEnd - iOffset), writeStart - blockStart);
|
|
259
|
+
return merged;
|
|
260
|
+
});
|
|
261
|
+
const rc = await this.#writeBlock(pFile, filename, block, plaintext);
|
|
262
|
+
if (rc !== VFS.SQLITE_OK) return rc;
|
|
263
|
+
}
|
|
264
|
+
return VFS.SQLITE_OK;
|
|
265
|
+
}
|
|
266
|
+
}
|