From 4e02f7810e433f8cb5d2e042d61cc0ba6a57a00d Mon Sep 17 00:00:00 2001 From: Yigit Colakoglu Date: Thu, 31 Jul 2025 06:52:55 +0200 Subject: [PATCH] Tried to address the comments --- docs/plugins/Encrypt.md | 70 ++- index.d.ts | 8 +- quartz.config.ts | 4 +- quartz/components/ArticleTitle.tsx | 7 +- quartz/components/Explorer.tsx | 6 +- quartz/components/scripts/encrypt.inline.ts | 382 ++++-------- quartz/components/scripts/explorer.inline.ts | 33 +- quartz/components/scripts/graph.inline.ts | 5 + quartz/components/scripts/mermaid.inline.ts | 8 +- quartz/components/scripts/popover.inline.ts | 11 +- quartz/components/scripts/search.inline.ts | 84 ++- quartz/components/styles/encrypt.scss | 288 ++++----- quartz/plugins/emitters/contentIndex.tsx | 26 +- quartz/plugins/transformers/description.ts | 4 +- quartz/plugins/transformers/encrypt.ts | 205 +++---- quartz/plugins/transformers/frontmatter.ts | 20 +- quartz/plugins/transformers/index.ts | 2 +- quartz/util/encryption.ts | 595 +++++++++++++++++++ 18 files changed, 1174 insertions(+), 584 deletions(-) create mode 100644 quartz/util/encryption.ts diff --git a/docs/plugins/Encrypt.md b/docs/plugins/Encrypt.md index 191cda950..56397eda6 100644 --- a/docs/plugins/Encrypt.md +++ b/docs/plugins/Encrypt.md @@ -2,9 +2,9 @@ title: "Encrypt" tags: - plugin/transformer -encrypt: true -encrypt_message: '^ Password is "quartz"' -password: "quartz" +encryptConfig: + password: "quartz" + message: '^ Password is "quartz"' --- This plugin enables content encryption for sensitive pages in your Quartz site. It uses AES encryption with password-based access control, allowing you to protect specific pages or entire folders with passwords. @@ -16,28 +16,54 @@ This plugin enables content encryption for sensitive pages in your Quartz site. ```typescript Plugin.Encrypt({ - algorithm: "aes-256-cbc", // Encryption algorithm - keyLength: 32, // Key length in bytes - iterations: 100000, // PBKDF2 iterations + algorithm: "aes-256-cbc", // Encryption algorithm (key length auto-inferred) encryptedFolders: { - // Folder-level encryption + // Folder-level encryption with simple passwords "private/": "folder-password", "work/confidential/": "work-password", + // Advanced per-folder configuration + "secure/": { + password: "advanced-password", + algorithm: "aes-256-gcm", + ttl: 3600 * 24 * 30, // 30 days + }, }, ttl: 3600 * 24 * 7, // Password cache TTL in seconds (7 days) }) ``` +> [!warning] +> It is very important to note that: +> +> - All non-markdown files will be left unencrypted in the final build. +> - Marking something as encrypted will only encrypt the page in the final build. The file's content can still be viewed if your repository is public. + ### Configuration Options - `algorithm`: Encryption algorithm to use. Supported values: - `"aes-256-cbc"` (default): AES-256 in CBC mode - `"aes-256-gcm"`: AES-256 in GCM mode (authenticated encryption) - `"aes-256-ecb"`: AES-256 in ECB mode (not recommended for security) -- `keyLength`: Key length in bytes (default: 32 for AES-256) -- `iterations`: Number of PBKDF2 iterations for key derivation (default: 100000) -- `encryptedFolders`: Object mapping folder paths to passwords for folder-level encryption + - Key length is automatically inferred from the algorithm (e.g., 256-bit = 32 bytes) +- `encryptedFolders`: Object mapping folder paths to passwords or configuration objects for folder-level encryption - `ttl`: Time-to-live for cached passwords in seconds (default: 604800 = 7 days, set to 0 for session-only) +- `message`: Message to be displayed in the decryption page + +### Advanced Folder Configuration + +You can provide detailed configuration for each encrypted folder: + +```typescript +encryptedFolders: { + "basic/": "simple-password", // Simple string password + "advanced/": { + password: "complex-password", + algorithm: "aes-256-gcm", // Override global algorithm + ttl: 3600 * 24 * 30, // Override global TTL (30 days) + message: "This content is encrypted", // Message to be displayed + } +} +``` ## Usage @@ -59,34 +85,43 @@ All pages within these folders will be encrypted with the specified password. Ne ### Page-level Encryption -Use frontmatter to encrypt individual pages or override folder passwords: +Use frontmatter to encrypt individual pages or override folder passwords. ```yaml --- title: "My Secret Page" -encrypt: true password: "page-specific-password" -encrypt_message: "Sorry, this one is only for my eyes," +encryptConfig: + password: "password-also-allowed-here" + message: "Sorry, this one is only for my eyes" + algorithm: "aes-256-gcm" # Optional: override algorithm + ttl: 86400 # Optional: override TTL (1 day) --- This content will be encrypted and require a password to view. ``` ### Frontmatter Fields -The plugin recognizes these frontmatter fields: +#### encryptConfig object fields: + +- `password`: (required) The password required to decrypt this page +- `message`: (optional) Custom message to show on the unlock page +- `algorithm`: (optional) Override the encryption algorithm for this page +- `ttl`: (optional) Override password cache TTL for this page + +#### Legacy fields (still supported): - `encrypt`: Set to `true` to enable encryption for this page - `password`: The password required to decrypt this page -- `encrypt_message`: Message to be shown on the unlock page. -If a page is in an encrypted folder but has its own `password` field, the page-specific password will be used instead of the folder password. +If a page is in an encrypted folder but has its own password configuration, the page-specific settings will be used instead of the folder settings. ### Security Considerations - Use strong passwords for sensitive content - Consider using AES-256-GCM mode for authenticated encryption -- The default 100,000 PBKDF2 iterations provide good security but can be increased for higher security needs, or decreased for slow devices - ECB mode is provided for compatibility but is not recommended for security-critical applications +- Key lengths are automatically determined by the algorithm (no manual configuration needed) ## Security Features @@ -103,7 +138,6 @@ The plugin implements intelligent password caching: - **Full Content Encryption**: The entire HTML content is encrypted, not just hidden - **SEO Protection**: Search engines and RSS feeds see generic placeholder descriptions - **Client-side Decryption**: Content is never transmitted in plain text -- **Secure Key Derivation**: Uses PBKDF2 with configurable iterations - **Password Verification**: Fast password hash verification before attempting decryption ## API diff --git a/index.d.ts b/index.d.ts index 9011ee38f..70ec9c26f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -6,10 +6,14 @@ declare module "*.scss" { // dom custom event interface CustomEventMap { prenav: CustomEvent<{}> - nav: CustomEvent<{ url: FullSlug }> + nav: CustomEvent<{ url: FullSlug; rerender?: boolean }> themechange: CustomEvent<{ theme: "light" | "dark" }> readermodechange: CustomEvent<{ mode: "on" | "off" }> } -type ContentIndex = Record +type DecryptedFlag = { + decrypted?: boolean +} + +type ContentIndex = Record declare const fetchData: Promise diff --git a/quartz.config.ts b/quartz.config.ts index 52ae335f2..8914f6363 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -71,10 +71,8 @@ const config: QuartzConfig = { Plugin.TableOfContents(), Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }), Plugin.Latex({ renderEngine: "katex" }), - Plugin.EncryptPlugin({ + Plugin.Encrypt({ algorithm: "aes-256-cbc", - keyLength: 32, - iterations: 100000, encryptedFolders: {}, ttl: 3600 * 24 * 7, // A week }), diff --git a/quartz/components/ArticleTitle.tsx b/quartz/components/ArticleTitle.tsx index 318aeb24e..7e08c8799 100644 --- a/quartz/components/ArticleTitle.tsx +++ b/quartz/components/ArticleTitle.tsx @@ -4,7 +4,12 @@ import { classNames } from "../util/lang" const ArticleTitle: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => { const title = fileData.frontmatter?.title if (title) { - return

{title}

+ return ( +

+ {fileData.encryptionResult && ๐Ÿ”’ } + {title} +

+ ) } else { return null } diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx index 56784f132..3827587d7 100644 --- a/quartz/components/Explorer.tsx +++ b/quartz/components/Explorer.tsx @@ -30,7 +30,7 @@ const defaultOptions: Options = { return node }, sortFn: (a, b) => { - // Sort order: folders first, then files. Sort folders and files alphabeticall + // Sort order: folders first, then files. Sort folders and files alphabetically if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) { // numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10" // sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a โ‰  b, a = รก, a = A @@ -121,6 +121,7 @@ export default ((userOpts?: Partial) => { @@ -143,7 +144,8 @@ export default ((userOpts?: Partial) => {
diff --git a/quartz/components/scripts/encrypt.inline.ts b/quartz/components/scripts/encrypt.inline.ts index fd01deab4..62cd62e0f 100644 --- a/quartz/components/scripts/encrypt.inline.ts +++ b/quartz/components/scripts/encrypt.inline.ts @@ -1,222 +1,15 @@ -// Password cache management -const PASSWORD_CACHE_KEY = "quartz-encrypt-passwords" +import { + decryptContent, + verifyPasswordHash, + addPasswordToCache, + Hash, + EncryptionConfig, + searchForValidPassword, + EncryptionResult, +} from "../../util/encryption" +import { FullSlug, getFullSlug } from "../../util/path" -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) - - // 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).buffer as ArrayBuffer -} - -// 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 showLoading = (container: Element, show: boolean) => { const loadingDiv = container.querySelector(".decrypt-loading") as HTMLElement const form = container.querySelector(".decrypt-form") as HTMLElement @@ -231,26 +24,35 @@ function showLoading(container: Element, show: boolean) { } } -async function decryptWithPassword( +const decryptWithPassword = async ( 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!) - const i18n = JSON.parse((container as HTMLElement).dataset.i18n!) +): Promise => { + const errorDivs = container.querySelectorAll(".decrypt-error") as NodeListOf + const containerElement = container as HTMLElement - if (showError) errorDiv.style.display = "none" + const config = JSON.parse(containerElement.dataset.config!) as EncryptionConfig + const encrypted = JSON.parse(containerElement.dataset.encrypted!) as EncryptionResult + const hash = JSON.parse(containerElement.dataset.hash!) as Hash + + if (showError) errorDivs.forEach((div) => (div.style.display = "none")) try { - const parsed = JSON.parse(atob(encryptedData)) + // Check if crypto.subtle is available (especially important for older iOS versions) + if (!crypto || !crypto.subtle) { + throw new Error( + "This device does not support the required encryption features. Please use a modern browser.", + ) + } // First verify password hash - const isValidPassword = await verifyPassword(password, parsed) + let isValidPassword: boolean + + isValidPassword = await verifyPasswordHash(password, hash) if (!isValidPassword) { - if (showError) throw new Error(i18n.incorrectPassword) + if (showError) throw new Error("incorrect-password") return false } @@ -262,13 +64,14 @@ async function decryptWithPassword( } try { - // If hash matches, decrypt content - const decryptedContent = await performDecryption(password, parsed, config) + let decryptedContent: string + + decryptedContent = await decryptContent(encrypted, config, password) if (decryptedContent) { // Cache the password - const filePath = window.location.pathname - addPasswordToCache(password, filePath, config.ttl) + const filePath = getFullSlug(window) + await addPasswordToCache(password, filePath, config.ttl) // Replace content const contentWrapper = document.createElement("div") @@ -278,41 +81,100 @@ async function decryptWithPassword( return true } - if (showError) throw new Error(i18n.decryptionFailed) + if (showError) throw new Error("decryption-failed") return false } catch (decryptError) { if (showError) showLoading(container, false) - if (showError) throw new Error(i18n.decryptionFailed) + if (showError) throw new Error("decryption-failed") return false } } catch (error) { if (showError) { showLoading(container, false) - errorDiv.style.display = "block" - errorDiv.textContent = error instanceof Error ? error.message : "Decryption failed" + const errorMessage = error instanceof Error ? error.message : null + + errorDivs.forEach((div) => { + if (div.dataset.error == errorMessage) { + div.style.display = "block" + } + }) const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement + const decryptButton = container.querySelector(".decrypt-button") as HTMLButtonElement + + // Check if we're in a popover context + const isInPopover = container.closest(".popover") !== null + if (passwordInput) { passwordInput.value = "" - passwordInput.focus() + if (isInPopover) { + // Disable input and button in popover on failure + passwordInput.disabled = true + if (decryptButton) { + decryptButton.disabled = true + } + } else { + passwordInput.focus() + } } } return false } } -async function tryAutoDecrypt(container: Element): Promise { - const filePath = window.location.pathname - const passwords = getRelevantPasswords(filePath) +const notifyNav = (url: FullSlug) => { + const event: CustomEventMap["nav"] = new CustomEvent("nav", { + detail: { url, rerender: true }, + }) + document.dispatchEvent(event) +} - for (const password of passwords) { - if (await decryptWithPassword(container, password, false)) { - return true +const getFirstParentWithClass = (container: HTMLElement, className: string): HTMLElement | null => { + let current = container.parentElement + while (current) { + if (current.classList.contains(className)) { + return current } + current = current.parentElement + } + return null +} + +function updateTitle(container: HTMLElement | null) { + console.log(container) + if (container) { + const span = container.querySelector(".article-title-icon") as HTMLElement + if (span) { + span.textContent = "๐Ÿ”“ " + } + } +} + +const tryAutoDecrypt = async (container: HTMLElement): Promise => { + const filePath = getFullSlug(window) + const fullSlug = container.dataset.slug as FullSlug + const config = JSON.parse(container.dataset.config!) as EncryptionConfig + const hash = JSON.parse(container.dataset.hash!) as Hash + const parent = + (container.closest(".popover") as HTMLElement) || + (container.closest(".preview-inner") as HTMLElement) || + (container.closest(".center") as HTMLElement) || + null + + const password = await searchForValidPassword(fullSlug, hash, config) + + if (password && (await decryptWithPassword(container, password, false))) { + notifyNav(filePath) + updateTitle(parent) + return true } return false } -async function manualDecrypt(container: Element) { +const manualDecrypt = async (container: HTMLElement) => { + const parent = + (container.closest(".preview-inner") as HTMLElement) || + (container.closest(".center") as HTMLElement) || + null const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement const password = passwordInput.value @@ -321,15 +183,21 @@ async function manualDecrypt(container: Element) { return } - await decryptWithPassword(container, password, true) + if (await decryptWithPassword(container, password, true)) { + const filePath = getFullSlug(window) + notifyNav(filePath) + updateTitle(parent) + } } document.addEventListener("nav", async () => { // Try auto-decryption for all encrypted content - const encryptedElements = document.querySelectorAll(".encrypted-content") + const encryptedElements = document.querySelectorAll( + ".encrypted-content", + ) as NodeListOf - for (const container of encryptedElements) { - await tryAutoDecrypt(container) + for (const encryptedContainer of encryptedElements) { + await tryAutoDecrypt(encryptedContainer) } // Manual decryption handlers @@ -337,12 +205,15 @@ document.addEventListener("nav", async () => { buttons.forEach((button) => { const handleClick = async function (this: HTMLElement) { - const container = this.closest(".encrypted-content")! - await manualDecrypt(container) + const encryptedContainer = this.closest(".encrypted-content")! + await manualDecrypt(encryptedContainer as HTMLElement) } button.addEventListener("click", handleClick) - window.addCleanup(() => button.removeEventListener("click", handleClick)) + // Check if window.addCleanup exists before using it + if (window.addCleanup) { + window.addCleanup(() => button.removeEventListener("click", handleClick)) + } }) // Enter key handler @@ -350,12 +221,15 @@ document.addEventListener("nav", async () => { 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) + const encryptedContainer = this.closest(".encrypted-content")! + await manualDecrypt(encryptedContainer as HTMLElement) } } input.addEventListener("keypress", handleKeypress) - window.addCleanup(() => input.removeEventListener("keypress", handleKeypress)) + // Check if window.addCleanup exists before using it + if (window.addCleanup) { + window.addCleanup(() => input.removeEventListener("keypress", handleKeypress)) + } }) }) diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts index 9c8341169..6361196f3 100644 --- a/quartz/components/scripts/explorer.inline.ts +++ b/quartz/components/scripts/explorer.inline.ts @@ -1,6 +1,7 @@ import { FileTrieNode } from "../../util/fileTrie" import { FullSlug, resolveRelative, simplifySlug } from "../../util/path" import { ContentDetails } from "../../plugins/emitters/contentIndex" +import { contentDecryptedEventListener } from "../../util/encryption" type MaybeHTMLElement = HTMLElement | undefined @@ -88,6 +89,18 @@ function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElemen a.dataset.for = node.slug a.textContent = node.displayName + const span = li.querySelector("span") as HTMLSpanElement + + if (span && node.data?.encryptionResult) { + span.textContent = "๐Ÿ”’ " + + contentDecryptedEventListener(node.slug, node.data.hash!, node.data.encryptionConfig!, () => { + span.textContent = "๐Ÿ”“ " + }) + } else if (span) { + span.remove() + } + if (currentSlug === node.slug) { a.classList.add("active") } @@ -111,6 +124,15 @@ function createFolderNode( const folderPath = node.slug folderContainer.dataset.folderpath = folderPath + const span = titleContainer.querySelector("span.folder-title-icon") as HTMLElement + + if (span && node.data?.encryptionResult) { + span.textContent = "๐Ÿ”’ " + contentDecryptedEventListener(folderPath, node.data.hash!, node.data.encryptionConfig!, () => { + span.textContent = "๐Ÿ”“ " + }) + } + if (opts.folderClickBehavior === "link") { // Replace button with link for link behavior const button = titleContainer.querySelector(".folder-button") as HTMLElement @@ -120,8 +142,9 @@ function createFolderNode( a.className = "folder-title" a.textContent = node.displayName button.replaceWith(a) + titleContainer.insertBefore(span, a) } else { - const span = titleContainer.querySelector(".folder-title") as HTMLElement + const span = titleContainer.querySelector(".folder-title-text") as HTMLElement span.textContent = node.displayName } @@ -173,6 +196,7 @@ async function setupExplorer(currentSlug: FullSlug) { ) const data = await fetchData + const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][] const trie = FileTrieNode.fromEntries(entries) @@ -267,6 +291,13 @@ document.addEventListener("prenav", async () => { document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { const currentSlug = e.detail.url + const rerender = e.detail.rerender + + // If this is secondary nav call, do not populate explorer again + if (rerender) { + return + } + await setupExplorer(currentSlug) // if mobile hamburger is visible, collapse by default diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index a669b0547..2f75fa218 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -95,6 +95,7 @@ async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) { v, ]), ) + const links: SimpleLinkData[] = [] const tags: SimpleSlug[] = [] const validLinks = new Set(data.keys()) @@ -575,6 +576,10 @@ function cleanupGlobalGraphs() { document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { const slug = e.detail.url + + // if we are rerendering, we can ignore this event + if (e.detail.rerender) return + addToVisited(simplifySlug(slug)) async function renderLocalGraph() { diff --git a/quartz/components/scripts/mermaid.inline.ts b/quartz/components/scripts/mermaid.inline.ts index 19ef24db1..533e38555 100644 --- a/quartz/components/scripts/mermaid.inline.ts +++ b/quartz/components/scripts/mermaid.inline.ts @@ -143,9 +143,11 @@ const cssVars = [ ] as const let mermaidImport = undefined + document.addEventListener("nav", async () => { - const center = document.querySelector(".center") as HTMLElement - const nodes = center.querySelectorAll("code.mermaid") as NodeListOf + const nodes = document.querySelectorAll( + "code.mermaid:not([data-processed])", + ) as NodeListOf if (nodes.length === 0) return mermaidImport ||= await import( @@ -162,9 +164,9 @@ document.addEventListener("nav", async () => { async function renderMermaid() { // de-init any other diagrams for (const node of nodes) { - node.removeAttribute("data-processed") const oldText = textMapping.get(node) if (oldText) { + node.removeAttribute("data-processed") node.innerHTML = oldText } } diff --git a/quartz/components/scripts/popover.inline.ts b/quartz/components/scripts/popover.inline.ts index 989af7ee8..d48fecf51 100644 --- a/quartz/components/scripts/popover.inline.ts +++ b/quartz/components/scripts/popover.inline.ts @@ -1,10 +1,17 @@ import { computePosition, flip, inline, shift } from "@floating-ui/dom" -import { normalizeRelativeURLs } from "../../util/path" +import { FullSlug, normalizeRelativeURLs } from "../../util/path" import { fetchCanonical } from "./util" const p = new DOMParser() let activeAnchor: HTMLAnchorElement | null = null +function notifyNav(url: FullSlug) { + const event: CustomEventMap["nav"] = new CustomEvent("nav", { + detail: { url, rerender: true }, + }) + document.dispatchEvent(event) +} + async function mouseEnterHandler( this: HTMLAnchorElement, { clientX, clientY }: { clientX: number; clientY: number }, @@ -37,6 +44,8 @@ async function mouseEnterHandler( popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" }) } } + + notifyNav(link.getAttribute("href") as FullSlug) } const targetUrl = new URL(link.href) diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index 27f74ecb8..3183f47b9 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -1,7 +1,7 @@ import FlexSearch from "flexsearch" -import { ContentDetails } from "../../plugins/emitters/contentIndex" import { registerEscapeHandler, removeAllChildren } from "./util" import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path" +import { contentDecryptedEventListener, decryptContent } from "../../util/encryption" interface Item { id: number @@ -143,6 +143,13 @@ function highlightHTML(searchTerm: string, el: HTMLElement) { return html.body } +function notifyNav(url: FullSlug) { + const event: CustomEventMap["nav"] = new CustomEvent("nav", { + detail: { url, rerender: true }, + }) + document.dispatchEvent(event) +} + async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: ContentIndex) { const container = searchElement.querySelector(".search-container") as HTMLElement if (!container) return @@ -265,10 +272,18 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: const formatForDisplay = (term: string, id: number) => { const slug = idDataMap[id] + let title = data[slug].title + + if (data[slug].decrypted === false) { + title = "๐Ÿ”’ " + title + } else if (data[slug].decrypted === true) { + title = "๐Ÿ”“ " + title + } + return { id, slug, - title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""), + title: searchType === "tags" ? title : highlight(term, title ?? ""), content: highlight(term, data[slug].content ?? "", true), tags: highlightTags(term.substring(1), data[slug].tags), } @@ -389,6 +404,9 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: (a, b) => b.innerHTML.length - a.innerHTML.length, ) highlights[0]?.scrollIntoView({ block: "start" }) + await new Promise((resolve) => setTimeout(resolve, 100)) + + notifyNav(slug) } async function onType(e: HTMLElementEventMap["input"]) { @@ -470,16 +488,55 @@ async function fillDocument(data: ContentIndex) { if (indexPopulated) return let id = 0 const promises: Array> = [] - for (const [slug, fileData] of Object.entries(data)) { - promises.push( - index.addAsync(id++, { - id, - slug: slug as FullSlug, - title: fileData.title, - content: fileData.content, - tags: fileData.tags, - }), - ) + for (const [slug, fileData] of Object.entries(data)) { + if (fileData.encryptionResult) { + const slugId = id + promises.push( + index.addAsync(id, { + id: id++, + slug: slug as FullSlug, + title: fileData.title, + content: "", + tags: fileData.tags, + }), + ) + fileData.decrypted = false + + promises.push( + contentDecryptedEventListener( + slug, + fileData.hash!, + fileData.encryptionConfig!, + async (password) => { + const decryptedContent = await decryptContent( + fileData.encryptionResult!, + fileData.encryptionConfig!, + password, + ) + + fileData.decrypted = true + + index.updateAsync(slugId, { + id, + slug: slug as FullSlug, + title: fileData.title, + content: decryptedContent, + tags: fileData.tags, + }) + }, + ), + ) + } else { + promises.push( + index.addAsync(id++, { + id, + slug: slug as FullSlug, + title: fileData.title, + content: fileData.content, + tags: fileData.tags, + }), + ) + } } await Promise.all(promises) @@ -487,6 +544,9 @@ async function fillDocument(data: ContentIndex) { } document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { + // Ignore rerender events + if (e.detail.rerender) return + const currentSlug = e.detail.url const data = await fetchData const searchElement = document.getElementsByClassName("search") diff --git a/quartz/components/styles/encrypt.scss b/quartz/components/styles/encrypt.scss index b8dc5aec0..d7327351f 100644 --- a/quartz/components/styles/encrypt.scss +++ b/quartz/components/styles/encrypt.scss @@ -4,18 +4,158 @@ display: flex; align-items: center; justify-content: center; + + .encryption-notice { + background: color-mix(in srgb, var(--lightgray) 60%, var(--light)); + border: 1px solid var(--lightgray); + border-radius: 5px; + padding: 2rem 1.5rem; + max-width: 450px; + width: 100%; + text-align: center; + box-shadow: 0 6px 36px rgba(0, 0, 0, 0.15); + margin-top: 2rem; + font-family: var(--bodyFont); + + & h3 { + margin: 0 0 0.5rem 0; + color: var(--dark); + font-size: 1.3rem; + font-weight: $semiBoldWeight; + font-family: var(--headerFont); + } + + & p { + color: var(--darkgray); + line-height: 1.5; + font-size: 0.95rem; + margin: 0 0 1rem 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(--lightgray); + background: var(--light); + color: var(--dark); + transition: border-color 0.2s ease; + + &:focus { + outline: none; + border-color: var(--secondary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--secondary) 20%, transparent); + } + + @media all and ($mobile) { + font-size: 16px; // Prevent zoom on iOS + } + } + + .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: $semiBoldWeight; + cursor: pointer; + transition: all 0.2s ease; + min-width: 120px; + + &: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); + } + + &:active { + transform: translateY(0); + } + + @media all and ($mobile) { + font-size: 16px; // Prevent zoom on iOS + } + } + + .encrypted-message-footnote { + color: var(--gray); + font-size: 0.85rem; + opacity: 0.8; + text-align: center; + width: 100%; + font-style: italic; + margin: 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; + } + } + + .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 all and ($mobile) { + padding: 1.5rem 1rem; + margin: 1rem; + } + } } -.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; +// Hide the decrypt form when encrypted content appears in popover and search +.popover .encrypted-content .encryption-notice .decrypt-form { + display: none; +} + +.search-space .encrypted-content .encryption-notice .decrypt-form { + display: none; +} + +.decrypted-content-wrapper { + display: block; + margin: 0; + padding: 0; } .encryption-icon { @@ -24,100 +164,6 @@ 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 { - 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); @@ -126,37 +172,3 @@ 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 */ - } -} - -.encrypted-message-footnote { - color: var(--gray); - font-size: 0.85rem; - opacity: 0.8; - text-align: center; - width: 100%; - font-style: italic; - margin: 0; -} diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index 94ab67b1c..eda0c322f 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -7,8 +7,11 @@ import { QuartzEmitterPlugin } from "../types" import { toHtml } from "hast-util-to-html" import { write } from "./helpers" import { i18n } from "../../i18n" +import { Hash, EncryptionResult, EncryptionConfig } from "../../util/encryption" export type ContentIndexMap = Map + +// Base content details without encryption-specific fields export type ContentDetails = { slug: FullSlug filePath: FilePath @@ -19,7 +22,9 @@ export type ContentDetails = { richContent?: string date?: Date description?: string - encrypted?: boolean + encryptionConfig?: EncryptionConfig + hash?: Hash + encryptionResult?: EncryptionResult } interface Options { @@ -104,7 +109,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { const slug = file.data.slug! const date = getDate(ctx.cfg.configuration, file.data) ?? new Date() if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { - linkIndex.set(slug, { + const contentDetails: ContentDetails = { slug, filePath: file.data.relativePath!, title: file.data.frontmatter?.title!, @@ -112,13 +117,17 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { tags: file.data.frontmatter?.tags ?? [], content: file.data.text ?? "", richContent: - !file.data.encrypted && opts?.rssFullHtml + !file.data.encryptionResult && opts?.rssFullHtml ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) : undefined, date: date, description: file.data.description ?? "", - encrypted: file.data.encrypted, - }) + encryptionConfig: file.data.encryptionConfig, + hash: file.data.hash, + encryptionResult: file.data.encryptionResult, + } + + linkIndex.set(slug, contentDetails) } } @@ -146,9 +155,12 @@ 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) { - content.description = "" + if (content.encryptionResult) { delete content.richContent + } else { + delete content.hash + delete content.encryptionConfig + delete content.encryptionResult } delete content.description diff --git a/quartz/plugins/transformers/description.ts b/quartz/plugins/transformers/description.ts index aa8d20a84..9036436b9 100644 --- a/quartz/plugins/transformers/description.ts +++ b/quartz/plugins/transformers/description.ts @@ -29,9 +29,9 @@ export const Description: QuartzTransformerPlugin> = (userOpts) return [ () => { return async (tree: HTMLRoot, file) => { - if (file.data?.encrypted) { + if (file.data?.encrypted && file.data.encryptionConfig) { file.data.description = - file.data.encryptMessage || + file.data.encryptionConfig.message || i18n(ctx.cfg.configuration.locale).components.encryption.encryptedDescription return } diff --git a/quartz/plugins/transformers/encrypt.ts b/quartz/plugins/transformers/encrypt.ts index c6d095b33..e4d2d0bd6 100644 --- a/quartz/plugins/transformers/encrypt.ts +++ b/quartz/plugins/transformers/encrypt.ts @@ -1,159 +1,86 @@ -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 { toString } from "hast-util-to-string" import { VFile } from "vfile" import { i18n } from "../../i18n" +import { + EncryptionOptions, + DirectoryConfig, + defaultEncryptionConfig, + SUPPORTED_ALGORITHMS, + SupportedEncryptionAlgorithm, + encryptContent, + getEncryptionConfigForPath, + Hash, + hashString, + EncryptionResult, + EncryptionConfig, +} from "../../util/encryption" // @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 -} +export interface Options extends EncryptionOptions {} const defaultOptions: Options = { - algorithm: "aes-256-cbc", - keyLength: 32, - iterations: 100000, + ...defaultEncryptionConfig, 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) => { +export const Encrypt: QuartzTransformerPlugin> = (userOpts) => { const opts = { ...defaultOptions, ...userOpts } // Validate algorithm at build time - if (opts.algorithm && !SUPPORTED_ALGORITHMS.includes(opts.algorithm as SupportedAlgorithm)) { + if (opts.algorithm && !SUPPORTED_ALGORITHMS.includes(opts.algorithm as SupportedEncryptionAlgorithm)) { throw new Error( `[EncryptPlugin] Unsupported encryption algorithm: ${opts.algorithm}. Supported algorithms: ${SUPPORTED_ALGORITHMS.join(", ")}`, ) } - const getPassword = (file: VFile): string | undefined => { + const getEncryptionConfig = (file: VFile): DirectoryConfig | undefined => { const frontmatter = file.data?.frontmatter + const frontmatterConfig = (frontmatter?.encryptConfig ?? {}) as DirectoryConfig + const relativePath = file.data?.relativePath - if (frontmatter?.encrypt && frontmatter?.password) { - return frontmatter.password as string + const folderConfig = relativePath ? getEncryptionConfigForPath(relativePath, opts) : null + + if (!folderConfig && !frontmatterConfig.password) { + return undefined + } else if (!folderConfig && !frontmatter?.encrypt) { + return undefined } - let deepestFolder = "" - for (const folder of Object.keys(opts.encryptedFolders ?? {})) { - if (file.data?.relativePath?.startsWith(folder) && deepestFolder.length < folder.length) { - deepestFolder = folder - } + const config = { + algorithm: frontmatterConfig.algorithm || folderConfig?.algorithm || opts.algorithm, + password: frontmatterConfig.password || folderConfig?.password || "", + message: frontmatterConfig.message || folderConfig?.message || opts.message, + ttl: frontmatterConfig.ttl || folderConfig?.ttl || opts.ttl, } - if (deepestFolder) { - if (frontmatter?.password) { - // if frontmatter has a password, use it - return frontmatter.password as string - } - - return opts.encryptedFolders!![deepestFolder] as string + if (!config.password) { + return undefined } + + return config } return { - name: "EncryptPlugin", + name: "Encrypt", markdownPlugins() { - // If encypted, prepend lock emoji before the title + // If encrypted, prepend lock emoji before the title return [ () => { - return (_, file) => { - const password = getPassword(file) - if (!password) { + return async (_, file) => { + const config = getEncryptionConfig(file) + if (!config) { return } - file.data.encrypted = true - file.data.password = password - if (file.data?.frontmatter?.encryptMessage) { - file.data.encryptMessage = file.data.frontmatter.encryptMessage as string - } - - if (file.data?.frontmatter?.title) { - file.data.frontmatter.title = `๐Ÿ”’ ${file.data.frontmatter.title}` - } + file.data.encryptionConfig = config + file.data.hash = await hashString(config.password) } }, ] @@ -161,41 +88,55 @@ export const EncryptPlugin: QuartzTransformerPlugin> = (userOpt htmlPlugins(ctx) { return [ () => { - return (tree: Root, file) => { - const password = getPassword(file) - if (!password) { - return tree // No encryption, return original tree + return async (tree: Root, file) => { + const config = getEncryptionConfig(file) + + if (!file.data.hash || !config) { + return tree } const locale = ctx.cfg.configuration.locale const t = i18n(locale).components.encryption - // Convert the HTML tree to string - const htmlContent = toHtml(tree) + // Convert html to plaintext and encrypt it + file.data.encryptionResult = await encryptContent( + toString(tree), + config.password, + config, + ) - // Encrypt the content - const encryptedContent = encryptContent(htmlContent, password, opts) - console.log(file.data) + // Encrypt the content and generate verification hash + const encryptionResult = await encryptContent(toHtml(tree), config.password, config) + + // Create individual attributes for each field instead of JSON + const attributes = [ + `data-config='${JSON.stringify(config)}'`, + `data-encrypted='${JSON.stringify(encryptionResult)}'`, + `data-hash='${JSON.stringify(file.data.hash)}'`, + `data-slug='${file.data.slug}'`, + ].join(" ") // Create a new tree with encrypted content placeholder const encryptedTree = fromHtml( ` -
+

${t.title}

-

${t.restricted}

+ ${config.message ? `

${config.message}

` : ""}
- ${file.data.encryptMessage ? `

${file.data.encryptMessage}

` : ""}
${t.decrypting}
-
+
${t.incorrectPassword}
+
+ ${t.decryptionFailed} +
`, @@ -233,8 +174,8 @@ export const EncryptPlugin: QuartzTransformerPlugin> = (userOpt declare module "vfile" { interface DataMap { - encrypted: boolean - encryptMessage?: string - password?: string + encryptionConfig: EncryptionConfig + encryptionResult: EncryptionResult + hash: Hash } } diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index ed5bc935b..0979385d3 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -6,6 +6,7 @@ import toml from "toml" import { FilePath, FullSlug, getFileExtension, slugifyFilePath, slugTag } from "../../util/path" import { QuartzPluginData } from "../vfile" import { i18n } from "../../i18n" +import { DirectoryConfig } from "../../util/encryption" export interface Options { delimiters: string | [string, string] @@ -122,14 +123,17 @@ export const FrontMatter: QuartzTransformerPlugin> = (userOpts) if (encrypted) data.encrypt = true const password = coalesceAliases(data, ["password"]) - if (password) data.password = password + if (password) data.encryptConfig = { password: password } - const encryptMessage = coalesceAliases(data, [ - "encryptMessage", - "encrypt_message", - "encrypt-message", - ]) - if (encryptMessage) data.encryptMessage = encryptMessage + const encryptConfig = coalesceAliases(data, ["encryptConfig", "encrypt_config"]) + if (encryptConfig && typeof encryptConfig === "object") { + data.encryptConfig = { + password: encryptConfig.password || password, + message: encryptConfig.message || undefined, + algorithm: encryptConfig.algorithm || undefined, + ttl: encryptConfig.ttl || undefined, + } + } // Remove duplicate slugs const uniqueSlugs = [...new Set(allSlugs)] @@ -164,6 +168,8 @@ declare module "vfile" { cssclasses: string[] socialImage: string comments: boolean | string + encrypt: boolean + encryptConfig: DirectoryConfig }> } } diff --git a/quartz/plugins/transformers/index.ts b/quartz/plugins/transformers/index.ts index 74b57ef6c..5270d5f05 100644 --- a/quartz/plugins/transformers/index.ts +++ b/quartz/plugins/transformers/index.ts @@ -8,7 +8,7 @@ export { CrawlLinks } from "./links" export { ObsidianFlavoredMarkdown } from "./ofm" export { OxHugoFlavouredMarkdown } from "./oxhugofm" export { SyntaxHighlighting } from "./syntax" -export { EncryptPlugin } from "./encrypt" +export { Encrypt } from "./encrypt" export { TableOfContents } from "./toc" export { HardLineBreaks } from "./linebreaks" export { RoamFlavoredMarkdown } from "./roam" diff --git a/quartz/util/encryption.ts b/quartz/util/encryption.ts new file mode 100644 index 000000000..cd138f1d3 --- /dev/null +++ b/quartz/util/encryption.ts @@ -0,0 +1,595 @@ +// ============================================================================= +// TYPES AND INTERFACES +// ============================================================================= + +export const SUPPORTED_ALGORITHMS = ["aes-256-cbc", "aes-256-gcm", "aes-256-ecb"] as const + +export type SupportedEncryptionAlgorithm = (typeof SUPPORTED_ALGORITHMS)[number] + +export interface Hash { + hash: string + salt: string +} + +export interface EncryptionResult { + encryptedContent: string + encryptionSalt: string + iv?: string + authTag?: string +} + +export interface EncryptionConfig { + algorithm: string + ttl: number + message: string +} + +export interface DirectoryConfig extends EncryptionConfig { + password: string +} + +export interface EncryptionOptions { + algorithm: string + encryptedFolders: { [folderPath: string]: string | DirectoryConfig } + message: string + ttl: number +} + +// ============================================================================= +// CONSTANTS AND CONFIGURATION +// ============================================================================= + +export const defaultEncryptionConfig: EncryptionConfig = { + algorithm: "aes-256-cbc", + ttl: 3600 * 24 * 7, + message: "This content is encrypted.", +} + +const ENCRYPTION_CACHE_KEY = "quartz-encrypt-passwords" + +// ============================================================================= +// CRYPTO INITIALIZATION +// ============================================================================= + +// Unified crypto interface for both Node.js and browser environments +let crypto: Crypto +if (typeof globalThis !== "undefined" && globalThis.crypto) { + crypto = globalThis.crypto +} else if (typeof window !== "undefined" && window.crypto) { + crypto = window.crypto +} else { + // Node.js environment + try { + const { webcrypto } = require("node:crypto") + crypto = webcrypto as Crypto + } catch { + throw new Error("No crypto implementation available") + } +} + +// ============================================================================= +// UTILITY FUNCTIONS +// ============================================================================= + +// Check if crypto.subtle is available and supported +function checkCryptoSupport(): void { + if (!crypto || !crypto.subtle) { + throw new Error("Web Crypto API is not supported in this environment") + } +} + +function deriveKeyLengthFromAlgorithm(algorithm: string): number { + if (algorithm.includes("256")) return 32 // 256 bits = 32 bytes + if (algorithm.includes("192")) return 24 // 192 bits = 24 bytes + if (algorithm.includes("128")) return 16 // 128 bits = 16 bytes + return 32 // Default to 256-bit +} + +// Browser-compatible base64 encoding/decoding +export function base64Encode(data: string): string { + if (typeof Buffer !== "undefined") { + // Node.js environment + return Buffer.from(data).toString("base64") + } else { + // Browser environment + return btoa(unescape(encodeURIComponent(data))) + } +} + +export function base64Decode(data: string): string { + if (typeof Buffer !== "undefined") { + // Node.js environment + return Buffer.from(data, "base64").toString() + } else { + // Browser environment + return decodeURIComponent(escape(atob(data))) + } +} + +// Utility functions for array buffer conversions +export 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 +} + +export function arrayBufferToHex(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") +} + +export function stringToArrayBuffer(str: string): ArrayBuffer { + const encoder = new TextEncoder() + return encoder.encode(str).buffer as ArrayBuffer +} + +export function arrayBufferToString(buffer: ArrayBuffer): string { + const decoder = new TextDecoder() + return decoder.decode(buffer) +} + +// ============================================================================= +// CORE CRYPTOGRAPHIC FUNCTIONS +// ============================================================================= + +export async function deriveKeyFromHash( + passwordHash: string, + algorithm: string, +): Promise { + try { + const keyLength = deriveKeyLengthFromAlgorithm(algorithm) + const hashBytes = hexToArrayBuffer(passwordHash) + + // Use only the required key length from the hash + const keyBytes = new Uint8Array(hashBytes).slice(0, keyLength) + + // For GCM mode, use AES-GCM as the algorithm name + const algorithmName = algorithm === "aes-256-gcm" ? "AES-GCM" : "AES-CBC" + + return await crypto.subtle.importKey("raw", keyBytes, { name: algorithmName }, false, [ + "encrypt", + "decrypt", + ]) + } catch (error) { + throw new Error( + `Key derivation failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } +} + +export async function hashString( + password: string, + salt: ArrayBuffer | string | undefined = undefined, +): Promise { + const passwordBytes = stringToArrayBuffer(password) + + let saltBytes: Uint8Array | null = null + + if (typeof salt === "string") { + saltBytes = new Uint8Array(hexToArrayBuffer(salt)) + } else if (salt !== undefined) { + saltBytes = new Uint8Array(salt) + } else { + saltBytes = crypto.getRandomValues(new Uint8Array(16)) + } + + const combined = new Uint8Array(passwordBytes.byteLength + saltBytes.byteLength) + combined.set(new Uint8Array(passwordBytes), 0) + combined.set(saltBytes, passwordBytes.byteLength) + + const hashBuffer = await crypto.subtle.digest("SHA-256", combined) + return { + hash: arrayBufferToHex(hashBuffer), + salt: arrayBufferToHex(saltBytes.buffer as ArrayBuffer), + } +} + +export async function verifyPasswordHash( + password: string, + passwordHashData: Hash, +): Promise { + const { hash: passwordHash } = await hashString(password, passwordHashData.salt) + return passwordHash === passwordHashData.hash +} + +export async function encryptContent( + content: string, + password: string, + config: EncryptionConfig, +): Promise { + checkCryptoSupport() + + const { algorithm } = config + + if (!SUPPORTED_ALGORITHMS.includes(algorithm as SupportedEncryptionAlgorithm)) { + throw new Error( + `Unsupported encryption algorithm: ${algorithm}. Supported: ${SUPPORTED_ALGORITHMS.join(", ")}`, + ) + } + + // Generate random salt for encryption + const initializationVector = + algorithm === "aes-256-ecb" ? new Uint8Array(16) : crypto.getRandomValues(new Uint8Array(16)) // Zero IV for ECB simulation + + // Create encryption hash and derive key + const encryptionHashData = await hashString(password) + const key = await deriveKeyFromHash(encryptionHashData.hash, algorithm) + + // Prepare content for encryption + const contentBuffer = stringToArrayBuffer(content) + + let encryptedBuffer: ArrayBuffer + let authTag: ArrayBuffer | undefined + + try { + if (algorithm === "aes-256-gcm") { + // GCM mode - the Web Crypto API returns ciphertext with auth tag appended + const encryptedWithTag = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: initializationVector, + }, + key, + contentBuffer, + ) + + // The last 16 bytes (128 bits) are the authentication tag + const encryptedBytes = new Uint8Array(encryptedWithTag) + const ciphertext = encryptedBytes.slice(0, -16) + const authTagBytes = encryptedBytes.slice(-16) + + encryptedBuffer = ciphertext.buffer + authTag = authTagBytes.buffer + } else if (algorithm === "aes-256-cbc") { + // CBC mode + encryptedBuffer = await crypto.subtle.encrypt( + { + name: "AES-CBC", + iv: initializationVector, + }, + key, + contentBuffer, + ) + } else if (algorithm === "aes-256-ecb") { + // ECB simulation using CBC with zero IV + encryptedBuffer = await crypto.subtle.encrypt( + { + name: "AES-CBC", + iv: initializationVector, // Zero IV for ECB simulation + }, + key, + contentBuffer, + ) + } else { + throw new Error("Unsupported algorithm: " + algorithm) + } + } catch (error) { + throw new Error( + `Encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } + + // Build result object + const result: EncryptionResult = { + encryptedContent: arrayBufferToHex(encryptedBuffer), + encryptionSalt: encryptionHashData.salt, + } + + if (algorithm !== "aes-256-ecb") { + result.iv = arrayBufferToHex(initializationVector.buffer) + } + + if (authTag) { + result.authTag = arrayBufferToHex(authTag) + } + + return result +} + +export async function decryptContent( + encrypted: EncryptionResult, + config: EncryptionConfig, + password: string, +): Promise { + checkCryptoSupport() + const { encryptedContent, encryptionSalt, iv, authTag } = encrypted + + // Create encryption hash and derive key + const encryptionSaltBuffer = hexToArrayBuffer(encryptionSalt) + const encryptionHashData = await hashString(password, encryptionSaltBuffer) + const key = await deriveKeyFromHash(encryptionHashData.hash, config.algorithm) + + // Prepare for decryption + const ciphertext = hexToArrayBuffer(encryptedContent) + let decryptedBuffer: ArrayBuffer + + try { + if (config.algorithm === "aes-256-gcm") { + // GCM mode + if (!iv) throw new Error("IV is required for GCM mode") + if (!authTag) throw new Error("Authentication tag is required for GCM mode") + + const initializationVectorBuffer = hexToArrayBuffer(iv) + const authTagBuffer = hexToArrayBuffer(authTag) + + // For GCM decryption, we need to append the auth tag to the ciphertext + const combined = new Uint8Array(ciphertext.byteLength + authTagBuffer.byteLength) + combined.set(new Uint8Array(ciphertext), 0) + combined.set(new Uint8Array(authTagBuffer), ciphertext.byteLength) + + decryptedBuffer = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: initializationVectorBuffer, + }, + key, + combined.buffer, + ) + } else if (config.algorithm === "aes-256-cbc") { + // CBC mode + if (!iv) throw new Error("IV is required for CBC mode") + const initializationVectorBuffer = hexToArrayBuffer(iv) + + decryptedBuffer = await crypto.subtle.decrypt( + { + name: "AES-CBC", + iv: initializationVectorBuffer, + }, + key, + ciphertext, + ) + } else if (config.algorithm === "aes-256-ecb") { + // ECB simulation using CBC with zero IV + const zeroInitializationVector = new ArrayBuffer(16) + + decryptedBuffer = await crypto.subtle.decrypt( + { + name: "AES-CBC", + iv: zeroInitializationVector, + }, + key, + ciphertext, + ) + } else { + throw new Error("Unsupported algorithm: " + config.algorithm) + } + } catch (error) { + throw new Error( + `Decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } + + return arrayBufferToString(decryptedBuffer) +} + +// ============================================================================= +// CONFIGURATION MANAGEMENT +// ============================================================================= + +export function normalizeDirectoryConfig( + folderConfig: string | DirectoryConfig, + globalConfig: EncryptionConfig, +): DirectoryConfig { + if (typeof folderConfig === "string") { + return { + ...globalConfig, + password: folderConfig, + } + } + + return { + algorithm: folderConfig.algorithm || globalConfig.algorithm, + ttl: folderConfig.ttl || globalConfig.ttl, + password: folderConfig.password, + message: folderConfig.message || globalConfig.message, + } +} + +export function getEncryptionConfigForPath( + filePath: string, + options: EncryptionOptions, +): DirectoryConfig | undefined { + if (!options.encryptedFolders) { + return undefined + } + + let deepestFolder = "" + let deepestConfig: string | DirectoryConfig | undefined + + for (const [folder, config] of Object.entries(options.encryptedFolders)) { + if (filePath.startsWith(folder) && deepestFolder.length < folder.length) { + deepestFolder = folder + deepestConfig = config + } + } + + if (deepestConfig) { + const globalConfig = { + algorithm: options.algorithm || defaultEncryptionConfig.algorithm, + ttl: options.ttl || defaultEncryptionConfig.ttl, + message: options.message || defaultEncryptionConfig.message, + } + + return normalizeDirectoryConfig(deepestConfig, globalConfig) + } + + return undefined +} + +export async function searchForValidPassword( + filePath: string, + hash: Hash, + config: EncryptionConfig, +): Promise { + const passwords = getRelevantPasswords(filePath) + + for (const password of passwords) { + if (await verifyPasswordHash(password, hash)) { + addPasswordToCache(password, filePath, config.ttl) + return password + } + } + + return undefined +} + +// ============================================================================= +// PASSWORD CACHING AND MANAGEMENT +// ============================================================================= + +// Queue to prevent race conditions in cache operations +let cacheOperationQueue: Promise = Promise.resolve() + +interface CachedPassword { + password: string + ttl: number +} + +// Helper function to execute cache operations atomically +async function executeAtomicCacheOperation(operation: () => T): Promise { + return new Promise((resolve, reject) => { + cacheOperationQueue = cacheOperationQueue + .then(() => { + try { + const result = operation() + resolve(result) + } catch (error) { + reject(error) + } + }) + .catch((error) => { + reject(error) + }) + }) +} + +export function getPasswordCache(): Record { + // Check if we're in a browser environment + if (typeof localStorage === "undefined") { + return {} + } + + try { + const cache = localStorage.getItem(ENCRYPTION_CACHE_KEY) + return cache ? JSON.parse(cache) : {} + } catch { + return {} + } +} + +export function savePasswordCache(cache: Record) { + // Check if we're in a browser environment + if (typeof localStorage === "undefined") { + return + } + + try { + localStorage.setItem(ENCRYPTION_CACHE_KEY, JSON.stringify(cache)) + } catch { + // Silent fail if localStorage is not available + } +} + +export async function addPasswordToCache( + password: string, + filePath: string, + ttl: number, +): Promise { + return executeAtomicCacheOperation(() => { + const cache = getPasswordCache() + const now = Date.now() + + cache[filePath] = { + password, + ttl: ttl <= 0 ? 0 : now + ttl, + } + + savePasswordCache(cache) + }) +} + +export function getRelevantPasswords(filePath: string): string[] { + const cache = getPasswordCache() + const now = Date.now() + const uniquePasswords: Set = new Set() + 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] + } + }) + + if (cache[filePath] && cache[filePath].ttl > now) { + // If the exact file path is cached, return its password + return [cache[filePath].password] + } + + // Get passwords by directory hierarchy (closest first) + 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 path of sortedPaths) { + if (!uniquePasswords.has(cache[path].password)) { + uniquePasswords.add(cache[path].password) + passwords.push(cache[path].password) + } + } + + return passwords +} + +export 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 +} + +export async function contentDecryptedEventListener( + filePath: string, + hash: Hash, + config: EncryptionConfig, + callback: (password: string) => void, + once: boolean = true, +) { + const checkForValidPassword: () => Promise = async () => { + const password = await searchForValidPassword(filePath, hash, config) + if (password) { + callback(password) + return true + } + return false + } + + const result = await checkForValidPassword() + + if (!result || !once) { + document.addEventListener("nav", async function listener() { + const result = await checkForValidPassword() + if (result && once) { + document.removeEventListener("nav", listener) + } + }) + } +} \ No newline at end of file