From 19abdca7642cd2941564ebcef49a25c2af58d507 Mon Sep 17 00:00:00 2001 From: Yigit Colakoglu Date: Wed, 30 Jul 2025 19:20:32 +0200 Subject: [PATCH] Created plugin to publish encrypted pages --- quartz.config.ts | 10 +- quartz/components/scripts/encrypt.inline.ts | 361 ++++++++++++++++++++ quartz/components/styles/encrypt.scss | 149 ++++++++ quartz/plugins/emitters/contentIndex.tsx | 9 +- quartz/plugins/transformers/description.ts | 5 + quartz/plugins/transformers/encrypt.ts | 224 ++++++++++++ quartz/plugins/transformers/index.ts | 1 + 7 files changed, 757 insertions(+), 2 deletions(-) create mode 100644 quartz/components/scripts/encrypt.inline.ts create mode 100644 quartz/components/styles/encrypt.scss create mode 100644 quartz/plugins/transformers/encrypt.ts diff --git a/quartz.config.ts b/quartz.config.ts index b3db3d60d..b85e51b74 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -70,8 +70,16 @@ const config: QuartzConfig = { Plugin.GitHubFlavoredMarkdown(), Plugin.TableOfContents(), Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }), - Plugin.Description(), Plugin.Latex({ renderEngine: "katex" }), + Plugin.EncryptPlugin({ + algorithm: "aes-256-cbc", + keyLength: 32, + iterations: 100000, + encryptedFolders: { + }, + ttl: 3600 * 24 * 7, // A week + }), + Plugin.Description(), ], filters: [Plugin.RemoveDrafts()], emitters: [ diff --git a/quartz/components/scripts/encrypt.inline.ts b/quartz/components/scripts/encrypt.inline.ts new file mode 100644 index 000000000..ca28f30e8 --- /dev/null +++ b/quartz/components/scripts/encrypt.inline.ts @@ -0,0 +1,361 @@ +// Password cache management +const PASSWORD_CACHE_KEY = "quartz-encrypt-passwords" + +function getPasswordCache(): Record { + try { + const cache = localStorage.getItem(PASSWORD_CACHE_KEY) + return cache ? JSON.parse(cache) : {} + } catch { + return {} + } +} + +function savePasswordCache(cache: Record) { + try { + localStorage.setItem(PASSWORD_CACHE_KEY, JSON.stringify(cache)) + } catch { + // Silent fail if localStorage is not available + } +} + +function addPasswordToCache(password: string, filePath: string, ttl: number) { + const cache = getPasswordCache() + const now = Date.now() + + // Store password for exact file path + cache[filePath] = { + password, + ttl: ttl <= 0 ? 0 : now + ttl, + } + + savePasswordCache(cache) +} + +function getRelevantPasswords(filePath: string): string[] { + const cache = getPasswordCache() + const now = Date.now() + const passwords: string[] = [] + + // Clean expired passwords (but keep infinite TTL ones) + Object.keys(cache).forEach((path) => { + if (cache[path].ttl > 0 && cache[path].ttl < now) { + delete cache[path] + } + }) + + // Get passwords by directory hierarchy (closest first) + const pathParts = filePath.split("/") + + // Sort cache keys by how many directory levels they share with current file + const sortedPaths = Object.keys(cache).sort((a, b) => { + const aShared = getSharedDirectoryDepth(a, filePath) + const bShared = getSharedDirectoryDepth(b, filePath) + return bShared - aShared // Descending order (most shared first) + }) + + for (const cachedPath of sortedPaths) { + if (getSharedDirectoryDepth(cachedPath, filePath) > 0) { + passwords.push(cache[cachedPath].password) + } + } + + savePasswordCache(cache) + return passwords +} + +function getSharedDirectoryDepth(path1: string, path2: string): number { + const parts1 = path1.split("/") + const parts2 = path2.split("/") + let sharedDepth = 0 + + const minLength = Math.min(parts1.length, parts2.length) + for (let i = 0; i < minLength - 1; i++) { + // -1 to exclude filename + if (parts1[i] === parts2[i]) { + sharedDepth++ + } else { + break + } + } + + return sharedDepth +} + +// Helper: hex string to ArrayBuffer +function hexToArrayBuffer(hex: string): ArrayBuffer { + if (!hex) return new ArrayBuffer(0) + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.substr(i * 2, 2), 16) + } + return bytes.buffer +} + +// Helper: ArrayBuffer to hex string +function arrayBufferToHex(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") +} + +// Helper: string to ArrayBuffer +function stringToArrayBuffer(str: string): ArrayBuffer { + const encoder = new TextEncoder() + return encoder.encode(str) +} + +// Helper: ArrayBuffer to string +function arrayBufferToString(buffer: ArrayBuffer): string { + const decoder = new TextDecoder() + return decoder.decode(buffer) +} + +async function verifyPassword(password: string, parsed: any): Promise { + // Hash password with salt for verification using SubtleCrypto + const encoder = new TextEncoder() + const passwordBytes = encoder.encode(password) + const saltBytes = hexToArrayBuffer(parsed.salt) + + // Concatenate password and salt + const combined = new Uint8Array(passwordBytes.byteLength + saltBytes.byteLength) + combined.set(new Uint8Array(passwordBytes), 0) + combined.set(new Uint8Array(saltBytes), passwordBytes.byteLength) + + // Hash using SHA-256 + const hashBuffer = await crypto.subtle.digest("SHA-256", combined) + const passwordHash = arrayBufferToHex(hashBuffer) + + return passwordHash === parsed.passwordHash +} + +async function performDecryption(password: string, parsed: any, config: any): Promise { + const encoder = new TextEncoder() + const keyMaterial = await crypto.subtle.importKey( + "raw", + encoder.encode(password), + { name: "PBKDF2" }, + false, + ["deriveKey"], + ) + + // Derive key using PBKDF2 + const key = await crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: hexToArrayBuffer(parsed.salt), + iterations: config.iterations, + hash: "SHA-256", + }, + keyMaterial, + { name: getAlgorithmName(config.algorithm), length: config.keyLength * 8 }, + false, + ["decrypt"], + ) + + const ciphertext = hexToArrayBuffer(parsed.content) + + let decryptedBuffer: ArrayBuffer + + if (config.algorithm.includes("gcm")) { + // GCM mode + const iv = hexToArrayBuffer(parsed.iv) + const authTag = parsed.authTag ? hexToArrayBuffer(parsed.authTag) : null + + // For GCM, concatenate ciphertext + authTag + let fullCiphertext = ciphertext + if (authTag) { + const combined = new Uint8Array(ciphertext.byteLength + authTag.byteLength) + combined.set(new Uint8Array(ciphertext), 0) + combined.set(new Uint8Array(authTag), ciphertext.byteLength) + fullCiphertext = combined.buffer + } + + decryptedBuffer = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: iv, + }, + key, + fullCiphertext, + ) + } else if (config.algorithm.includes("cbc")) { + // CBC mode + const iv = hexToArrayBuffer(parsed.iv) + + decryptedBuffer = await crypto.subtle.decrypt( + { + name: "AES-CBC", + iv: iv, + }, + key, + ciphertext, + ) + } else if (config.algorithm.includes("ecb")) { + // ECB mode - simulate using CBC with zero IV (SubtleCrypto doesn't support ECB directly) + const zeroIv = new ArrayBuffer(16) // 16 bytes of zeros + + decryptedBuffer = await crypto.subtle.decrypt( + { + name: "AES-CBC", + iv: zeroIv, + }, + key, + ciphertext, + ) + } else { + throw new Error("Unsupported algorithm: " + config.algorithm) + } + + return arrayBufferToString(decryptedBuffer) +} + +function getAlgorithmName(algorithm: string): string { + if (algorithm.includes("gcm")) return "AES-GCM" + if (algorithm.includes("cbc")) return "AES-CBC" + if (algorithm.includes("ecb")) return "AES-CBC" // Use CBC for ECB simulation + throw new Error("Unsupported algorithm: " + algorithm) +} + +function showLoading(container: Element, show: boolean) { + const loadingDiv = container.querySelector(".decrypt-loading") as HTMLElement + const form = container.querySelector(".decrypt-form") as HTMLElement + + if (loadingDiv && form) { + if (show) { + form.style.display = "none" + loadingDiv.style.display = "flex" + } else { + form.style.display = "flex" + loadingDiv.style.display = "none" + } + } +} + +async function decryptWithPassword( + container: Element, + password: string, + showError = true, +): Promise { + const errorDiv = container.querySelector(".decrypt-error") as HTMLElement + const encryptedData = (container as HTMLElement).dataset.encrypted! + const config = JSON.parse((container as HTMLElement).dataset.config!) + + if (showError) errorDiv.style.display = "none" + + try { + const parsed = JSON.parse(atob(encryptedData)) + + // First verify password hash + const isValidPassword = await verifyPassword(password, parsed) + + if (!isValidPassword) { + if (showError) throw new Error("Incorrect password") + return false + } + + // Show loading indicator when hash passes and give UI time to update + if (showError) { + showLoading(container, true) + // Allow UI to update before starting heavy computation + await new Promise((resolve) => setTimeout(resolve, 50)) + } + + try { + // If hash matches, decrypt content + const decryptedContent = await performDecryption(password, parsed, config) + + if (decryptedContent) { + // Cache the password + const filePath = window.location.pathname + addPasswordToCache(password, filePath, config.ttl) + + // Replace content + const contentWrapper = document.createElement("div") + contentWrapper.className = "decrypted-content-wrapper" + contentWrapper.innerHTML = decryptedContent + container.parentNode!.replaceChild(contentWrapper, container) + return true + } + + if (showError) throw new Error("Decryption failed, check logs") + return false + } catch (decryptError) { + if (showError) showLoading(container, false) + if (showError) throw new Error("Decryption failed, check logs") + return false + } + } catch (error) { + if (showError) { + showLoading(container, false) + errorDiv.style.display = "block" + errorDiv.textContent = error instanceof Error ? error.message : "Decryption failed" + const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement + if (passwordInput) { + passwordInput.value = "" + passwordInput.focus() + } + } + return false + } +} + +async function tryAutoDecrypt(container: Element): Promise { + const filePath = window.location.pathname + const passwords = getRelevantPasswords(filePath) + + for (const password of passwords) { + if (await decryptWithPassword(container, password, false)) { + return true + } + } + return false +} + +async function manualDecrypt(container: Element) { + const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement + const password = passwordInput.value + + if (!password) { + passwordInput.focus() + return + } + + await decryptWithPassword(container, password, true) +} + +document.addEventListener("nav", async () => { + // Try auto-decryption for all encrypted content + const encryptedElements = document.querySelectorAll(".encrypted-content") + + for (const container of encryptedElements) { + await tryAutoDecrypt(container) + } + + // Manual decryption handlers + const buttons = document.querySelectorAll(".decrypt-button") + + buttons.forEach((button) => { + const handleClick = async function (this: HTMLElement) { + const container = this.closest(".encrypted-content")! + await manualDecrypt(container) + } + + button.addEventListener("click", handleClick) + window.addCleanup(() => button.removeEventListener("click", handleClick)) + }) + + // Enter key handler + document.querySelectorAll(".decrypt-password").forEach((input) => { + const handleKeypress = async function (this: HTMLInputElement, e: Event) { + const keyEvent = e as KeyboardEvent + if (keyEvent.key === "Enter") { + const container = this.closest(".encrypted-content")! + await manualDecrypt(container) + } + } + + input.addEventListener("keypress", handleKeypress) + window.addCleanup(() => input.removeEventListener("keypress", handleKeypress)) + }) +}) diff --git a/quartz/components/styles/encrypt.scss b/quartz/components/styles/encrypt.scss new file mode 100644 index 000000000..4128f6af8 --- /dev/null +++ b/quartz/components/styles/encrypt.scss @@ -0,0 +1,149 @@ +@use "../../styles/variables.scss" as *; + +.encrypted-content { + display: flex; + align-items: center; + justify-content: center; +} + +.encryption-notice { + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--lightgray); + border-radius: 8px; + padding: 2rem 1.5rem; + max-width: 450px; + width: 100%; + text-align: center; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + margin-top: 2rem; +} + +.encryption-icon { + font-size: 2.5rem; + margin-bottom: 0.5rem; + opacity: 0.7; +} + +.encryption-notice h3 { + margin: 0 0 0.5rem 0; + color: var(--darkgray); + font-size: 1.3rem; + font-weight: 600; +} + +.encryption-notice p { + margin: 0 0 1.5rem 0; + color: var(--gray); + line-height: 1.4; + font-size: 0.95rem; +} + +.decrypted-content-wrapper { + /* Reset any container styles that might interfere */ + display: block; + margin: 0; + padding: 0; +} + +.decrypt-form { + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; +} + +.decrypt-password { + width: 100%; + box-sizing: border-box; + padding: 0.75rem 1rem; + border-radius: 5px; + font-family: var(--bodyFont); + font-size: 1rem; + border: 1px solid var(--gray); + background: rgba(0, 0, 0, 0); + color: var(--dark); + transition: border-color 0.2s ease; +} + +.decrypt-password:focus { + outline: none; + border-color: var(--secondary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--secondary) 20%, transparent); +} + +.decrypt-button { + background: var(--secondary); + color: var(--light); + border: none; + border-radius: 5px; + padding: 0.75rem 2rem; + font-family: var(--bodyFont); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + min-width: 120px; +} + +.decrypt-button:hover { + background: color-mix(in srgb, var(--secondary) 90%, var(--dark)); + transform: translateY(-1px); + box-shadow: 0 4px 12px color-mix(in srgb, var(--secondary) 30%, transparent); +} + +.decrypt-button:active { + transform: translateY(0); +} + +.decrypt-loading { + display: none; + margin-top: 1rem; + padding: 0.75rem 1rem; + background: color-mix(in srgb, var(--secondary) 10%, var(--light)); + border: 1px solid color-mix(in srgb, var(--secondary) 30%, transparent); + border-radius: 5px; + color: var(--secondary); + font-size: 0.9rem; + text-align: center; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.loading-spinner { + width: 16px; + height: 16px; + border: 2px solid color-mix(in srgb, var(--secondary) 20%, transparent); + border-top: 2px solid var(--secondary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.decrypt-error { + display: none; + margin-top: 1rem; + padding: 0.75rem 1rem; + background: color-mix(in srgb, #ef4444 10%, var(--light)); + border: 1px solid color-mix(in srgb, #ef4444 30%, transparent); + border-radius: 5px; + color: #dc2626; + font-size: 0.9rem; + text-align: center; +} + +@media (max-width: 600px) { + .encryption-notice { + padding: 1.5rem 1rem; + margin: 1rem; + } + + .decrypt-password, + .decrypt-button { + font-size: 16px; /* Prevent zoom on iOS */ + } +} diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index 56392b358..ddfcf752b 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -19,6 +19,7 @@ export type ContentDetails = { richContent?: string date?: Date description?: string + encrypted?: boolean } interface Options { @@ -58,7 +59,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndexMap, limit?: ${escapeHTML(content.title)} https://${joinSegments(base, encodeURI(slug))} https://${joinSegments(base, encodeURI(slug))} - + ${content.date?.toUTCString()} ` @@ -115,6 +116,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { : undefined, date: date, description: file.data.description ?? "", + encrypted: file.data.encrypted, }) } } @@ -143,6 +145,11 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { // remove description and from content index as nothing downstream // actually uses it. we only keep it in the index as we need it // for the RSS feed + if (content.encrypted) { + delete content.content + delete content.richContent + } + delete content.description delete content.date return [slug, content] diff --git a/quartz/plugins/transformers/description.ts b/quartz/plugins/transformers/description.ts index 3f8519b32..bc075e5a1 100644 --- a/quartz/plugins/transformers/description.ts +++ b/quartz/plugins/transformers/description.ts @@ -28,6 +28,11 @@ export const Description: QuartzTransformerPlugin> = (userOpts) return [ () => { return async (tree: HTMLRoot, file) => { + if (file.data?.encrypted) { + file.data.description = "This file is encrypted. Open it to see the contents." + return; + } + let frontMatterDescription = file.data.frontmatter?.description let text = escapeHTML(toString(tree)) diff --git a/quartz/plugins/transformers/encrypt.ts b/quartz/plugins/transformers/encrypt.ts new file mode 100644 index 000000000..2b06881b0 --- /dev/null +++ b/quartz/plugins/transformers/encrypt.ts @@ -0,0 +1,224 @@ +import { createCipheriv, randomBytes, pbkdf2Sync, createHash } from "crypto" +import { QuartzTransformerPlugin } from "../types" +import { Root } from "hast" +import { toHtml } from "hast-util-to-html" +import { fromHtml } from "hast-util-from-html" +import { VFile } from "vfile" + +// @ts-ignore +import encryptScript from "../../components/scripts/encrypt.inline.ts" +import encryptStyle from "../../components/styles/encrypt.scss" + +export interface Options { + algorithm?: string + keyLength?: number + iterations?: number + encryptedFolders?: { [folderPath: string]: string } // json object with folder paths as keys and passwords as values + ttl: number +} + +const defaultOptions: Options = { + algorithm: "aes-256-cbc", + keyLength: 32, + iterations: 100000, + encryptedFolders: {}, + ttl: 3600 * 24 * 7, +} + +const SUPPORTED_ALGORITHMS = ["aes-256-cbc", "aes-256-gcm", "aes-256-ecb"] as const + +type SupportedAlgorithm = (typeof SUPPORTED_ALGORITHMS)[number] + +function deriveKey(password: string, salt: Buffer, keyLength: number, iterations: number): Buffer { + return pbkdf2Sync(password, salt, iterations, keyLength, "sha256") +} + +function hashPassword(password: string, salt: Buffer): string { + // Create a fast hash for password verification (separate from key derivation) + const hash = createHash("sha256") + hash.update(password) + hash.update(salt) + const result = hash.digest("hex") + return result +} + +function encryptContent(content: string, password: string, options: Options): string { + const { algorithm, keyLength, iterations } = { ...defaultOptions, ...options } + + // Validate algorithm + if (!SUPPORTED_ALGORITHMS.includes(algorithm as SupportedAlgorithm)) { + throw new Error( + `Unsupported encryption algorithm: ${algorithm}. Supported: ${SUPPORTED_ALGORITHMS.join(", ")}`, + ) + } + + const salt = randomBytes(16) + const key = deriveKey(password, salt, keyLength!, iterations!) + + let iv: Buffer | undefined + let cipher: any + + // Handle different encryption modes + if (algorithm === "aes-256-ecb") { + // ECB doesn't use IV + cipher = createCipheriv(algorithm!, key, null) + } else { + // CBC and GCM use IV + iv = randomBytes(16) + cipher = createCipheriv(algorithm!, key, iv) + } + + let encrypted = cipher.update(content, "utf8", "hex") + encrypted += cipher.final("hex") + + // Handle auth tag for GCM + let authTag: string | undefined + if (algorithm === "aes-256-gcm") { + authTag = cipher.getAuthTag().toString("hex") + } + + // Create password hash for verification + const passwordHash = hashPassword(password, salt) + + // Build result object + const result: any = { + salt: salt.toString("hex"), + content: encrypted, + passwordHash, + } + + if (iv) { + result.iv = iv.toString("hex") + } + + if (authTag) { + result.authTag = authTag + } + + return Buffer.from(JSON.stringify(result)).toString("base64") +} + +export const EncryptPlugin: QuartzTransformerPlugin> = (userOpts) => { + const opts = { ...defaultOptions, ...userOpts } + + // Validate algorithm at build time + if (opts.algorithm && !SUPPORTED_ALGORITHMS.includes(opts.algorithm as SupportedAlgorithm)) { + throw new Error( + `[EncryptPlugin] Unsupported encryption algorithm: ${opts.algorithm}. Supported algorithms: ${SUPPORTED_ALGORITHMS.join(", ")}`, + ) + } + + const getPassword = (file: VFile): string | undefined => { + const frontmatter = file.data?.frontmatter + + if (frontmatter?.encrypt && frontmatter?.password) { + return frontmatter.password as string + } + + let deepestFolder = ""; + for (const folder of Object.keys(opts.encryptedFolders ?? {})) { + if (file.data?.relativePath?.startsWith(folder) && deepestFolder.length < folder.length) { + deepestFolder = folder; + } + } + + if (deepestFolder) { + return String(opts.encryptedFolders[deepestFolder]) + } + } + + return { + name: "EncryptPlugin", + markdownPlugins() { + // If encypted, prepend lock emoji before the title + return [ + () => { + return (_, file) => { + const password = getPassword(file) + if (!password) { + return + } + + file.data.encrypted = true + + if (file.data?.frontmatter?.title) { + file.data.frontmatter.title = `🔒 ${file.data.frontmatter.title}` + } + } + }, + ] + }, + htmlPlugins() { + return [ + () => { + return (tree: Root, file) => { + const password = getPassword(file) + if (!password) { + return tree // No encryption, return original tree + } + + // Convert the HTML tree to string + const htmlContent = toHtml(tree) + + // Encrypt the content + const encryptedContent = encryptContent(htmlContent, password, opts) + + // Create a new tree with encrypted content placeholder + const encryptedTree = fromHtml( + ` +
+
+

🛡️ Restricted Content 🛡️

+

This content is restricted. Enter the password to view:

+
+ + +
+
+
+ Decrypting... +
+
+ Incorrect password. Please try again. +
+
+
+ `, + { fragment: true }, + ) + + // Replace the original tree + tree.children = encryptedTree.children + + return tree + } + }, + ] + }, + externalResources() { + return { + js: [ + { + loadTime: "afterDOMReady", + contentType: "inline", + script: encryptScript, + }, + ], + css: [ + { + content: encryptStyle, + inline: true, + }, + ], + additionalHead: [], + } + }, + } +} + + +declare module "vfile" { + interface DataMap { + encrypted: boolean + } +} diff --git a/quartz/plugins/transformers/index.ts b/quartz/plugins/transformers/index.ts index 8e2cd844f..74b57ef6c 100644 --- a/quartz/plugins/transformers/index.ts +++ b/quartz/plugins/transformers/index.ts @@ -8,6 +8,7 @@ export { CrawlLinks } from "./links" export { ObsidianFlavoredMarkdown } from "./ofm" export { OxHugoFlavouredMarkdown } from "./oxhugofm" export { SyntaxHighlighting } from "./syntax" +export { EncryptPlugin } from "./encrypt" export { TableOfContents } from "./toc" export { HardLineBreaks } from "./linebreaks" export { RoamFlavoredMarkdown } from "./roam"