Clean up the plugin configuration types and make fields nullable

This commit is contained in:
arg3t 2025-07-31 18:10:44 +02:00
parent 78d4fb5faa
commit 127e5c3c03
11 changed files with 357 additions and 266 deletions

View File

@ -17,132 +17,90 @@ This plugin enables content encryption for sensitive pages in your Quartz site.
```typescript ```typescript
Plugin.Encrypt({ 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: { encryptedFolders: {
// Folder-level encryption with simple passwords // Simple password for a folder
"private/": "folder-password", "private/": "folder-password",
"work/confidential/": "work-password",
// Advanced per-folder configuration // Advanced configuration for a folder
"secure/": { "secure/": {
password: "advanced-password", password: "advanced-password",
algorithm: "aes-256-gcm", algorithm: "aes-256-gcm",
ttl: 3600 * 24 * 30, // 30 days ttl: 3600 * 24 * 30, // 30 days
message: "Authorized access only",
}, },
}, },
ttl: 3600 * 24 * 7, // Password cache TTL in seconds (7 days)
}) })
``` ```
> [!warning] > [!warning]
> It is very important to note that: > Important security notes:
> >
> - All non-markdown files will be left unencrypted in the final build. > - All non-markdown files remain 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. > - 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 ### 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-cbc"` (default): AES-256 in CBC mode
- `"aes-256-gcm"`: AES-256 in GCM mode (authenticated encryption) - `"aes-256-gcm"`: AES-256 in GCM mode (authenticated encryption)
- `"aes-256-ecb"`: AES-256 in ECB mode (not recommended for security) - `"aes-256-ecb"`: AES-256 in ECB mode (not recommended)
- Key length is automatically inferred from the algorithm (e.g., 256-bit = 32 bytes) - `ttl`: Time-to-live for cached passwords in seconds (default: 7 days)
- `encryptedFolders`: Object mapping folder paths to passwords or configuration objects for folder-level encryption - `message`: Default message shown on encrypted pages
- `ttl`: Time-to-live for cached passwords in seconds (default: 604800 = 7 days, set to 0 for session-only) - `encryptedFolders`: Configure folder-level encryption
- `message`: Message to be displayed in the decryption page
### 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 ```typescript
encryptedFolders: { encryptedFolders: {
"basic/": "simple-password", // Simple string password "docs/": {
"advanced/": { password: "docs-password",
password: "complex-password", algorithm: "aes-256-gcm"
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
### Folder-level Encryption
Configure folders to be encrypted by adding them to the `encryptedFolders` option:
```typescript
Plugin.Encrypt({
encryptedFolders: {
"private/": "my-secret-password",
"work/": "work-password",
"personal/diary/": "diary-password",
}, },
}) "docs/internal/": {
password: "internal-password"
// Inherits algorithm from parent folder
}
}
``` ```
All pages within these folders will be encrypted with the specified password. Nested folders inherit passwords from parent folders, with deeper paths taking precedence. In this example:
### Page-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)
Use frontmatter to encrypt individual pages or override folder passwords. ### Configuration Priority
```yaml When multiple configurations apply, the priority is:
---
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 1. **Page frontmatter** (highest priority)
2. **Deepest matching folder**
#### encryptConfig object fields: 3. **Parent folders** (inherited settings)
4. **Global defaults** (lowest priority)
- `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)
## Security Features ## 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 ### Protection Levels
- Configurable TTL (time-to-live) for automatic cache expiration
- Passwords are automatically tried when navigating to encrypted pages
### Content Protection - **Content**: Entire page HTML is encrypted
- **Search/RSS**: Only generic descriptions are exposed
- **Full Content Encryption**: The entire HTML content is encrypted, not just hidden - **Navigation**: Encrypted pages appear in navigation but require passwords to view
- **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
## API ## API
- Category: Transformer - Category: Transformer
- Function name: `Plugin.Encrypt()`. - Function name: `Plugin.Encrypt()`
- Source: [`quartz/plugins/transformers/encrypt.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/encrypt.ts). - Source: [`quartz/plugins/transformers/encrypt.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/encrypt.ts)

1
index.d.ts vendored
View File

@ -8,6 +8,7 @@ interface CustomEventMap {
prenav: CustomEvent<{}> prenav: CustomEvent<{}>
nav: CustomEvent<{ url: FullSlug }> nav: CustomEvent<{ url: FullSlug }>
render: CustomEvent<{ htmlElement: HTMLElement }> render: CustomEvent<{ htmlElement: HTMLElement }>
decrypt: CustomEvent<{ filePath: FullSlug; password: string }>
themechange: CustomEvent<{ theme: "light" | "dark" }> themechange: CustomEvent<{ theme: "light" | "dark" }>
readermodechange: CustomEvent<{ mode: "on" | "off" }> readermodechange: CustomEvent<{ mode: "on" | "off" }>
} }

View File

@ -121,7 +121,6 @@ export default ((userOpts?: Partial<Options>) => {
</div> </div>
<template id="template-file"> <template id="template-file">
<li> <li>
<span class="file-title"></span>
<a href="#"></a> <a href="#"></a>
</li> </li>
</template> </template>
@ -144,7 +143,6 @@ export default ((userOpts?: Partial<Options>) => {
</svg> </svg>
<div> <div>
<button class="folder-button"> <button class="folder-button">
<span class="folder-title folder-title-icon"></span>
<span class="folder-title folder-title-text"></span> <span class="folder-title folder-title-text"></span>
</button> </button>
</div> </div>

View File

@ -54,8 +54,7 @@ export default ((opts?: Partial<Options>) => {
</svg> </svg>
</button> </button>
<OverflowList class={fileData.collapseToc ? "collapsed toc-content" : "toc-content"}> <OverflowList class={fileData.collapseToc ? "collapsed toc-content" : "toc-content"}>
{!fileData.encryptionResult && {fileData.toc.map((tocEntry) => (
fileData.toc.map((tocEntry) => (
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}> <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}> <a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
{tocEntry.text} {tocEntry.text}
@ -71,7 +70,7 @@ export default ((opts?: Partial<Options>) => {
TableOfContents.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded) TableOfContents.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded)
const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => { const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
if (!fileData.toc) { if (!fileData.toc || fileData.encryptionResult) {
return null return null
} }
return ( return (

View File

@ -3,7 +3,7 @@ import {
verifyPasswordHash, verifyPasswordHash,
addPasswordToCache, addPasswordToCache,
Hash, Hash,
EncryptionConfig, CompleteCryptoConfig,
searchForValidPassword, searchForValidPassword,
EncryptionResult, EncryptionResult,
} from "../../util/encryption" } 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 ( const decryptWithPassword = async (
container: Element, container: Element,
password: string, password: string,
@ -33,7 +43,7 @@ const decryptWithPassword = async (
const errorDivs = container.querySelectorAll(".decrypt-error") as NodeListOf<HTMLElement> const errorDivs = container.querySelectorAll(".decrypt-error") as NodeListOf<HTMLElement>
const containerElement = container as HTMLElement 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 encrypted = JSON.parse(containerElement.dataset.encrypted!) as EncryptionResult
const hash = JSON.parse(containerElement.dataset.hash!) as Hash 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<boolean> => { const tryAutoDecrypt = async (parent: HTMLElement, container: HTMLElement): Promise<boolean> => {
const fullSlug = container.dataset.slug as FullSlug 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 hash = JSON.parse(container.dataset.hash!) as Hash
const password = await searchForValidPassword(fullSlug, hash, config) const password = await searchForValidPassword(fullSlug, hash, config)
if (password && (await decryptWithPassword(container, password, false))) { if (password && (await decryptWithPassword(container, password, false))) {
dispatchRenderEvent(parent) dispatchRenderEvent(parent)
dispatchDecryptEvent(fullSlug, password)
updateTitle(parent) updateTitle(parent)
return true return true
} }
@ -151,6 +162,7 @@ const tryAutoDecrypt = async (parent: HTMLElement, container: HTMLElement): Prom
} }
const manualDecrypt = async (parent: HTMLElement, container: HTMLElement) => { const manualDecrypt = async (parent: HTMLElement, container: HTMLElement) => {
const fullSlug = container.dataset.slug as FullSlug
const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement
const password = passwordInput.value const password = passwordInput.value
@ -161,6 +173,7 @@ const manualDecrypt = async (parent: HTMLElement, container: HTMLElement) => {
if (await decryptWithPassword(container, password, true)) { if (await decryptWithPassword(container, password, true)) {
dispatchRenderEvent(parent) dispatchRenderEvent(parent)
dispatchDecryptEvent(fullSlug, password)
updateTitle(parent) updateTitle(parent)
} }
} }

View File

@ -87,18 +87,15 @@ function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElemen
const a = li.querySelector("a") as HTMLAnchorElement const a = li.querySelector("a") as HTMLAnchorElement
a.href = resolveRelative(currentSlug, node.slug) a.href = resolveRelative(currentSlug, node.slug)
a.dataset.for = node.slug a.dataset.for = node.slug
a.textContent = node.displayName
const span = li.querySelector("span") as HTMLSpanElement if (node.data?.encryptionResult) {
a.textContent = "🔒 " + node.displayName
if (span && node.data?.encryptionResult) {
span.textContent = "🔒 "
contentDecryptedEventListener(node.slug, node.data.hash!, node.data.encryptionConfig!, () => { contentDecryptedEventListener(node.slug, node.data.hash!, node.data.encryptionConfig!, () => {
span.textContent = "🔓 " a.textContent = "🔓 " + node.displayName
}) })
} else if (span) { } else {
span.remove() a.textContent = node.displayName
} }
if (currentSlug === node.slug) { if (currentSlug === node.slug) {
@ -124,14 +121,7 @@ function createFolderNode(
const folderPath = node.slug const folderPath = node.slug
folderContainer.dataset.folderpath = folderPath folderContainer.dataset.folderpath = folderPath
const span = titleContainer.querySelector("span.folder-title-icon") as HTMLElement let titleElement: HTMLElement
if (span && node.data?.encryptionResult) {
span.textContent = "🔒 "
contentDecryptedEventListener(folderPath, node.data.hash!, node.data.encryptionConfig!, () => {
span.textContent = "🔓 "
})
}
if (opts.folderClickBehavior === "link") { if (opts.folderClickBehavior === "link") {
// Replace button with link for link behavior // Replace button with link for link behavior
@ -140,12 +130,20 @@ function createFolderNode(
a.href = resolveRelative(currentSlug, folderPath) a.href = resolveRelative(currentSlug, folderPath)
a.dataset.for = folderPath a.dataset.for = folderPath
a.className = "folder-title" a.className = "folder-title"
a.textContent = node.displayName titleElement = a
button.replaceWith(a) button.replaceWith(a)
titleContainer.insertBefore(span, a)
} else { } else {
const span = titleContainer.querySelector(".folder-title-text") as HTMLElement 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 // if the saved state is collapsed or the default state is collapsed

View File

@ -49,9 +49,9 @@
font-family: var(--bodyFont); font-family: var(--bodyFont);
font-size: 1rem; font-size: 1rem;
border: 1px solid var(--lightgray); border: 1px solid var(--lightgray);
background: var(--light);
color: var(--dark); color: var(--dark);
transition: border-color 0.2s ease; transition: border-color 0.2s ease;
// background: var(--backgroundPrimary);
&:focus { &:focus {
outline: none; outline: none;

View File

@ -7,7 +7,7 @@ import { QuartzEmitterPlugin } from "../types"
import { toHtml } from "hast-util-to-html" import { toHtml } from "hast-util-to-html"
import { write } from "./helpers" import { write } from "./helpers"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
import { Hash, EncryptionResult, EncryptionConfig } from "../../util/encryption" import { Hash, EncryptionResult, CompleteCryptoConfig } from "../../util/encryption"
export type ContentIndexMap = Map<FullSlug, ContentDetails> export type ContentIndexMap = Map<FullSlug, ContentDetails>
@ -22,7 +22,7 @@ export type ContentDetails = {
richContent?: string richContent?: string
date?: Date date?: Date
description?: string description?: string
encryptionConfig?: EncryptionConfig encryptionConfig?: CompleteCryptoConfig
hash?: Hash hash?: Hash
encryptionResult?: EncryptionResult encryptionResult?: EncryptionResult
} }

View File

@ -2,78 +2,238 @@ import { QuartzTransformerPlugin } from "../types"
import { Root } from "hast" import { Root } from "hast"
import { toHtml } from "hast-util-to-html" import { toHtml } from "hast-util-to-html"
import { fromHtml } from "hast-util-from-html" import { fromHtml } from "hast-util-from-html"
import { toString } from "hast-util-to-string"
import { VFile } from "vfile" import { VFile } from "vfile"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
import { import {
EncryptionOptions,
DirectoryConfig,
defaultEncryptionConfig,
SUPPORTED_ALGORITHMS, SUPPORTED_ALGORITHMS,
SupportedEncryptionAlgorithm,
encryptContent, encryptContent,
getEncryptionConfigForPath,
Hash, Hash,
hashString, hashString,
EncryptionResult, EncryptionResult,
CompleteCryptoConfig,
BaseCryptoConfig,
EncryptionConfig, EncryptionConfig,
DirectoryConfig,
} from "../../util/encryption" } from "../../util/encryption"
// @ts-ignore // @ts-ignore
import encryptScript from "../../components/scripts/encrypt.inline.ts" import encryptScript from "../../components/scripts/encrypt.inline.ts"
import encryptStyle from "../../components/styles/encrypt.scss" import encryptStyle from "../../components/styles/encrypt.scss"
export interface Options extends EncryptionOptions {} // =============================================================================
// TYPE DEFINITIONS
const defaultOptions: Options = { // =============================================================================
...defaultEncryptionConfig, // Plugin configuration
encryptedFolders: {}, export interface PluginConfig extends CompleteCryptoConfig {
encryptedFolders: Record<string, DirectoryConfig>
} }
export const Encrypt: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => { // User-provided options (all optional)
const opts = { ...defaultOptions, ...userOpts } export type PluginOptions = Partial<PluginConfig>
// Validate algorithm at build time // Internal normalized folder configuration
if ( interface NormalizedFolderConfig extends CompleteCryptoConfig {
opts.algorithm && password: string
!SUPPORTED_ALGORITHMS.includes(opts.algorithm as SupportedEncryptionAlgorithm) 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<string, DirectoryConfig>,
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<BaseCryptoConfig>,
): 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( 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<PluginOptions> = (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 frontmatter = file.data?.frontmatter
const frontmatterConfig = (frontmatter?.encryptConfig ?? {}) as DirectoryConfig
const relativePath = file.data?.relativePath 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) { // Get folder-based configuration
return undefined const folderConfig = relativePath
} else if (!folderConfig && !frontmatter?.encrypt) { ? getConfigurationForPath(relativePath, folderConfigs, pluginConfig)
: undefined
// If neither folder config nor frontmatter indicates encryption, skip
if (!folderConfig && !shouldEncryptViaFrontmatter) {
return undefined return undefined
} }
const config = { // Build final configuration with proper precedence
algorithm: frontmatterConfig.algorithm || folderConfig?.algorithm || opts.algorithm, let finalConfig: EncryptionConfig | undefined
password: frontmatterConfig.password || folderConfig?.password || "",
message: frontmatterConfig.message || folderConfig?.message || opts.message, if (folderConfig && frontmatterConfig) {
ttl: frontmatterConfig.ttl || folderConfig?.ttl || opts.ttl, // 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 undefined
} }
return config return finalConfig
} }
return { return {
name: "Encrypt", name: "Encrypt",
markdownPlugins() { markdownPlugins() {
// If encrypted, prepend lock emoji before the title
return [ return [
() => { () => {
return async (_, file) => { return async (_, file) => {
@ -93,34 +253,33 @@ export const Encrypt: QuartzTransformerPlugin<Partial<Options>> = (userOpts) =>
() => { () => {
return async (tree: Root, file) => { return async (tree: Root, file) => {
const config = getEncryptionConfig(file) const config = getEncryptionConfig(file)
if (!config || !file.data.hash) {
if (!file.data.hash || !config) {
return tree return tree
} }
const locale = ctx.cfg.configuration.locale const locale = ctx.cfg.configuration.locale
const t = i18n(locale).components.encryption const t = i18n(locale).components.encryption
// Convert html to plaintext and encrypt it // Encrypt the content
file.data.encryptionResult = await encryptContent(
toString(tree),
config.password,
config,
)
// Encrypt the content and generate verification hash
const encryptionResult = await encryptContent(toHtml(tree), config.password, config) 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 = [ 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-encrypted='${JSON.stringify(encryptionResult)}'`,
`data-hash='${JSON.stringify(file.data.hash)}'`, `data-hash='${JSON.stringify(file.data.hash)}'`,
`data-slug='${file.data.slug}'`, `data-slug='${file.data.slug}'`,
`data-decrypted='false'`, `data-decrypted='false'`,
].join(" ") ].join(" ")
// Create a new tree with encrypted content placeholder // Create encrypted content placeholder
const encryptedTree = fromHtml( const encryptedTree = fromHtml(
` `
<div class="encrypted-content" ${attributes}> <div class="encrypted-content" ${attributes}>
@ -149,7 +308,6 @@ export const Encrypt: QuartzTransformerPlugin<Partial<Options>> = (userOpts) =>
// Replace the original tree // Replace the original tree
tree.children = encryptedTree.children tree.children = encryptedTree.children
return tree return tree
} }
}, },
@ -170,12 +328,15 @@ export const Encrypt: QuartzTransformerPlugin<Partial<Options>> = (userOpts) =>
inline: true, inline: true,
}, },
], ],
additionalHead: [],
} }
}, },
} }
} }
// =============================================================================
// MODULE AUGMENTATION
// =============================================================================
declare module "vfile" { declare module "vfile" {
interface DataMap { interface DataMap {
encryptionConfig: EncryptionConfig encryptionConfig: EncryptionConfig

View File

@ -6,7 +6,7 @@ import toml from "toml"
import { FilePath, FullSlug, getFileExtension, slugifyFilePath, slugTag } from "../../util/path" import { FilePath, FullSlug, getFileExtension, slugifyFilePath, slugTag } from "../../util/path"
import { QuartzPluginData } from "../vfile" import { QuartzPluginData } from "../vfile"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
import { DirectoryConfig } from "../../util/encryption" import { EncryptionConfig } from "../../util/encryption"
export interface Options { export interface Options {
delimiters: string | [string, string] delimiters: string | [string, string]
@ -169,7 +169,7 @@ declare module "vfile" {
socialImage: string socialImage: string
comments: boolean | string comments: boolean | string
encrypt: boolean encrypt: boolean
encryptConfig: DirectoryConfig encryptConfig: EncryptionConfig
}> }>
} }
} }

View File

@ -1,16 +1,17 @@
// ============================================================================= // =============================================================================
// TYPES AND INTERFACES // TYPES AND INTERFACES
// ============================================================================= // =============================================================================
export const SUPPORTED_ALGORITHMS = ["aes-256-cbc", "aes-256-gcm", "aes-256-ecb"] as const export const SUPPORTED_ALGORITHMS = ["aes-256-cbc", "aes-256-gcm", "aes-256-ecb"] as const
export type SupportedEncryptionAlgorithm = (typeof SUPPORTED_ALGORITHMS)[number] export type SupportedEncryptionAlgorithm = (typeof SUPPORTED_ALGORITHMS)[number]
// Result of hash operation
export interface Hash { export interface Hash {
hash: string hash: string
salt: string salt: string
} }
// Result of encryption operation
export interface EncryptionResult { export interface EncryptionResult {
encryptedContent: string encryptedContent: string
encryptionSalt: string encryptionSalt: string
@ -18,30 +19,28 @@ export interface EncryptionResult {
authTag?: string authTag?: string
} }
export interface EncryptionConfig { // Base crypto configuration without password
algorithm: string 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 ttl: number
message: string message: string
} }
export interface DirectoryConfig extends EncryptionConfig { // Encryption configuration includes password
export interface EncryptionConfig extends CompleteCryptoConfig {
password: string 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" const ENCRYPTION_CACHE_KEY = "quartz-encrypt-passwords"
// ============================================================================= // =============================================================================
@ -197,7 +196,7 @@ export async function verifyPasswordHash(
export async function encryptContent( export async function encryptContent(
content: string, content: string,
password: string, password: string,
config: EncryptionConfig, config: CompleteCryptoConfig,
): Promise<EncryptionResult> { ): Promise<EncryptionResult> {
checkCryptoSupport() checkCryptoSupport()
@ -290,7 +289,7 @@ export async function encryptContent(
export async function decryptContent( export async function decryptContent(
encrypted: EncryptionResult, encrypted: EncryptionResult,
config: EncryptionConfig, config: CompleteCryptoConfig,
password: string, password: string,
): Promise<string> { ): Promise<string> {
checkCryptoSupport() checkCryptoSupport()
@ -364,72 +363,27 @@ export async function decryptContent(
return arrayBufferToString(decryptedBuffer) 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( export async function searchForValidPassword(
filePath: string, filePath: string,
hash: Hash, hash: Hash,
config: EncryptionConfig, config: CompleteCryptoConfig,
blacklist: Set<string> | null = null,
): Promise<string | undefined> { ): Promise<string | undefined> {
const passwords = getRelevantPasswords(filePath) const passwords = getRelevantPasswords(filePath)
for (const password of passwords) { for (const password of passwords) {
if (blacklist && blacklist.has(password)) {
continue
}
if (await verifyPasswordHash(password, hash)) { if (await verifyPasswordHash(password, hash)) {
addPasswordToCache(password, filePath, config.ttl) addPasswordToCache(password, filePath, config.ttl)
return password return password
} }
if (blacklist) {
blacklist.add(password)
}
} }
return undefined return undefined
@ -566,27 +520,36 @@ export function getSharedDirectoryDepth(path1: string, path2: string): number {
export async function contentDecryptedEventListener( export async function contentDecryptedEventListener(
filePath: string, filePath: string,
hash: Hash, hash: Hash,
config: EncryptionConfig, config: CompleteCryptoConfig,
callback: (password: string) => void, callback: (password: string) => void,
once: boolean = true,
) { ) {
const checkForValidPassword: () => Promise<boolean> = async () => { const blacklist = new Set<string>()
const password = await searchForValidPassword(filePath, hash, config)
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)
}
}
const password = await searchForValidPassword(filePath, hash, config, blacklist)
if (password) { if (password) {
callback(password) callback(password)
return true
}
return false
} }
const result = await checkForValidPassword() document.addEventListener("decrypt", listener)
if (!result || !once) {
document.addEventListener("nav", async function listener() {
const result = await checkForValidPassword()
if (result && once) {
document.removeEventListener("nav", listener)
}
})
}
} }