From 127e5c3c031252e56ab69189580b13136096c617 Mon Sep 17 00:00:00 2001 From: arg3t Date: Thu, 31 Jul 2025 18:10:44 +0200 Subject: [PATCH] Clean up the plugin configuration types and make fields nullable --- docs/plugins/Encrypt.md | 134 ++++------ index.d.ts | 1 + quartz/components/Explorer.tsx | 2 - quartz/components/TableOfContents.tsx | 17 +- quartz/components/scripts/encrypt.inline.ts | 19 +- quartz/components/scripts/explorer.inline.ts | 36 ++- quartz/components/styles/encrypt.scss | 2 +- quartz/plugins/emitters/contentIndex.tsx | 4 +- quartz/plugins/transformers/encrypt.ts | 257 +++++++++++++++---- quartz/plugins/transformers/frontmatter.ts | 4 +- quartz/util/encryption.ts | 147 ++++------- 11 files changed, 357 insertions(+), 266 deletions(-) diff --git a/docs/plugins/Encrypt.md b/docs/plugins/Encrypt.md index 052076186..cf966ae0f 100644 --- a/docs/plugins/Encrypt.md +++ b/docs/plugins/Encrypt.md @@ -17,132 +17,90 @@ This plugin enables content encryption for sensitive pages in your Quartz site. ```typescript Plugin.Encrypt({ - algorithm: "aes-256-cbc", // Encryption algorithm (key length auto-inferred) + algorithm: "aes-256-cbc", // Encryption algorithm + ttl: 3600 * 24 * 7, // Password cache TTL in seconds (7 days) + message: "This content is encrypted.", // Default message shown encryptedFolders: { - // Folder-level encryption with simple passwords + // Simple password for a folder "private/": "folder-password", - "work/confidential/": "work-password", - // Advanced per-folder configuration + + // Advanced configuration for a folder "secure/": { password: "advanced-password", algorithm: "aes-256-gcm", ttl: 3600 * 24 * 30, // 30 days + message: "Authorized access only", }, }, - ttl: 3600 * 24 * 7, // Password cache TTL in seconds (7 days) }) ``` > [!warning] -> It is very important to note that: +> Important security notes: > -> - 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. +> - All non-markdown files remain unencrypted in the final build +> - Encrypted content is still visible in your source repository if it's public +> - Use this for access control, not for storing highly sensitive secrets ### Configuration Options -- `algorithm`: Encryption algorithm to use. Supported values: +- `algorithm`: Encryption algorithm to use - `"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) - - 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 + - `"aes-256-ecb"`: AES-256 in ECB mode (not recommended) +- `ttl`: Time-to-live for cached passwords in seconds (default: 7 days) +- `message`: Default message shown on encrypted pages +- `encryptedFolders`: Configure folder-level encryption -### Advanced Folder Configuration +## How Configuration Works -You can provide detailed configuration for each encrypted folder: +### Configuration Inheritance + +Settings cascade down through your folder structure: ```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 + "docs/": { + password: "docs-password", + algorithm: "aes-256-gcm" + }, + "docs/internal/": { + password: "internal-password" + // Inherits algorithm from parent folder } } ``` -## Usage +In this example: -### Folder-level Encryption +- `docs/page.md` uses `"docs-password"` with `"aes-256-gcm"` +- `docs/internal/report.md` uses `"internal-password"` but still uses `"aes-256-gcm"` (inherited) -Configure folders to be encrypted by adding them to the `encryptedFolders` option: +### Configuration Priority -```typescript -Plugin.Encrypt({ - encryptedFolders: { - "private/": "my-secret-password", - "work/": "work-password", - "personal/diary/": "diary-password", - }, -}) -``` +When multiple configurations apply, the priority is: -All pages within these folders will be encrypted with the specified password. Nested folders inherit passwords from parent folders, with deeper paths taking precedence. - -### Page-level Encryption - -Use frontmatter to encrypt individual pages or override folder passwords. - -```yaml ---- -title: "My Secret Page" -password: "page-specific-password" -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 - -#### 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 - -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 -- 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) +1. **Page frontmatter** (highest priority) +2. **Deepest matching folder** +3. **Parent folders** (inherited settings) +4. **Global defaults** (lowest priority) ## Security Features -### Password Cache +### Password Caching -The plugin implements intelligent password caching: +- Passwords are stored in browser localStorage +- Automatic expiration based on TTL settings +- Cached passwords are tried automatically when navigating -- Passwords are cached in browser localStorage -- Configurable TTL (time-to-live) for automatic cache expiration -- Passwords are automatically tried when navigating to encrypted pages +### Protection Levels -### Content Protection - -- **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 -- **Password Verification**: Fast password hash verification before attempting decryption +- **Content**: Entire page HTML is encrypted +- **Search/RSS**: Only generic descriptions are exposed +- **Navigation**: Encrypted pages appear in navigation but require passwords to view ## API - Category: Transformer -- Function name: `Plugin.Encrypt()`. -- Source: [`quartz/plugins/transformers/encrypt.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/encrypt.ts). +- Function name: `Plugin.Encrypt()` +- Source: [`quartz/plugins/transformers/encrypt.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/encrypt.ts) diff --git a/index.d.ts b/index.d.ts index 03cff3763..62dcdb2b2 100644 --- a/index.d.ts +++ b/index.d.ts @@ -8,6 +8,7 @@ interface CustomEventMap { prenav: CustomEvent<{}> nav: CustomEvent<{ url: FullSlug }> render: CustomEvent<{ htmlElement: HTMLElement }> + decrypt: CustomEvent<{ filePath: FullSlug; password: string }> themechange: CustomEvent<{ theme: "light" | "dark" }> readermodechange: CustomEvent<{ mode: "on" | "off" }> } diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx index 3827587d7..f28ccda3b 100644 --- a/quartz/components/Explorer.tsx +++ b/quartz/components/Explorer.tsx @@ -121,7 +121,6 @@ export default ((userOpts?: Partial) => { @@ -144,7 +143,6 @@ export default ((userOpts?: Partial) => {
diff --git a/quartz/components/TableOfContents.tsx b/quartz/components/TableOfContents.tsx index 8ed3f9f3f..d6329f221 100644 --- a/quartz/components/TableOfContents.tsx +++ b/quartz/components/TableOfContents.tsx @@ -54,14 +54,13 @@ export default ((opts?: Partial) => { - {!fileData.encryptionResult && - fileData.toc.map((tocEntry) => ( -
  • - - {tocEntry.text} - -
  • - ))} + {fileData.toc.map((tocEntry) => ( +
  • + + {tocEntry.text} + +
  • + ))}
    ) @@ -71,7 +70,7 @@ export default ((opts?: Partial) => { TableOfContents.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded) const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => { - if (!fileData.toc) { + if (!fileData.toc || fileData.encryptionResult) { return null } return ( diff --git a/quartz/components/scripts/encrypt.inline.ts b/quartz/components/scripts/encrypt.inline.ts index 495ed0ba3..aec95793e 100644 --- a/quartz/components/scripts/encrypt.inline.ts +++ b/quartz/components/scripts/encrypt.inline.ts @@ -3,7 +3,7 @@ import { verifyPasswordHash, addPasswordToCache, Hash, - EncryptionConfig, + CompleteCryptoConfig, searchForValidPassword, EncryptionResult, } from "../../util/encryption" @@ -25,6 +25,16 @@ const showLoading = (container: Element, show: boolean) => { } } +function dispatchDecryptEvent(filePath: FullSlug, password: string) { + const event = new CustomEvent("decrypt", { + detail: { + filePath, + password, + }, + }) + document.dispatchEvent(event) +} + const decryptWithPassword = async ( container: Element, password: string, @@ -33,7 +43,7 @@ const decryptWithPassword = async ( const errorDivs = container.querySelectorAll(".decrypt-error") as NodeListOf const containerElement = container as HTMLElement - const config = JSON.parse(containerElement.dataset.config!) as EncryptionConfig + const config = JSON.parse(containerElement.dataset.config!) as CompleteCryptoConfig const encrypted = JSON.parse(containerElement.dataset.encrypted!) as EncryptionResult const hash = JSON.parse(containerElement.dataset.hash!) as Hash @@ -137,13 +147,14 @@ function updateTitle(container: HTMLElement | null) { const tryAutoDecrypt = async (parent: HTMLElement, container: HTMLElement): Promise => { const fullSlug = container.dataset.slug as FullSlug - const config = JSON.parse(container.dataset.config!) as EncryptionConfig + const config = JSON.parse(container.dataset.config!) as CompleteCryptoConfig const hash = JSON.parse(container.dataset.hash!) as Hash const password = await searchForValidPassword(fullSlug, hash, config) if (password && (await decryptWithPassword(container, password, false))) { dispatchRenderEvent(parent) + dispatchDecryptEvent(fullSlug, password) updateTitle(parent) return true } @@ -151,6 +162,7 @@ const tryAutoDecrypt = async (parent: HTMLElement, container: HTMLElement): Prom } const manualDecrypt = async (parent: HTMLElement, container: HTMLElement) => { + const fullSlug = container.dataset.slug as FullSlug const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement const password = passwordInput.value @@ -161,6 +173,7 @@ const manualDecrypt = async (parent: HTMLElement, container: HTMLElement) => { if (await decryptWithPassword(container, password, true)) { dispatchRenderEvent(parent) + dispatchDecryptEvent(fullSlug, password) updateTitle(parent) } } diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts index fbae65e80..b9bbf42b4 100644 --- a/quartz/components/scripts/explorer.inline.ts +++ b/quartz/components/scripts/explorer.inline.ts @@ -87,18 +87,15 @@ function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElemen const a = li.querySelector("a") as HTMLAnchorElement a.href = resolveRelative(currentSlug, node.slug) a.dataset.for = node.slug - a.textContent = node.displayName - const span = li.querySelector("span") as HTMLSpanElement - - if (span && node.data?.encryptionResult) { - span.textContent = "🔒 " + if (node.data?.encryptionResult) { + a.textContent = "🔒 " + node.displayName contentDecryptedEventListener(node.slug, node.data.hash!, node.data.encryptionConfig!, () => { - span.textContent = "🔓 " + a.textContent = "🔓 " + node.displayName }) - } else if (span) { - span.remove() + } else { + a.textContent = node.displayName } if (currentSlug === node.slug) { @@ -124,14 +121,7 @@ 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 = "🔓 " - }) - } + let titleElement: HTMLElement if (opts.folderClickBehavior === "link") { // Replace button with link for link behavior @@ -140,12 +130,20 @@ function createFolderNode( a.href = resolveRelative(currentSlug, folderPath) a.dataset.for = folderPath a.className = "folder-title" - a.textContent = node.displayName + titleElement = a button.replaceWith(a) - titleContainer.insertBefore(span, a) } else { const span = titleContainer.querySelector(".folder-title-text") as HTMLElement - span.textContent = node.displayName + titleElement = span + } + + if (node.data?.encryptionResult) { + titleElement.textContent = "🔒 " + node.displayName + contentDecryptedEventListener(folderPath, node.data.hash!, node.data.encryptionConfig!, () => { + titleElement.textContent = "🔓 " + node.displayName + }) + } else { + titleElement.textContent = node.displayName } // if the saved state is collapsed or the default state is collapsed diff --git a/quartz/components/styles/encrypt.scss b/quartz/components/styles/encrypt.scss index dd6e5ba3f..5ce5b99a8 100644 --- a/quartz/components/styles/encrypt.scss +++ b/quartz/components/styles/encrypt.scss @@ -49,9 +49,9 @@ font-family: var(--bodyFont); font-size: 1rem; border: 1px solid var(--lightgray); - background: var(--light); color: var(--dark); transition: border-color 0.2s ease; + // background: var(--backgroundPrimary); &:focus { outline: none; diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index eda0c322f..c4234dc2e 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -7,7 +7,7 @@ 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" +import { Hash, EncryptionResult, CompleteCryptoConfig } from "../../util/encryption" export type ContentIndexMap = Map @@ -22,7 +22,7 @@ export type ContentDetails = { richContent?: string date?: Date description?: string - encryptionConfig?: EncryptionConfig + encryptionConfig?: CompleteCryptoConfig hash?: Hash encryptionResult?: EncryptionResult } diff --git a/quartz/plugins/transformers/encrypt.ts b/quartz/plugins/transformers/encrypt.ts index a52395d9c..5470f8325 100644 --- a/quartz/plugins/transformers/encrypt.ts +++ b/quartz/plugins/transformers/encrypt.ts @@ -2,78 +2,238 @@ 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, + CompleteCryptoConfig, + BaseCryptoConfig, EncryptionConfig, + DirectoryConfig, } from "../../util/encryption" // @ts-ignore import encryptScript from "../../components/scripts/encrypt.inline.ts" import encryptStyle from "../../components/styles/encrypt.scss" -export interface Options extends EncryptionOptions {} - -const defaultOptions: Options = { - ...defaultEncryptionConfig, - encryptedFolders: {}, +// ============================================================================= +// TYPE DEFINITIONS +// ============================================================================= +// Plugin configuration +export interface PluginConfig extends CompleteCryptoConfig { + encryptedFolders: Record } -export const Encrypt: QuartzTransformerPlugin> = (userOpts) => { - const opts = { ...defaultOptions, ...userOpts } +// User-provided options (all optional) +export type PluginOptions = Partial - // Validate algorithm at build time - if ( - opts.algorithm && - !SUPPORTED_ALGORITHMS.includes(opts.algorithm as SupportedEncryptionAlgorithm) - ) { +// Internal normalized folder configuration +interface NormalizedFolderConfig extends CompleteCryptoConfig { + password: string + path: string + depth: number +} + +// ============================================================================= +// CONFIGURATION MANAGEMENT +// ============================================================================= + +const DEFAULT_CONFIG: CompleteCryptoConfig = { + algorithm: "aes-256-cbc", + ttl: 3600 * 24 * 7, // 1 week + message: "This content is encrypted.", +} + +/** + * Normalizes a directory configuration into a complete configuration object + */ +function normalizeDirectoryConfig( + dirConfig: DirectoryConfig, + defaults: CompleteCryptoConfig, +): EncryptionConfig { + if (typeof dirConfig === "string") { + return { + ...defaults, + password: dirConfig, + } + } + + return { + algorithm: dirConfig.algorithm ?? defaults.algorithm, + ttl: dirConfig.ttl ?? defaults.ttl, + message: dirConfig.message ?? defaults.message, + password: dirConfig.password, + } +} + +/** + * Creates a sorted list of folder configurations by path depth + * This ensures parent configurations are processed before children + */ +function createFolderConfigHierarchy( + folders: Record, + globalDefaults: CompleteCryptoConfig, +): NormalizedFolderConfig[] { + const configs: NormalizedFolderConfig[] = [] + + for (const [path, config] of Object.entries(folders)) { + const normalized = normalizeDirectoryConfig(config, globalDefaults) + configs.push({ + ...normalized, + path: path.endsWith("/") ? path : path + "/", + depth: path.split("/").filter(Boolean).length, + }) + } + + // Sort by depth (shallow to deep) to ensure proper inheritance + return configs.sort((a, b) => a.depth - b.depth) +} + +/** + * Merges configurations following the inheritance chain + * Deeper paths override shallower ones + */ +function mergeConfigurations( + base: CompleteCryptoConfig, + override: Partial, +): CompleteCryptoConfig { + return { + algorithm: override.algorithm ?? base.algorithm, + ttl: override.ttl ?? base.ttl, + message: override.message ?? base.message, + } +} + +/** + * Gets the encryption configuration for a specific file path + * Respects the directory hierarchy and inheritance + */ +function getConfigurationForPath( + filePath: string, + folderConfigs: NormalizedFolderConfig[], + globalDefaults: CompleteCryptoConfig, +): EncryptionConfig | undefined { + let currentConfig: CompleteCryptoConfig = globalDefaults + let password: string | undefined + + // Apply configurations in order (shallow to deep) + for (const folderConfig of folderConfigs) { + if (filePath.startsWith(folderConfig.path)) { + // Merge the configuration, inheriting from parent + currentConfig = mergeConfigurations(currentConfig, folderConfig) + password = folderConfig.password + } + } + + // If no password was found in any matching folder, return undefined + if (!password) { + return undefined + } + + return { + ...currentConfig, + password, + } +} + +/** + * Validates the plugin configuration + */ +function validateConfig(config: PluginConfig): void { + if (!SUPPORTED_ALGORITHMS.includes(config.algorithm)) { throw new Error( - `[EncryptPlugin] Unsupported encryption algorithm: ${opts.algorithm}. Supported algorithms: ${SUPPORTED_ALGORITHMS.join(", ")}`, + `[EncryptPlugin] Unsupported encryption algorithm: ${config.algorithm}. ` + + `Supported algorithms: ${SUPPORTED_ALGORITHMS.join(", ")}`, ) } - const getEncryptionConfig = (file: VFile): DirectoryConfig | undefined => { + if (config.ttl <= 0) { + throw new Error(`[EncryptPlugin] TTL must be a positive number`) + } +} + +// ============================================================================= +// PLUGIN IMPLEMENTATION +// ============================================================================= + +export const Encrypt: QuartzTransformerPlugin = (userOpts) => { + // Merge user options with defaults + const pluginConfig: PluginConfig = { + ...DEFAULT_CONFIG, + ...userOpts, + encryptedFolders: userOpts?.encryptedFolders ?? {}, + } + + // Validate configuration + validateConfig(pluginConfig) + + // Pre-process folder configurations for efficient lookup + const folderConfigs = createFolderConfigHierarchy(pluginConfig.encryptedFolders, pluginConfig) + + /** + * Determines the final encryption configuration for a file + * Priority: frontmatter > deepest matching folder > global defaults + */ + const getEncryptionConfig = (file: VFile): EncryptionConfig | undefined => { const frontmatter = file.data?.frontmatter - const frontmatterConfig = (frontmatter?.encryptConfig ?? {}) as DirectoryConfig const relativePath = file.data?.relativePath - const folderConfig = relativePath ? getEncryptionConfigForPath(relativePath, opts) : null + // Check if file should be encrypted via frontmatter + const shouldEncryptViaFrontmatter = frontmatter?.encrypt === true + const frontmatterConfig = frontmatter?.encryptConfig as + | (BaseCryptoConfig & { password?: string }) + | undefined - if (!folderConfig && !frontmatterConfig.password) { - return undefined - } else if (!folderConfig && !frontmatter?.encrypt) { + // Get folder-based configuration + const folderConfig = relativePath + ? getConfigurationForPath(relativePath, folderConfigs, pluginConfig) + : undefined + + // If neither folder config nor frontmatter indicates encryption, skip + if (!folderConfig && !shouldEncryptViaFrontmatter) { return undefined } - 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, + // Build final configuration with proper precedence + let finalConfig: EncryptionConfig | undefined + + if (folderConfig && frontmatterConfig) { + // Merge folder and frontmatter configs + finalConfig = { + algorithm: frontmatterConfig.algorithm ?? folderConfig.algorithm, + ttl: frontmatterConfig.ttl ?? folderConfig.ttl, + message: frontmatterConfig.message ?? folderConfig.message, + password: frontmatterConfig.password ?? folderConfig.password, + } + } else if (folderConfig) { + // Use folder config only + finalConfig = folderConfig + } else if (frontmatterConfig?.password) { + // Use frontmatter config with global defaults + finalConfig = { + algorithm: frontmatterConfig.algorithm ?? pluginConfig.algorithm, + ttl: frontmatterConfig.ttl ?? pluginConfig.ttl, + message: frontmatterConfig.message ?? pluginConfig.message, + password: frontmatterConfig.password, + } } - if (!config.password) { + // Validate final configuration + if (!finalConfig?.password) { + console.warn(`[EncryptPlugin] No password configured for ${relativePath}`) return undefined } - return config + return finalConfig } return { name: "Encrypt", markdownPlugins() { - // If encrypted, prepend lock emoji before the title return [ () => { return async (_, file) => { @@ -93,34 +253,33 @@ export const Encrypt: QuartzTransformerPlugin> = (userOpts) => () => { return async (tree: Root, file) => { const config = getEncryptionConfig(file) - - if (!file.data.hash || !config) { + if (!config || !file.data.hash) { return tree } const locale = ctx.cfg.configuration.locale const t = i18n(locale).components.encryption - // Convert html to plaintext and encrypt it - file.data.encryptionResult = await encryptContent( - toString(tree), - config.password, - config, - ) - - // Encrypt the content and generate verification hash + // Encrypt the content const encryptionResult = await encryptContent(toHtml(tree), config.password, config) - // Create individual attributes for each field instead of JSON + // Store for later use + file.data.encryptionResult = encryptionResult + + // Create attributes for client-side decryption const attributes = [ - `data-config='${JSON.stringify(config)}'`, + `data-config='${JSON.stringify({ + algorithm: config.algorithm, + ttl: config.ttl, + message: config.message, + })}'`, `data-encrypted='${JSON.stringify(encryptionResult)}'`, `data-hash='${JSON.stringify(file.data.hash)}'`, `data-slug='${file.data.slug}'`, `data-decrypted='false'`, ].join(" ") - // Create a new tree with encrypted content placeholder + // Create encrypted content placeholder const encryptedTree = fromHtml( `
    @@ -149,7 +308,6 @@ export const Encrypt: QuartzTransformerPlugin> = (userOpts) => // Replace the original tree tree.children = encryptedTree.children - return tree } }, @@ -170,12 +328,15 @@ export const Encrypt: QuartzTransformerPlugin> = (userOpts) => inline: true, }, ], - additionalHead: [], } }, } } +// ============================================================================= +// MODULE AUGMENTATION +// ============================================================================= + declare module "vfile" { interface DataMap { encryptionConfig: EncryptionConfig diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index 0979385d3..b06fffd20 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -6,7 +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" +import { EncryptionConfig } from "../../util/encryption" export interface Options { delimiters: string | [string, string] @@ -169,7 +169,7 @@ declare module "vfile" { socialImage: string comments: boolean | string encrypt: boolean - encryptConfig: DirectoryConfig + encryptConfig: EncryptionConfig }> } } diff --git a/quartz/util/encryption.ts b/quartz/util/encryption.ts index feef85f29..90bb2c093 100644 --- a/quartz/util/encryption.ts +++ b/quartz/util/encryption.ts @@ -1,16 +1,17 @@ // ============================================================================= // 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] +// Result of hash operation export interface Hash { hash: string salt: string } +// Result of encryption operation export interface EncryptionResult { encryptedContent: string encryptionSalt: string @@ -18,30 +19,28 @@ export interface EncryptionResult { authTag?: string } -export interface EncryptionConfig { - algorithm: string +// Base crypto configuration without password +export interface BaseCryptoConfig { + algorithm?: SupportedEncryptionAlgorithm + ttl?: number + message?: string +} + +// Directory configuration can be partial or just a password string +export type DirectoryConfig = (BaseCryptoConfig & { password: string }) | string + +// Complete crypto configuration with all required fields +export interface CompleteCryptoConfig { + algorithm: SupportedEncryptionAlgorithm ttl: number message: string } -export interface DirectoryConfig extends EncryptionConfig { +// Encryption configuration includes password +export interface EncryptionConfig extends CompleteCryptoConfig { password: string } -export interface EncryptionOptions extends EncryptionConfig { - encryptedFolders: { [folderPath: string]: string | DirectoryConfig } -} - -// ============================================================================= -// 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" // ============================================================================= @@ -197,7 +196,7 @@ export async function verifyPasswordHash( export async function encryptContent( content: string, password: string, - config: EncryptionConfig, + config: CompleteCryptoConfig, ): Promise { checkCryptoSupport() @@ -290,7 +289,7 @@ export async function encryptContent( export async function decryptContent( encrypted: EncryptionResult, - config: EncryptionConfig, + config: CompleteCryptoConfig, password: string, ): Promise { checkCryptoSupport() @@ -364,72 +363,27 @@ export async function decryptContent( 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, + config: CompleteCryptoConfig, + blacklist: Set | null = null, ): Promise { const passwords = getRelevantPasswords(filePath) for (const password of passwords) { + if (blacklist && blacklist.has(password)) { + continue + } + if (await verifyPasswordHash(password, hash)) { addPasswordToCache(password, filePath, config.ttl) return password } + + if (blacklist) { + blacklist.add(password) + } } return undefined @@ -566,27 +520,36 @@ export function getSharedDirectoryDepth(path1: string, path2: string): number { export async function contentDecryptedEventListener( filePath: string, hash: Hash, - config: EncryptionConfig, + config: CompleteCryptoConfig, callback: (password: string) => void, - once: boolean = true, ) { - const checkForValidPassword: () => Promise = async () => { - const password = await searchForValidPassword(filePath, hash, config) - if (password) { - callback(password) - return true + const blacklist = new Set() + + async function decryptionSuccessful(password: string) { + addPasswordToCache(password, filePath, config.ttl) + callback(password) + } + + async function listener(e: CustomEventMap["decrypt"]) { + const password = e.detail.password + + if (blacklist.has(password)) { + return + } + + if (await verifyPasswordHash(password, hash)) { + document.removeEventListener("decrypt", listener) + await decryptionSuccessful(password) + return + } else { + blacklist.add(password) } - 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) - } - }) + const password = await searchForValidPassword(filePath, hash, config, blacklist) + if (password) { + callback(password) } + + document.addEventListener("decrypt", listener) }