Optional client encryption (#1640)

Implements #1268
This commit is contained in:
Zef Hemel
2025-10-22 15:35:37 +02:00
committed by GitHub
parent cd6f4e9c58
commit 272f293ddb
21 changed files with 738 additions and 119 deletions
+58 -13
View File
@@ -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<void>((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<string> {
}
const redirectHeader = response.headers.get("location");
if (redirectHeader) {
alert(
console.info(
"Received an (authentication) redirect, redirecting to URL: " +
redirectHeader,
);
+26 -13
View File
@@ -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<number>(100); // bodytext -> height
private widgetHeightCache = new LimitedMap<number>(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 || {});
}
+6 -5
View File
@@ -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,
+6 -7
View File
@@ -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);
});
}
@@ -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);
});
+111
View File
@@ -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<void> {
return this.wrapped.clear();
}
private encryptKey(key: KvKey): Promise<KvKey> {
return Promise.all(
key.map((part) => encryptStringDeterministic(this.keyKey, part)),
);
}
private decryptKey(key: KvKey): Promise<KvKey> {
return Promise.all(
key.map((part) => decryptStringDeterministic(this.keyKey, part)),
);
}
private encryptValue(value: any): Promise<any> {
if (value === undefined) {
return Promise.resolve(undefined);
}
return encryptAesGcm(this.dataKey, encode(value));
}
private async decryptValue(value: any): Promise<any> {
if (value === undefined) {
return undefined;
}
return decode(await decryptAesGcm(this.dataKey, value));
}
async batchGet(keys: KvKey[]): Promise<any[]> {
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<void> {
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<void> {
const encryptedKeys = await Promise.all(
keys.map((key) => this.encryptKey(key)),
);
await this.wrapped.batchDelete(encryptedKeys);
}
async *query({ prefix }: KvQueryOptions): AsyncIterableIterator<KV> {
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();
}
}
+130 -23
View File
@@ -144,7 +144,7 @@
Login to <img src=".client/logo.png" style="height: 1ch" />
{{.SpaceName}}
</h1>
<form action=".auth" class="flow" id="login" method="POST">
<form class="flow" id="login" method="POST">
<div class="error-message"></div>
<div>
<label for="username">Username</label>
@@ -170,6 +170,14 @@
/>
<label for="rememberMe">Remember me</label>
</div>
<div class="checkbox-wrapper">
<input
id="clientEncryption"
name="clientEncryption"
type="checkbox"
/>
<label for="clientEncryption">Enable client encryption (e.g. when using a public computer)</label>
</div>
<div style="--space: 1.8rem">
<button>Login</button>
</div>
@@ -180,30 +188,129 @@
</div>
</div>
<script>
const params = new URLSearchParams(window.location.search);
<script>
const workerURL = new URL("service_worker.js", document.baseURI);
console.log("Registering service worker");
navigator.serviceWorker.register(workerURL, {
type: "module",
scope: workerURL.pathname.substring(0, workerURL.pathname.lastIndexOf("/") + 1),
}).then(() => {
console.log("Service worker registered");
});
const error = params.get("error");
if (error === "0") {
document.querySelector(".error-message").innerText =
"The sent data was invalid";
} else if (error === "1") {
document.querySelector(".error-message").innerText =
"Invalid username or password";
} else if (error === "2") {
document.querySelector(".error-message").innerText =
"Too many invalid logins. Try again later";
}
document.getElementById("login").addEventListener("submit", (e) => {
const enableEncryption = document.getElementById('clientEncryption').checked;
const params = new URLSearchParams();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
params.append('username', username);
params.append('password', password);
fetch('.auth', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
credentials:'include',
body: params.toString(),
})
.then(async(response) => {
const body = await response.json();
if(body.status === "error") {
document.querySelector(".error-message").innerText =
body.error;
} else {
if(enableEncryption) {
localStorage.setItem('enableEncryption', "true");
} else {
localStorage.removeItem('enableEncryption');
}
if(enableEncryption) {
const salt = base64Decode({{.EncryptionSalt}});
const key = await deriveKey(`${username}:${password}`, salt);
// Encode password to ArrayBuffer
if(navigator.serviceWorker?.controller) {
navigator.serviceWorker.controller.postMessage({
type: "set-encryption-key",
key,
});
} else {
alert("Something is not right, service worker not active");
return;
}
}
location.href = body.redirect;
}
})
e.preventDefault();
});
const params = new URLSearchParams(window.location.search);
const from = params.get("from");
if(localStorage.getItem("enableEncryption")) {
document.getElementById("clientEncryption").checked = true;
}
if (from) {
var input = document.createElement("input");
input.setAttribute("type", "hidden");
input.setAttribute("name", "from");
input.setAttribute("value", from);
const from = params.get("from");
if (from) {
var input = document.createElement("input");
input.setAttribute("type", "hidden");
input.setAttribute("name", "from");
input.setAttribute("value", from);
document.getElementById("login").appendChild(input);
}
document.getElementById("login").appendChild(input);
}
</script>
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;
}
</script>
</body>
</html>
+2 -2
View File
@@ -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({
+64 -8
View File
@@ -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);
+2 -2
View File
@@ -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<string, any>): ASTCtx {
+8 -1
View File
@@ -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;
};
+7 -23
View File
@@ -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"
}
}
Generated
+5
View File
@@ -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",
+28
View File
@@ -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);
});
+143 -13
View File
@@ -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<string> {
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<string> {
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<string> {
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<Uint8Array> {
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<Uint8Array> {
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<CryptoKey> {
// 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<CryptoKey> {
return crypto.subtle.importKey(
"raw",
base64Decode(b64EncodedKey) as BufferSource,
{ name: "AES-CTR" },
true,
["encrypt", "decrypt"],
);
}
export async function exportKey(ctrKey: CryptoKey): Promise<string> {
const key = await crypto.subtle.exportKey("raw", ctrKey);
return base64Encode(new Uint8Array(key));
}
export async function deriveGCMKeyFromCTR(
ctrKey: CryptoKey,
): Promise<CryptoKey> {
const rawKey = await crypto.subtle.exportKey("raw", ctrKey);
return crypto.subtle.importKey(
"raw",
rawKey,
{ name: "AES-GCM" },
true,
["encrypt", "decrypt"],
);
}
+20 -8
View File
@@ -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)
+7
View File
@@ -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()
}
+5
View File
@@ -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")
+2
View File
@@ -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)
+17 -1
View File
@@ -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:
+27
View File
@@ -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.