Skip to content

Commit 272f293

Browse files
authored
Optional client encryption (silverbulletmd#1640)
Implements silverbulletmd#1268
1 parent cd6f4e9 commit 272f293

File tree

21 files changed

+738
-119
lines changed

21 files changed

+738
-119
lines changed

client/boot.ts

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { safeRun } from "@silverbulletmd/silverbullet/lib/async";
1+
import { race, safeRun, sleep } from "@silverbulletmd/silverbullet/lib/async";
22
import {
33
notAuthenticatedError,
44
offlineError,
@@ -11,8 +11,9 @@ import {
1111
flushCachesAndUnregisterServiceWorker,
1212
} from "./service_worker/util.ts";
1313
import "./lib/polyfills.ts";
14-
import type { BootConfig } from "./types/ui.ts";
14+
import type { BootConfig, ServiceWorkerTargetMessage } from "./types/ui.ts";
1515
import { BoxProxy } from "./lib/box_proxy.ts";
16+
import { importKey } from "@silverbulletmd/silverbullet/lib/crypto";
1617

1718
const logger = initLogger("[Client]");
1819

@@ -50,6 +51,57 @@ safeRun(async () => {
5051
return;
5152
}
5253

54+
let encryptionKey: CryptoKey | undefined;
55+
// If client encryption is enabled (from auth page) AND the server signals it
56+
if (
57+
localStorage.getItem("enableEncryption") &&
58+
bootConfig?.enableClientEncryption
59+
) {
60+
// Init encryption
61+
console.log("Initializing encryption");
62+
const swController = navigator.serviceWorker.controller;
63+
if (swController) {
64+
// Service is already running, let's see if has an encryption key for me
65+
console.log(
66+
"Service worker already running, querying it for an encryption key",
67+
);
68+
swController.postMessage(
69+
{ type: "get-encryption-key" } as ServiceWorkerTargetMessage,
70+
);
71+
await race([
72+
new Promise<void>((resolve) => {
73+
function keyListener(e: any) {
74+
if (e.data.type === "encryption-key") {
75+
navigator.serviceWorker.removeEventListener(
76+
"message",
77+
keyListener,
78+
);
79+
importKey(e.data.key).then((key) => {
80+
encryptionKey = key;
81+
resolve();
82+
});
83+
}
84+
}
85+
navigator.serviceWorker.addEventListener("message", keyListener);
86+
}),
87+
sleep(200),
88+
]);
89+
if (!encryptionKey) {
90+
// No encryption key, redirecting to the auth page
91+
console.warn("Not authenticated, redirecting to auth page");
92+
location.href = ".auth";
93+
throw new Error("Not authenticated");
94+
}
95+
} else {
96+
// No service worker, no encryption key, redirecting to the auth page
97+
console.warn("Not authenticated, redirecting to auth page");
98+
location.href = ".auth";
99+
throw new Error("Not authenticated");
100+
}
101+
} else {
102+
bootConfig!.enableClientEncryption = false;
103+
}
104+
53105
await augmentBootConfig(bootConfig!, config!);
54106

55107
// Update the browser URL to no longer contain the query parameters using pushState
@@ -68,7 +120,7 @@ safeRun(async () => {
68120
let lastStartNotification = 0;
69121
navigator.serviceWorker.addEventListener("message", (event) => {
70122
if (event.data.type === "service-worker-started") {
71-
// Service worker started, let's make sure it the current config
123+
// Service worker started, let's make sure it has the current config
72124
console.log(
73125
"Got notified that service worker has just started, sending config",
74126
bootConfig,
@@ -88,7 +140,7 @@ safeRun(async () => {
88140
if (startNotificationCount > 2) {
89141
// This is not normal. Safari sometimes gets stuck on a database connection if the service worker is updated which means it cannot boot properly
90142
// the only know fix is to quit the browser and restart it
91-
alert(
143+
console.warn(
92144
"Something is wrong with the sync engine, please quit your browser and restart it.",
93145
);
94146
}
@@ -134,13 +186,6 @@ safeRun(async () => {
134186
}
135187
});
136188
});
137-
138-
// // Handle service worker controlled changes (when a new service worker takes over)
139-
// navigator.serviceWorker.addEventListener("controllerchange", async () => {
140-
// console.log(
141-
// "New service worker activated!",
142-
// );
143-
// });
144189
} else {
145190
console.info("Service worker disabled.");
146191
}
@@ -157,7 +202,7 @@ safeRun(async () => {
157202
// @ts-ignore: on purpose
158203
globalThis.client = client;
159204
clientProxy.setTarget(client);
160-
await client.init();
205+
await client.init(encryptionKey);
161206
if (navigator.serviceWorker) {
162207
navigator.serviceWorker.addEventListener("message", (event) => {
163208
client.handleServiceWorkerMessage(event.data);
@@ -237,7 +282,7 @@ async function cachedFetch(path: string): Promise<string> {
237282
}
238283
const redirectHeader = response.headers.get("location");
239284
if (redirectHeader) {
240-
alert(
285+
console.info(
241286
"Received an (authentication) redirect, redirecting to URL: " +
242287
redirectHeader,
243288
);

client/client.ts

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ import type { StyleObject } from "../plugs/index/style.ts";
3939
import { jitter, throttle } from "@silverbulletmd/silverbullet/lib/async";
4040
import { PlugSpacePrimitives } from "./spaces/plug_space_primitives.ts";
4141
import { EventedSpacePrimitives } from "./spaces/evented_space_primitives.ts";
42-
import { simpleHash } from "@silverbulletmd/silverbullet/lib/crypto";
4342
import { HttpSpacePrimitives } from "./spaces/http_space_primitives.ts";
4443
import {
4544
encodePageURI,
@@ -79,6 +78,9 @@ import {
7978
offlineError,
8079
} from "@silverbulletmd/silverbullet/constants";
8180
import { Augmenter } from "./data/data_augmenter.ts";
81+
import { EncryptedKvPrimitives } from "./data/encrypted_kv_primitives.ts";
82+
import type { KvPrimitives } from "./data/kv_primitives.ts";
83+
import { deriveDbName } from "@silverbulletmd/silverbullet/lib/crypto";
8284

8385
const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/;
8486

@@ -92,7 +94,6 @@ declare global {
9294
}
9395

9496
type WidgetCacheItem = {
95-
height: number;
9697
html: string;
9798
block?: boolean;
9899
copyContent?: string;
@@ -138,7 +139,6 @@ export class Client {
138139
// Set to true once the system is ready (plugs loaded)
139140
public systemReady: boolean = false;
140141
private pageNavigator!: PathPageNavigator;
141-
private dbName: string;
142142
private onLoadRef: Ref;
143143
// Progress circle handling
144144
private progressTimeout?: number;
@@ -150,7 +150,7 @@ export class Client {
150150
console.error,
151151
);
152152
}, 2000);
153-
private widgetHeightCache = new LimitedMap<number>(100); // bodytext -> height
153+
private widgetHeightCache = new LimitedMap<number>(1000); // bodytext -> height
154154
debouncedWidgetHeightCacheFlush = throttle(() => {
155155
this.ds.set(
156156
["cache", "widgetHeight"],
@@ -167,11 +167,6 @@ export class Client {
167167
readonly config: Config,
168168
) {
169169
this.eventHook = new EventHook(this.config);
170-
// Generate a semi-unique prefix for the database so not to reuse databases for different space paths
171-
this.dbName = "" +
172-
simpleHash(
173-
`${bootConfig.spaceFolderPath}:${document.baseURI.replace(/\/*$/, "")}`,
174-
) + "_data";
175170
// The third case should only ever happen when the user provides an invalid index env variable
176171
this.onLoadRef = parseRefFromURI() || this.getIndexRef();
177172
}
@@ -180,10 +175,28 @@ export class Client {
180175
* Initialize the client
181176
* This is a separated from the constructor to allow for async initialization
182177
*/
183-
async init() {
178+
async init(encryptionKey?: CryptoKey) {
179+
const dbName = await deriveDbName(
180+
"data",
181+
this.bootConfig.spaceFolderPath,
182+
document.baseURI.replace(/\/$/, ""),
183+
encryptionKey,
184+
);
184185
// Setup the KV (database)
185-
const kvPrimitives = new IndexedDBKvPrimitives(this.dbName);
186-
await kvPrimitives.init();
186+
let kvPrimitives: KvPrimitives = new IndexedDBKvPrimitives(dbName);
187+
await (kvPrimitives as IndexedDBKvPrimitives).init();
188+
189+
console.log("Using IndexedDB database", dbName);
190+
191+
// See if we need to encrypt this
192+
if (encryptionKey) {
193+
kvPrimitives = new EncryptedKvPrimitives(
194+
kvPrimitives,
195+
encryptionKey,
196+
);
197+
await (kvPrimitives as EncryptedKvPrimitives).init();
198+
console.log("Enabled client-side encryption");
199+
}
187200
// Wrap it in a datastore
188201
this.ds = new DataStore(kvPrimitives);
189202

@@ -1312,7 +1325,7 @@ export class Client {
13121325
"cache",
13131326
"widgetHeight",
13141327
], ["cache", "widgets"]]);
1315-
this.widgetHeightCache = new LimitedMap(100, widgetHeightCache || {});
1328+
this.widgetHeightCache = new LimitedMap(1000, widgetHeightCache || {});
13161329
this.widgetCache = new LimitedMap(100, widgetCache || {});
13171330
}
13181331

client/codemirror/lua_widget.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,7 @@ export class LuaWidget extends WidgetType {
6060
}
6161

6262
override get estimatedHeight(): number {
63-
const cacheItem = this.client.getWidgetCache(this.cacheKey);
64-
return cacheItem ? cacheItem.height : -1;
63+
return this.client.getCachedWidgetHeight(this.cacheKey);
6564
}
6665

6766
toDOM(): HTMLElement {
@@ -96,8 +95,9 @@ export class LuaWidget extends WidgetType {
9695
div.innerHTML = "";
9796
this.client.setWidgetCache(
9897
this.cacheKey,
99-
{ height: div.clientHeight, html: "", block: false },
98+
{ html: "", block: false },
10099
);
100+
this.client.setCachedWidgetHeight(this.cacheKey, div.clientHeight);
101101
return;
102102
}
103103
widgetContent = { markdown: "nil", _isWidget: true };
@@ -159,8 +159,9 @@ export class LuaWidget extends WidgetType {
159159
div.innerHTML = "";
160160
this.client.setWidgetCache(
161161
this.cacheKey,
162-
{ height: div.clientHeight, html: "", block: false },
162+
{ html: "", block: false },
163163
);
164+
this.client.setCachedWidgetHeight(this.cacheKey, div.clientHeight);
164165
return;
165166
}
166167

@@ -207,12 +208,12 @@ export class LuaWidget extends WidgetType {
207208
this.client.setWidgetCache(
208209
this.cacheKey,
209210
{
210-
height: div.offsetHeight,
211211
html: html?.outerHTML || "",
212212
block,
213213
copyContent: copyContent,
214214
},
215215
);
216+
this.client.setCachedWidgetHeight(this.cacheKey, div.offsetHeight);
216217
// Because of the rejiggering of the DOM, we need to do a no-op cursor move to make sure it's positioned correctly
217218
this.client.editorView.dispatch({
218219
selection: this.client.editorView.state.selection,

client/codemirror/top_bottom_panels.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ class ArrayWidget extends WidgetType {
2020
}
2121

2222
override get estimatedHeight(): number {
23-
const cacheItem = this.client.getWidgetCache(this.cacheKey);
24-
return cacheItem ? cacheItem.height : -1;
23+
return this.client.getCachedWidgetHeight(this.cacheKey);
2524
}
2625

2726
toDOM(): HTMLElement {
@@ -91,13 +90,13 @@ class ArrayWidget extends WidgetType {
9190

9291
div.replaceChildren(...renderedWidgets);
9392

93+
this.client.setWidgetCache(this.cacheKey, {
94+
block: true,
95+
html: div.innerHTML,
96+
});
9497
// Wait for the clientHeight to settle
9598
setTimeout(() => {
96-
this.client.setWidgetCache(this.cacheKey, {
97-
height: div.clientHeight,
98-
block: true,
99-
html: div.innerHTML,
100-
});
99+
this.client.setCachedWidgetHeight(this.cacheKey, div.clientHeight);
101100
});
102101
}
103102

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { assert, assertEquals } from "@std/assert";
2+
import { EncryptedKvPrimitives } from "./encrypted_kv_primitives.ts";
3+
import { MemoryKvPrimitives } from "./memory_kv_primitives.ts";
4+
import { deriveCTRKeyFromPassword } from "@silverbulletmd/silverbullet/lib/crypto";
5+
6+
Deno.test("Test Encrypted KV Primitives", async () => {
7+
const memoryKv = new MemoryKvPrimitives();
8+
const salt = new Uint8Array(32);
9+
const key = await deriveCTRKeyFromPassword("test", salt);
10+
const kv = new EncryptedKvPrimitives(memoryKv, key);
11+
await kv.init();
12+
13+
// Store a basic key
14+
await kv.batchSet([{ key: ["key"], value: 10 }]);
15+
const [value] = await kv.batchGet([["key"]]);
16+
assertEquals(value, 10);
17+
18+
// Store a binary blob
19+
const blob = new Uint8Array([1, 2, 3, 4, 5]);
20+
await kv.batchSet([{ key: ["blob"], value: blob }]);
21+
const [blobValue] = await kv.batchGet([["blob"]]);
22+
assertEquals(blobValue, blob);
23+
24+
// Store a nested JSON and blob structure
25+
const nested = {
26+
json: { a: 1, b: 2 },
27+
blob: new Uint8Array([6, 7, 8, 9, 10]),
28+
};
29+
await kv.batchSet([{ key: ["nested"], value: nested }]);
30+
const [nestedValue] = await kv.batchGet([["nested"]]);
31+
assertEquals(nestedValue, nested);
32+
33+
// Put a few objects with a person prefix
34+
await kv.batchSet([
35+
{ key: ["person", "alice"], value: { name: "Alice", age: 30 } },
36+
{ key: ["person", "bob"], value: { name: "Bob", age: 25 } },
37+
]);
38+
39+
// Then query based on the prefix
40+
let counter = 0;
41+
for await (const { key, value } of kv.query({ prefix: ["person"] })) {
42+
assertEquals(key[0], "person");
43+
assert(key[1] === "alice" || key[1] === "bob");
44+
assert(value.age);
45+
counter++;
46+
}
47+
assertEquals(counter, 2);
48+
49+
// Delete something
50+
await kv.batchDelete([["person", "alice"]]);
51+
// Check it's gone
52+
const [deletedValue] = await kv.batchGet([["person", "alice"]]);
53+
assertEquals(deletedValue, undefined);
54+
55+
console.log(memoryKv);
56+
57+
// Clear
58+
await kv.clear();
59+
counter = 0;
60+
for await (const _ of kv.query({ prefix: ["person"] })) {
61+
counter++;
62+
}
63+
assertEquals(counter, 0);
64+
});

0 commit comments

Comments
 (0)