diff --git a/client/boot.ts b/client/boot.ts index 7360d1b6..ef06e049 100644 --- a/client/boot.ts +++ b/client/boot.ts @@ -1,4 +1,4 @@ -import { safeRun } from "@silverbulletmd/silverbullet/lib/async"; +import { race, safeRun, sleep } from "@silverbulletmd/silverbullet/lib/async"; import { notAuthenticatedError, offlineError, @@ -11,8 +11,9 @@ import { flushCachesAndUnregisterServiceWorker, } from "./service_worker/util.ts"; import "./lib/polyfills.ts"; -import type { BootConfig } from "./types/ui.ts"; +import type { BootConfig, ServiceWorkerTargetMessage } from "./types/ui.ts"; import { BoxProxy } from "./lib/box_proxy.ts"; +import { importKey } from "@silverbulletmd/silverbullet/lib/crypto"; const logger = initLogger("[Client]"); @@ -50,6 +51,57 @@ safeRun(async () => { return; } + let encryptionKey: CryptoKey | undefined; + // If client encryption is enabled (from auth page) AND the server signals it + if ( + localStorage.getItem("enableEncryption") && + bootConfig?.enableClientEncryption + ) { + // Init encryption + console.log("Initializing encryption"); + const swController = navigator.serviceWorker.controller; + if (swController) { + // Service is already running, let's see if has an encryption key for me + console.log( + "Service worker already running, querying it for an encryption key", + ); + swController.postMessage( + { type: "get-encryption-key" } as ServiceWorkerTargetMessage, + ); + await race([ + new Promise((resolve) => { + function keyListener(e: any) { + if (e.data.type === "encryption-key") { + navigator.serviceWorker.removeEventListener( + "message", + keyListener, + ); + importKey(e.data.key).then((key) => { + encryptionKey = key; + resolve(); + }); + } + } + navigator.serviceWorker.addEventListener("message", keyListener); + }), + sleep(200), + ]); + if (!encryptionKey) { + // No encryption key, redirecting to the auth page + console.warn("Not authenticated, redirecting to auth page"); + location.href = ".auth"; + throw new Error("Not authenticated"); + } + } else { + // No service worker, no encryption key, redirecting to the auth page + console.warn("Not authenticated, redirecting to auth page"); + location.href = ".auth"; + throw new Error("Not authenticated"); + } + } else { + bootConfig!.enableClientEncryption = false; + } + await augmentBootConfig(bootConfig!, config!); // Update the browser URL to no longer contain the query parameters using pushState @@ -68,7 +120,7 @@ safeRun(async () => { let lastStartNotification = 0; navigator.serviceWorker.addEventListener("message", (event) => { if (event.data.type === "service-worker-started") { - // Service worker started, let's make sure it the current config + // Service worker started, let's make sure it has the current config console.log( "Got notified that service worker has just started, sending config", bootConfig, @@ -88,7 +140,7 @@ safeRun(async () => { if (startNotificationCount > 2) { // This is not normal. Safari sometimes gets stuck on a database connection if the service worker is updated which means it cannot boot properly // the only know fix is to quit the browser and restart it - alert( + console.warn( "Something is wrong with the sync engine, please quit your browser and restart it.", ); } @@ -134,13 +186,6 @@ safeRun(async () => { } }); }); - - // // Handle service worker controlled changes (when a new service worker takes over) - // navigator.serviceWorker.addEventListener("controllerchange", async () => { - // console.log( - // "New service worker activated!", - // ); - // }); } else { console.info("Service worker disabled."); } @@ -157,7 +202,7 @@ safeRun(async () => { // @ts-ignore: on purpose globalThis.client = client; clientProxy.setTarget(client); - await client.init(); + await client.init(encryptionKey); if (navigator.serviceWorker) { navigator.serviceWorker.addEventListener("message", (event) => { client.handleServiceWorkerMessage(event.data); @@ -237,7 +282,7 @@ async function cachedFetch(path: string): Promise { } const redirectHeader = response.headers.get("location"); if (redirectHeader) { - alert( + console.info( "Received an (authentication) redirect, redirecting to URL: " + redirectHeader, ); diff --git a/client/client.ts b/client/client.ts index 916d8429..0bff5189 100644 --- a/client/client.ts +++ b/client/client.ts @@ -39,7 +39,6 @@ import type { StyleObject } from "../plugs/index/style.ts"; import { jitter, throttle } from "@silverbulletmd/silverbullet/lib/async"; import { PlugSpacePrimitives } from "./spaces/plug_space_primitives.ts"; import { EventedSpacePrimitives } from "./spaces/evented_space_primitives.ts"; -import { simpleHash } from "@silverbulletmd/silverbullet/lib/crypto"; import { HttpSpacePrimitives } from "./spaces/http_space_primitives.ts"; import { encodePageURI, @@ -79,6 +78,9 @@ import { offlineError, } from "@silverbulletmd/silverbullet/constants"; import { Augmenter } from "./data/data_augmenter.ts"; +import { EncryptedKvPrimitives } from "./data/encrypted_kv_primitives.ts"; +import type { KvPrimitives } from "./data/kv_primitives.ts"; +import { deriveDbName } from "@silverbulletmd/silverbullet/lib/crypto"; const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/; @@ -92,7 +94,6 @@ declare global { } type WidgetCacheItem = { - height: number; html: string; block?: boolean; copyContent?: string; @@ -138,7 +139,6 @@ export class Client { // Set to true once the system is ready (plugs loaded) public systemReady: boolean = false; private pageNavigator!: PathPageNavigator; - private dbName: string; private onLoadRef: Ref; // Progress circle handling private progressTimeout?: number; @@ -150,7 +150,7 @@ export class Client { console.error, ); }, 2000); - private widgetHeightCache = new LimitedMap(100); // bodytext -> height + private widgetHeightCache = new LimitedMap(1000); // bodytext -> height debouncedWidgetHeightCacheFlush = throttle(() => { this.ds.set( ["cache", "widgetHeight"], @@ -167,11 +167,6 @@ export class Client { readonly config: Config, ) { this.eventHook = new EventHook(this.config); - // Generate a semi-unique prefix for the database so not to reuse databases for different space paths - this.dbName = "" + - simpleHash( - `${bootConfig.spaceFolderPath}:${document.baseURI.replace(/\/*$/, "")}`, - ) + "_data"; // The third case should only ever happen when the user provides an invalid index env variable this.onLoadRef = parseRefFromURI() || this.getIndexRef(); } @@ -180,10 +175,28 @@ export class Client { * Initialize the client * This is a separated from the constructor to allow for async initialization */ - async init() { + async init(encryptionKey?: CryptoKey) { + const dbName = await deriveDbName( + "data", + this.bootConfig.spaceFolderPath, + document.baseURI.replace(/\/$/, ""), + encryptionKey, + ); // Setup the KV (database) - const kvPrimitives = new IndexedDBKvPrimitives(this.dbName); - await kvPrimitives.init(); + let kvPrimitives: KvPrimitives = new IndexedDBKvPrimitives(dbName); + await (kvPrimitives as IndexedDBKvPrimitives).init(); + + console.log("Using IndexedDB database", dbName); + + // See if we need to encrypt this + if (encryptionKey) { + kvPrimitives = new EncryptedKvPrimitives( + kvPrimitives, + encryptionKey, + ); + await (kvPrimitives as EncryptedKvPrimitives).init(); + console.log("Enabled client-side encryption"); + } // Wrap it in a datastore this.ds = new DataStore(kvPrimitives); @@ -1312,7 +1325,7 @@ export class Client { "cache", "widgetHeight", ], ["cache", "widgets"]]); - this.widgetHeightCache = new LimitedMap(100, widgetHeightCache || {}); + this.widgetHeightCache = new LimitedMap(1000, widgetHeightCache || {}); this.widgetCache = new LimitedMap(100, widgetCache || {}); } diff --git a/client/codemirror/lua_widget.ts b/client/codemirror/lua_widget.ts index 085ee5ae..7096e691 100644 --- a/client/codemirror/lua_widget.ts +++ b/client/codemirror/lua_widget.ts @@ -60,8 +60,7 @@ export class LuaWidget extends WidgetType { } override get estimatedHeight(): number { - const cacheItem = this.client.getWidgetCache(this.cacheKey); - return cacheItem ? cacheItem.height : -1; + return this.client.getCachedWidgetHeight(this.cacheKey); } toDOM(): HTMLElement { @@ -96,8 +95,9 @@ export class LuaWidget extends WidgetType { div.innerHTML = ""; this.client.setWidgetCache( this.cacheKey, - { height: div.clientHeight, html: "", block: false }, + { html: "", block: false }, ); + this.client.setCachedWidgetHeight(this.cacheKey, div.clientHeight); return; } widgetContent = { markdown: "nil", _isWidget: true }; @@ -159,8 +159,9 @@ export class LuaWidget extends WidgetType { div.innerHTML = ""; this.client.setWidgetCache( this.cacheKey, - { height: div.clientHeight, html: "", block: false }, + { html: "", block: false }, ); + this.client.setCachedWidgetHeight(this.cacheKey, div.clientHeight); return; } @@ -207,12 +208,12 @@ export class LuaWidget extends WidgetType { this.client.setWidgetCache( this.cacheKey, { - height: div.offsetHeight, html: html?.outerHTML || "", block, copyContent: copyContent, }, ); + this.client.setCachedWidgetHeight(this.cacheKey, div.offsetHeight); // Because of the rejiggering of the DOM, we need to do a no-op cursor move to make sure it's positioned correctly this.client.editorView.dispatch({ selection: this.client.editorView.state.selection, diff --git a/client/codemirror/top_bottom_panels.ts b/client/codemirror/top_bottom_panels.ts index 0392f080..08d6c0c8 100644 --- a/client/codemirror/top_bottom_panels.ts +++ b/client/codemirror/top_bottom_panels.ts @@ -20,8 +20,7 @@ class ArrayWidget extends WidgetType { } override get estimatedHeight(): number { - const cacheItem = this.client.getWidgetCache(this.cacheKey); - return cacheItem ? cacheItem.height : -1; + return this.client.getCachedWidgetHeight(this.cacheKey); } toDOM(): HTMLElement { @@ -91,13 +90,13 @@ class ArrayWidget extends WidgetType { div.replaceChildren(...renderedWidgets); + this.client.setWidgetCache(this.cacheKey, { + block: true, + html: div.innerHTML, + }); // Wait for the clientHeight to settle setTimeout(() => { - this.client.setWidgetCache(this.cacheKey, { - height: div.clientHeight, - block: true, - html: div.innerHTML, - }); + this.client.setCachedWidgetHeight(this.cacheKey, div.clientHeight); }); } diff --git a/client/data/encrypted_kv_primitives.test.ts b/client/data/encrypted_kv_primitives.test.ts new file mode 100644 index 00000000..b3ae7326 --- /dev/null +++ b/client/data/encrypted_kv_primitives.test.ts @@ -0,0 +1,64 @@ +import { assert, assertEquals } from "@std/assert"; +import { EncryptedKvPrimitives } from "./encrypted_kv_primitives.ts"; +import { MemoryKvPrimitives } from "./memory_kv_primitives.ts"; +import { deriveCTRKeyFromPassword } from "@silverbulletmd/silverbullet/lib/crypto"; + +Deno.test("Test Encrypted KV Primitives", async () => { + const memoryKv = new MemoryKvPrimitives(); + const salt = new Uint8Array(32); + const key = await deriveCTRKeyFromPassword("test", salt); + const kv = new EncryptedKvPrimitives(memoryKv, key); + await kv.init(); + + // Store a basic key + await kv.batchSet([{ key: ["key"], value: 10 }]); + const [value] = await kv.batchGet([["key"]]); + assertEquals(value, 10); + + // Store a binary blob + const blob = new Uint8Array([1, 2, 3, 4, 5]); + await kv.batchSet([{ key: ["blob"], value: blob }]); + const [blobValue] = await kv.batchGet([["blob"]]); + assertEquals(blobValue, blob); + + // Store a nested JSON and blob structure + const nested = { + json: { a: 1, b: 2 }, + blob: new Uint8Array([6, 7, 8, 9, 10]), + }; + await kv.batchSet([{ key: ["nested"], value: nested }]); + const [nestedValue] = await kv.batchGet([["nested"]]); + assertEquals(nestedValue, nested); + + // Put a few objects with a person prefix + await kv.batchSet([ + { key: ["person", "alice"], value: { name: "Alice", age: 30 } }, + { key: ["person", "bob"], value: { name: "Bob", age: 25 } }, + ]); + + // Then query based on the prefix + let counter = 0; + for await (const { key, value } of kv.query({ prefix: ["person"] })) { + assertEquals(key[0], "person"); + assert(key[1] === "alice" || key[1] === "bob"); + assert(value.age); + counter++; + } + assertEquals(counter, 2); + + // Delete something + await kv.batchDelete([["person", "alice"]]); + // Check it's gone + const [deletedValue] = await kv.batchGet([["person", "alice"]]); + assertEquals(deletedValue, undefined); + + console.log(memoryKv); + + // Clear + await kv.clear(); + counter = 0; + for await (const _ of kv.query({ prefix: ["person"] })) { + counter++; + } + assertEquals(counter, 0); +}); diff --git a/client/data/encrypted_kv_primitives.ts b/client/data/encrypted_kv_primitives.ts new file mode 100644 index 00000000..e640c519 --- /dev/null +++ b/client/data/encrypted_kv_primitives.ts @@ -0,0 +1,111 @@ +import type { KvPrimitives, KvQueryOptions } from "./kv_primitives.ts"; + +import type { KV, KvKey } from "../../plug-api/types/datastore.ts"; +import { + decryptAesGcm, + decryptStringDeterministic, + deriveGCMKeyFromCTR, + encryptAesGcm, + encryptStringDeterministic, +} from "@silverbulletmd/silverbullet/lib/crypto"; + +import { decode, encode } from "@msgpack/msgpack"; + +export class EncryptedKvPrimitives implements KvPrimitives { + private keyKey: CryptoKey; + private dataKey!: CryptoKey; + + constructor( + private wrapped: KvPrimitives, + encryptionKey: CryptoKey, + ) { + this.keyKey = encryptionKey; + } + + // MUST immediately be called after constructor + async init() { + this.dataKey = await deriveGCMKeyFromCTR(this.keyKey); + } + + clear(): Promise { + return this.wrapped.clear(); + } + + private encryptKey(key: KvKey): Promise { + return Promise.all( + key.map((part) => encryptStringDeterministic(this.keyKey, part)), + ); + } + + private decryptKey(key: KvKey): Promise { + return Promise.all( + key.map((part) => decryptStringDeterministic(this.keyKey, part)), + ); + } + + private encryptValue(value: any): Promise { + if (value === undefined) { + return Promise.resolve(undefined); + } + return encryptAesGcm(this.dataKey, encode(value)); + } + + private async decryptValue(value: any): Promise { + if (value === undefined) { + return undefined; + } + return decode(await decryptAesGcm(this.dataKey, value)); + } + + async batchGet(keys: KvKey[]): Promise { + const encryptedKeys: KvKey[] = await Promise.all(keys.map((key) => { + return this.encryptKey(key); + })); + const encryptedValues = await this.wrapped.batchGet(encryptedKeys); + return Promise.all( + encryptedValues.map((value) => this.decryptValue(value)), + ); + } + + async batchSet(entries: KV[]): Promise { + const encryptedEntries: KV[] = await Promise.all( + entries.map(async ({ key, value }) => { + const encryptedKey = await this.encryptKey(key); + const encryptedValue = await this.encryptValue(value); + return { key: encryptedKey, value: encryptedValue }; + }), + ); + await this.wrapped.batchSet(encryptedEntries); + } + + async batchDelete(keys: KvKey[]): Promise { + const encryptedKeys = await Promise.all( + keys.map((key) => this.encryptKey(key)), + ); + await this.wrapped.batchDelete(encryptedKeys); + } + + async *query({ prefix }: KvQueryOptions): AsyncIterableIterator { + const encryptedResults: KV[] = []; + // Collect all results first + for await ( + const entry of this.wrapped.query({ + prefix: prefix ? await this.encryptKey(prefix) : undefined, + }) + ) { + encryptedResults.push(entry); + } + + // Then decrypt them (to avoid transaction problems with wrapped indexed DBs) + for (const entry of encryptedResults) { + yield { + key: await this.decryptKey(entry.key), + value: await this.decryptValue(entry.value), + }; + } + } + + close() { + this.wrapped.close(); + } +} diff --git a/client/html/auth.html b/client/html/auth.html index 6049572d..37a0a0ff 100644 --- a/client/html/auth.html +++ b/client/html/auth.html @@ -144,7 +144,7 @@ Login to {{.SpaceName}} -
+
@@ -170,6 +170,14 @@ />
+
+ + +
@@ -180,30 +188,129 @@ - +async function deriveKey(phrase, salt) { + // Encode password to ArrayBuffer + const phraseBytes = new TextEncoder().encode(phrase); + + // Import password as a CryptoKey + const baseKey = await crypto.subtle.importKey( + "raw", + phraseBytes, + { name: "PBKDF2" }, + false, + ["deriveBits", "deriveKey"], + ); + + // Derive CTR key + const ctrKey = await crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: salt, + iterations: 100000, + hash: "SHA-256", + }, + baseKey, + { + name: "AES-CTR", + length: 256, + }, + true, // extractable + ["encrypt", "decrypt"], + ); + + // Export + const key = await crypto.subtle.exportKey("raw", ctrKey); + // Base64 encode + return base64Encode(new Uint8Array(key)); +} + +function base64Encode(buffer) { + let binary = ""; + const len = buffer.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(buffer[i]); + } + return btoa(binary); +} + +function base64Decode(s) { + const binString = atob(s); + const len = binString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binString.charCodeAt(i); + } + return bytes; +} + diff --git a/client/plugos/syscalls/editor.ts b/client/plugos/syscalls/editor.ts index 4ac66992..acf1bd60 100644 --- a/client/plugos/syscalls/editor.ts +++ b/client/plugos/syscalls/editor.ts @@ -13,8 +13,8 @@ import { moveLineDown, moveLineUp, redo, + toggleComment, undo, - toggleComment } from "@codemirror/commands"; import type { Transaction } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; @@ -600,7 +600,7 @@ export function editorSyscalls(client: Client): SysCallMapping { return toggleComment({ state: client.editorView.state, dispatch: client.editorView.dispatch, - }) + }); }, "editor.moveLineUp": () => { return moveLineUp({ diff --git a/client/service_worker.ts b/client/service_worker.ts index f067f5b4..8aa7a627 100644 --- a/client/service_worker.ts +++ b/client/service_worker.ts @@ -6,13 +6,19 @@ import type { ServiceWorkerSourceMessage, ServiceWorkerTargetMessage, } from "./types/ui.ts"; -import { simpleHash } from "@silverbulletmd/silverbullet/lib/crypto"; +import { + deriveDbName, + exportKey, + importKey, +} from "@silverbulletmd/silverbullet/lib/crypto"; import { IndexedDBKvPrimitives } from "./data/indexeddb_kv_primitives.ts"; import { fsEndpoint } from "./spaces/constants.ts"; import { DataStoreSpacePrimitives } from "./spaces/datastore_space_primitives.ts"; import { HttpSpacePrimitives } from "./spaces/http_space_primitives.ts"; import { throttleImmediately } from "@silverbulletmd/silverbullet/lib/async"; import { wrongSpacePathError } from "@silverbulletmd/silverbullet/constants"; +import type { KvPrimitives } from "./data/kv_primitives.ts"; +import { EncryptedKvPrimitives } from "./data/encrypted_kv_primitives.ts"; const logger = initLogger("[Service Worker]"); @@ -61,6 +67,20 @@ let configuring = false; // @ts-ignore: debugging globalThis.proxyRouter = proxyRouter; +// This is the in-memory store of an encryption key that SB clients and the index engine can share without asking for it constantly +let encryptionKeyMemoryStore: CryptoKey | undefined; + +// Let's clean this encryptionKey if there's no more clients left for a little while, asking to re-enter +setInterval(() => { + // @ts-ignore: service worker API + globalThis.clients.matchAll().then((clients) => { + if (clients.length === 0) { + console.info("No more clients, flushing encryption key"); + encryptionKeyMemoryStore = undefined; + } + }); +}, 5000); // little while is 5s + // Message received from client self.addEventListener("message", async (event: any) => { const message: ServiceWorkerTargetMessage = event.data; @@ -129,6 +149,19 @@ self.addEventListener("message", async (event: any) => { console.info("Forced connection status to", message.enabled); break; } + case "get-encryption-key": { + event.source.postMessage({ + type: "encryption-key", + key: encryptionKeyMemoryStore && + await exportKey(encryptionKeyMemoryStore), + } as ServiceWorkerSourceMessage); + break; + } + case "set-encryption-key": { + encryptionKeyMemoryStore = await importKey(message.key); + console.info("Encryption phrase set"); + break; + } case "config": { const config = message.config; // Configure the service worker if it hasn't been already @@ -156,12 +189,28 @@ self.addEventListener("message", async (event: any) => { configuring = false; }, 5000); try { + if (config.enableClientEncryption) { + if (!encryptionKeyMemoryStore) { + console.error( + "Supposed to use encryption, but no phrase set yet, auth error", + ); + broadcastMessage({ + type: "auth-error", + message: "Re-authentication required, redirecting...", + actionOrRedirectHeader: "reload", + }); + // ABORT + return; + } + } + const spaceFolderPath = config.spaceFolderPath; - // We're generating a simple hashed database name based on the space path in case people regularly switch between multiple space paths - const spaceHash = "" + - simpleHash(`${spaceFolderPath}:${baseURI.replace(/\/*$/, "")}`); - // And we'll use a _files postfix to signify where synced files are kept - const dbName = `${spaceHash}_files`; + const dbName = await deriveDbName( + "files", + spaceFolderPath, + baseURI, + encryptionKeyMemoryStore, + ); if (config.logPush) { setInterval(() => { @@ -170,8 +219,15 @@ self.addEventListener("message", async (event: any) => { } // Setup KV (database) for store synced files - const kv = new IndexedDBKvPrimitives(dbName); - await kv.init(); + let kv: KvPrimitives = new IndexedDBKvPrimitives(dbName); + await (kv as IndexedDBKvPrimitives).init(); + console.log("Using IndexedDB database", dbName); + + if (encryptionKeyMemoryStore) { + kv = new EncryptedKvPrimitives(kv, encryptionKeyMemoryStore); + await (kv as EncryptedKvPrimitives).init(); + console.log("Enabled client-side encryption for synced files"); + } // And use that to power the IndexedDB backed local storage const local = new DataStoreSpacePrimitives(kv); diff --git a/client/space_lua/parse.ts b/client/space_lua/parse.ts index 15177050..2ee7b1d4 100644 --- a/client/space_lua/parse.ts +++ b/client/space_lua/parse.ts @@ -52,8 +52,8 @@ export const luaLanguage = LRLanguage.define({ ], }), languageData: { - commentTokens: { line: "--", block: { open: "--[[", close: "--]]" }} - } + commentTokens: { line: "--", block: { open: "--[[", close: "--]]" } }, + }, }); function context(t: ParseTree, ctx: Record): ASTCtx { diff --git a/client/types/ui.ts b/client/types/ui.ts index 306f8001..062197a5 100644 --- a/client/types/ui.ts +++ b/client/types/ui.ts @@ -184,6 +184,8 @@ export type BootConfig = { disablePlugs?: boolean; performWipe?: boolean; performReset?: boolean; + + enableClientEncryption: boolean; }; /** @@ -199,7 +201,9 @@ export type ServiceWorkerTargetMessage = | { type: "wipe-data" } | { type: "perform-file-sync"; path: string } | { type: "perform-space-sync" } - | { type: "force-connection-status"; enabled: boolean }; + | { type: "force-connection-status"; enabled: boolean } + | { type: "get-encryption-key" } + | { type: "set-encryption-key"; key: string }; /** * Events received from the service worker -> client */ @@ -232,4 +236,7 @@ export type ServiceWorkerSourceMessage = { type: "dataWiped"; } | { type: "service-worker-started"; +} | { + type: "encryption-key"; + key: string; }; diff --git a/deno.json b/deno.json index 2b5a3368..54540808 100644 --- a/deno.json +++ b/deno.json @@ -10,27 +10,15 @@ "lint": "deno lint --fix", "fmt": "deno fmt", "test": "deno test -A", - "plugs": "deno run -A build_plugs_libraries.ts", + "plugs": "deno run -A build_plugs_libraries.ts" }, "publish": { - "exclude": [ - "website", - "CHANGELOG.md", - "**/*.md" - ] + "exclude": ["website", "CHANGELOG.md", "**/*.md"] }, "lint": { - "exclude": [ - "dist", - "client_bundle", - "website" - ], + "exclude": ["dist", "client_bundle", "website"], "rules": { - "exclude": [ - "no-explicit-any", - "no-slow-types", - "jsx-boolean-value" - ] + "exclude": ["no-explicit-any", "no-slow-types", "jsx-boolean-value"] } }, "fmt": { @@ -45,12 +33,7 @@ ] }, "compilerOptions": { - "lib": [ - "dom", - "dom.iterable", - "dom.asynciterable", - "deno.ns" - ], + "lib": ["dom", "dom.iterable", "dom.asynciterable", "deno.ns"], "jsx": "react-jsx", "jsxImportSource": "npm:preact@10.26.4" }, @@ -153,6 +136,7 @@ "style-mod": "npm:style-mod@4.1.2", "turndown": "npm:turndown@7.2.0", "ajv": "npm:ajv@8.17.1", - "turndown-plugin-gfm": "npm:@joplin/turndown-plugin-gfm@1.0.62" + "turndown-plugin-gfm": "npm:@joplin/turndown-plugin-gfm@1.0.62", + "@msgpack/msgpack": "npm:@msgpack/msgpack@3.1.2" } } diff --git a/deno.lock b/deno.lock index b51e870f..9c553352 100644 --- a/deno.lock +++ b/deno.lock @@ -50,6 +50,7 @@ "npm:@lezer/javascript@1.5.1": "1.5.1", "npm:@lezer/lr@1.4.2": "1.4.2", "npm:@lezer/markdown@1.4.3": "1.4.3", + "npm:@msgpack/msgpack@3.1.2": "3.1.2", "npm:@replit/codemirror-lang-nix@6.0.1": "6.0.1_@codemirror+autocomplete@6.18.6_@codemirror+language@6.11.1_@codemirror+state@6.5.2_@codemirror+view@6.37.2_@lezer+common@1.2.3_@lezer+highlight@1.2.1_@lezer+lr@1.4.2", "npm:@replit/codemirror-vim@6.3.0": "6.3.0_@codemirror+commands@6.8.1_@codemirror+language@6.11.1_@codemirror+search@6.5.11_@codemirror+state@6.5.2_@codemirror+view@6.37.2", "npm:@types/node@*": "22.15.15", @@ -386,6 +387,9 @@ "@mixmark-io/domino@2.2.0": { "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==" }, + "@msgpack/msgpack@3.1.2": { + "integrity": "sha512-JEW4DEtBzfe8HvUYecLU9e6+XJnKDlUAIve8FvPzF3Kzs6Xo/KuZkZJsDH0wJXl/qEZbeeE7edxDNY3kMs39hQ==" + }, "@replit/codemirror-lang-nix@6.0.1_@codemirror+autocomplete@6.18.6_@codemirror+language@6.11.1_@codemirror+state@6.5.2_@codemirror+view@6.37.2_@lezer+common@1.2.3_@lezer+highlight@1.2.1_@lezer+lr@1.4.2": { "integrity": "sha512-lvzjoYn9nfJzBD5qdm3Ut6G3+Or2wEacYIDJ49h9+19WSChVnxv4ojf+rNmQ78ncuxIt/bfbMvDLMeMP0xze6g==", "dependencies": [ @@ -677,6 +681,7 @@ "npm:@lezer/javascript@1.5.1", "npm:@lezer/lr@1.4.2", "npm:@lezer/markdown@1.4.3", + "npm:@msgpack/msgpack@3.1.2", "npm:@replit/codemirror-lang-nix@6.0.1", "npm:@replit/codemirror-vim@6.3.0", "npm:ajv@8.17.1", diff --git a/plug-api/lib/crypto.test.ts b/plug-api/lib/crypto.test.ts new file mode 100644 index 00000000..0ad48c6e --- /dev/null +++ b/plug-api/lib/crypto.test.ts @@ -0,0 +1,28 @@ +import { + decryptAesGcm, + decryptStringDeterministic, + deriveCTRKeyFromPassword, + deriveGCMKeyFromCTR, + encryptAesGcm, + encryptStringDeterministic, +} from "@silverbulletmd/silverbullet/lib/crypto"; +import { assertEquals } from "@std/assert"; + +Deno.test("Crypto test", async () => { + const salt = new Uint8Array(16); // zeroes for testing + const ctr = await deriveCTRKeyFromPassword("12345", salt); + const gcm = await deriveGCMKeyFromCTR(ctr); + const text = "123"; + const encrypted = await encryptStringDeterministic(ctr, text); + const encrypted2 = await encryptStringDeterministic(ctr, text); + // Ensure determinism + assertEquals(encrypted, encrypted2); + const decrypted = await decryptStringDeterministic(ctr, encrypted); + assertEquals(decrypted, text); + + // Now gcm + const buffer = new Uint8Array(100).fill(32); + const encryptedBuf = await encryptAesGcm(gcm, buffer); + const decryptedBuf = await decryptAesGcm(gcm, encryptedBuf); + assertEquals(decryptedBuf, buffer); +}); diff --git a/plug-api/lib/crypto.ts b/plug-api/lib/crypto.ts index 7618a3dd..6f7afa56 100644 --- a/plug-api/lib/crypto.ts +++ b/plug-api/lib/crypto.ts @@ -1,16 +1,3 @@ -export function simpleHash(s: string): number { - let hash = 0, - i, - chr; - if (s.length === 0) return hash; - for (i = 0; i < s.length; i++) { - chr = s.charCodeAt(i); - hash = ((hash << 5) - hash) + chr; - hash |= 0; // Convert to 32bit integer - } - return hash; -} - export function base64Decode(s: string): Uint8Array { const binString = atob(s); const len = binString.length; @@ -63,3 +50,146 @@ export async function hashSHA256(message: string): Promise { b.toString(16).padStart(2, "0") ).join(""); } + +/** + * To avoid database clashes based on space folder path name, base URLs and encryption keys we derive + * a database name from a hash of all these combined together + */ +export async function deriveDbName( + type: "data" | "files", + spaceFolderPath: string, + baseURI: string, + encryptionKey?: CryptoKey, +) { + let keyPart = ""; + if (encryptionKey) { + keyPart = await exportKey(encryptionKey); + } + const spaceHash = await hashSHA256( + `${spaceFolderPath}:${baseURI}:${keyPart}`, + ); + return `sb_${type}_${spaceHash}`; +} + +// Fixed counter for AES-CTR all zeroes, for determinism +const fixedCounter = new Uint8Array(16); + +export async function encryptStringDeterministic( + key: CryptoKey, + clearText: string, +): Promise { + const encrypted = await crypto.subtle.encrypt( + { name: "AES-CTR", counter: fixedCounter, length: fixedCounter.length * 8 }, + key, + new TextEncoder().encode(clearText), + ); + return base64Encode(new Uint8Array(encrypted)); +} + +export async function decryptStringDeterministic( + key: CryptoKey, + cipherText: string, +): Promise { + const decrypted = await crypto.subtle.decrypt( + { name: "AES-CTR", counter: fixedCounter, length: fixedCounter.length * 8 }, + key, + base64Decode(cipherText) as BufferSource, + ); + return new TextDecoder().decode(decrypted); +} + +// Encrypt using AES-GCM with random IV; output = IV + ciphertext +export async function encryptAesGcm( + key: CryptoKey, + data: Uint8Array, +): Promise { + const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV recommended for GCM + const encryptedBuffer = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + data as BufferSource, + ); + const encrypted = new Uint8Array(encryptedBuffer); + + // Prepend IV to ciphertext + const result = new Uint8Array(iv.length + encrypted.length); + result.set(iv, 0); + result.set(encrypted, iv.length); + return result; +} + +// Decrypt using AES-GCM assuming input format IV + ciphertext +export async function decryptAesGcm( + key: CryptoKey, + encryptedData: Uint8Array, +): Promise { + const iv = encryptedData.slice(0, 12); // extract IV (first 12 bytes) + const ciphertext = encryptedData.slice(12); + const decryptedBuffer = await crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + key, + ciphertext, + ); + return new Uint8Array(decryptedBuffer); +} + +export async function deriveCTRKeyFromPassword( + password: string, + salt: Uint8Array, +): Promise { + // Encode password to ArrayBuffer + const passwordBytes = new TextEncoder().encode(password); + + // Import password as a CryptoKey + const baseKey = await crypto.subtle.importKey( + "raw", + passwordBytes, + { name: "PBKDF2" }, + false, + ["deriveBits", "deriveKey"], + ); + + return crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: salt as BufferSource, + iterations: 100000, + hash: "SHA-256", + }, + baseKey, + { + name: "AES-CTR", + length: 256, + }, + true, // extractable + ["encrypt", "decrypt"], + ); +} + +export function importKey(b64EncodedKey: string): Promise { + return crypto.subtle.importKey( + "raw", + base64Decode(b64EncodedKey) as BufferSource, + { name: "AES-CTR" }, + true, + ["encrypt", "decrypt"], + ); +} + +export async function exportKey(ctrKey: CryptoKey): Promise { + const key = await crypto.subtle.exportKey("raw", ctrKey); + return base64Encode(new Uint8Array(key)); +} + +export async function deriveGCMKeyFromCTR( + ctrKey: CryptoKey, +): Promise { + const rawKey = await crypto.subtle.exportKey("raw", ctrKey); + return crypto.subtle.importKey( + "raw", + rawKey, + { name: "AES-GCM" }, + true, + ["encrypt", "decrypt"], + ); +} diff --git a/server/auth.go b/server/auth.go index 9d027f1d..3d7c2513 100644 --- a/server/auth.go +++ b/server/auth.go @@ -12,6 +12,7 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/go-chi/render" ) // path to auth page in the client bundle @@ -42,6 +43,10 @@ func addAuthEndpoints(r chi.Router, config *ServerConfig) { // Auth page r.Get("/.auth", func(w http.ResponseWriter, r *http.Request) { spaceConfig := spaceConfigFromContext(r.Context()) + if spaceConfig.Auth == nil { + http.Error(w, "Authentication not enabled", http.StatusForbidden) + return + } if err := spaceConfig.InitAuth(); err != nil { http.Error(w, "Failed to initialize authentication", http.StatusInternalServerError) return @@ -55,12 +60,10 @@ func addAuthEndpoints(r chi.Router, config *ServerConfig) { tpl := template.Must(template.New("auth").Parse(string(data))) - templateData := struct { - HostPrefix string - SpaceName string - }{ - HostPrefix: config.HostURLPrefix, - SpaceName: spaceConfig.SpaceName, + templateData := map[string]string{ + "HostPrefix": config.HostURLPrefix, + "SpaceName": spaceConfig.SpaceName, + "EncryptionSalt": spaceConfig.JwtIssuer.Salt, } w.Header().Set("Content-type", "text/html") @@ -138,18 +141,27 @@ func addAuthEndpoints(r chi.Router, config *ServerConfig) { redirectPath = from } - http.Redirect(w, r, applyURLPrefix(redirectPath, config.HostURLPrefix), http.StatusFound) + render.JSON(w, r, map[string]any{ + "status": "ok", + "redirect": redirectPath, + }) } else { log.Println("Authentication failed, redirecting to auth page.") spaceConfig.LockoutTimer.AddCount() - http.Redirect(w, r, applyURLPrefix("/.auth?error=1", config.HostURLPrefix), http.StatusFound) + render.JSON(w, r, map[string]any{ + "status": "error", + "error": "Invalid username and/or password", + }) } }) } func (spaceConfig *SpaceConfig) InitAuth() error { if spaceConfig.JwtIssuer == nil { + spaceConfig.authMutex.Lock() + defer spaceConfig.authMutex.Unlock() + var err error // Need to do some initialization spaceConfig.JwtIssuer, err = CreateAuthenticator(path.Join(spaceConfig.SpaceFolderPath, ".silverbullet.auth.json"), spaceConfig.Auth) diff --git a/server/crypto.go b/server/crypto.go index 01d085e4..1531950a 100644 --- a/server/crypto.go +++ b/server/crypto.go @@ -20,6 +20,7 @@ type Authenticator struct { path string SecretKey string `json:"secret_key"` AuthHash string `json:"auth_hash"` + Salt string `json:"salt"` // base64 encoded 16 byte randomized salt used for encryption } func CreateAuthenticator(path string, authOptions *AuthOptions) (*Authenticator, error) { @@ -79,6 +80,12 @@ func (j *Authenticator) init(authConfig *AuthOptions) error { j.AuthHash = newAuthHash + if j.Salt == "" { + b := make([]byte, 16) + rand.Read(b) + j.Salt = base64.StdEncoding.EncodeToString(b) + } + return j.save() } diff --git a/server/server.go b/server/server.go index 0711b569..736c1338 100644 --- a/server/server.go +++ b/server/server.go @@ -26,6 +26,9 @@ type BootConfig struct { // Whether or not the client should push logs to the server LogPush bool `json:"logPush"` + + // Encryption + EnableClientEncryption bool `json:"enableClientEncryption"` } func Router(config *ServerConfig) chi.Router { @@ -68,6 +71,8 @@ func Router(config *ServerConfig) chi.Router { IndexPage: spaceConfig.IndexPage, ReadOnly: spaceConfig.ReadOnlyMode, LogPush: spaceConfig.LogPush, + // Client encryption is offered as an option when auth is enabled only + EnableClientEncryption: spaceConfig.Auth != nil, } w.Header().Set("Cache-Control", "no-cache") diff --git a/server/types.go b/server/types.go index 305924d2..787a4558 100644 --- a/server/types.go +++ b/server/types.go @@ -3,6 +3,7 @@ package server import ( "errors" "net/http" + "sync" ) type ServerConfig struct { @@ -43,6 +44,7 @@ type SpaceConfig struct { // Auth temporary objects JwtIssuer *Authenticator LockoutTimer *LockoutTimer + authMutex sync.Mutex } type ConfigResolver func(r *http.Request) (*SpaceConfig, error) diff --git a/website/CHANGELOG.md b/website/CHANGELOG.md index 9db86ffc..0e9f9b17 100644 --- a/website/CHANGELOG.md +++ b/website/CHANGELOG.md @@ -1,7 +1,23 @@ An attempt at documenting the changes/new features introduced in each release. ## Edge -* Nothing new yet since 2.1.8 +* [[Client Encryption]]: due to a change in how database names are now generated, this will result in a _one-time_ resync and reindex of your space. +* Lua fixes, making [[Space Lua]] more compatible with Lua 5.4 (most courtesy of of Matouš Jan Fialka): + * [Fix length (`#` operator) features](https://github.com/silverbulletmd/silverbullet/pull/1637) + * [Add `rawget` and `rawequal`](https://github.com/silverbulletmd/silverbullet/pull/1647) + * [Allow `..` to also concatenate strings and numbers](https://github.com/silverbulletmd/silverbullet/pull/1648) + * [Make truthiness more Lua compatible](https://github.com/silverbulletmd/silverbullet/pull/1644) + * [Align arithmetic model with standard Lua](https://github.com/silverbulletmd/silverbullet/pull/1611) + * [Add `huge` constant and `type` to `math.*` API](https://github.com/silverbulletmd/silverbullet/pull/1632) + * [Add `load` function](https://github.com/silverbulletmd/silverbullet/pull/1631) + * [Support %u in os.date](https://github.com/silverbulletmd/silverbullet/issues/1598) + * [Pass on status code differently when using `http.request`](https://github.com/silverbulletmd/silverbullet/issues/1608) +* More video embeds in standard library (courtesey of Andy Costanza): + * [Vimeo](https://github.com/silverbulletmd/silverbullet/pull/1616) + * [Peertube](https://github.com/silverbulletmd/silverbullet/pull/1612) +* Atomic upgrades with `silverbullet update` and `silverbullet update-edge` (by [Mihai Maruseac](https://github.com/silverbulletmd/silverbullet/pull/1634)) +* Fix: bottom search bar dark mode styling (by [numan](https://github.com/silverbulletmd/silverbullet/pull/1614)) +* Fix: navigation with auto links (by [MrMugame](https://github.com/silverbulletmd/silverbullet/pull/1607)) ## 2.1.8 * New [[^Library/Std/APIs/Virtual Page]] API, internally used by: diff --git a/website/Client Encryption.md b/website/Client Encryption.md new file mode 100644 index 00000000..d88dee65 --- /dev/null +++ b/website/Client Encryption.md @@ -0,0 +1,27 @@ +> **warning** Warning +> Client encryption is a beta feature, its implementation may still evolve. + +By default SilverBullet keeps a copy of all your files as well as the index, unencrypted in your browser’s IndexedDB. As long as you control your device and other people do not have access to it, this should be perfectly safe. Browsers do not allow access to these databases from other websites and domains. + +However, if you are accessing SilverBullet from an “untrusted” device, such as a public computer, this is not a great option, because even after closing your browser all content remains stored on the device unencrypted. You can use commands like `Client: Wipe` and `Client: Logout` to remove data locally, but there’s a chance you will forget at some point. + +> **note** Note +> Client encryption right now is only available for users of SilverBullet’s native [[Authentication]] system. + +This is what SilverBullet’s **client encryption** is for. When you authenticate using SilverBullet’s [[Authentication]] system, you will have a checkbox “Enable client encryption”. When checked, _all_ your content kept on the client will be encrypted (both your synced files and index). Since no encryption keys are written to disk at any stage, closing your browser or even closing all SilverBullet tabs will flush the key. While content remains on the device, it cannot be viewed without the encryption key derived from your username and password, leaving it useless to malicious actors. + +This safety does come at a cost: + +1. **Performance:** due to constant encryption and decryption SilverBullet will be slower. +2. **Convenience:** the client-side encryption key (derived from your username and password) will only ever be kept in memory, which means that if you close all your SilverBullet browser tabs and windows, the key will be flushed and you will have to login again. + +# Details +Some technical details on how client encryption works. + +Note that this is _client_ encryption only, not end-to-end encryption. Data on the server is only encrypted if your space folder is kept on some encrypted volume on the server side. _Transfer_ of data from the client to server is always encrypted via TLS, whether you use client encryption or not. + +On the client, all of SilverBullet’s local data storage is built on a small key-value based [abstract interface](https://github.com/silverbulletmd/silverbullet/blob/main/client/data/kv_primitives.ts). By default this interface is implemented to [directly](https://github.com/silverbulletmd/silverbullet/blob/main/client/data/indexeddb_kv_primitives.ts) communicate with your browser’s [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API). On top of this we build the sync engine and all our database indexing features. When you enable client encryption, we put a layer in-between: the encryption layer, which based on a cryptographic key will encrypt and decrypt both keys and values on the fly. + +A strong 256-bit cryptographic key is derived (using _PBKDF2_) on the client from your username/password combo entered upon login. This key is kept in the service worker for SilverBullet clients to obtain so that the user is not required to constant log in when refreshing a tab, or opening new SilverBullet tabs and windows. + +Since we need deterministic and stable encryption for data store keys, we use _AES-CTR_ with a fixed counter. For values we use _AES-GCM_ with randomized ivs. \ No newline at end of file