Tried to address the comments

This commit is contained in:
Yigit Colakoglu 2025-07-31 06:52:55 +02:00
parent ff83a93588
commit 4e02f7810e
18 changed files with 1174 additions and 584 deletions

View File

@ -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

8
index.d.ts vendored
View File

@ -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<FullSlug, ContentDetails>
type DecryptedFlag = {
decrypted?: boolean
}
type ContentIndex = Record<FullSlug, ContentDetails & DecryptedFlag>
declare const fetchData: Promise<ContentIndex>

View File

@ -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
}),

View File

@ -4,7 +4,12 @@ import { classNames } from "../util/lang"
const ArticleTitle: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => {
const title = fileData.frontmatter?.title
if (title) {
return <h1 class={classNames(displayClass, "article-title")}>{title}</h1>
return (
<h1 class={classNames(displayClass, "article-title")}>
{fileData.encryptionResult && <span className="article-title-icon">🔒 </span>}
{title}
</h1>
)
} else {
return null
}

View File

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

View File

@ -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<string, { password: string; ttl: number }> {
try {
const cache = localStorage.getItem(PASSWORD_CACHE_KEY)
return cache ? JSON.parse(cache) : {}
} catch {
return {}
}
}
function savePasswordCache(cache: Record<string, { password: string; ttl: number }>) {
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<boolean> {
// 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<string> {
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<boolean> {
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<boolean> => {
const errorDivs = container.querySelectorAll(".decrypt-error") as NodeListOf<HTMLElement>
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<boolean> {
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<boolean> => {
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<HTMLElement>
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))
}
})
})

View File

@ -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

View File

@ -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() {

View File

@ -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<HTMLElement>
const nodes = document.querySelectorAll(
"code.mermaid:not([data-processed])",
) as NodeListOf<HTMLElement>
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
}
}

View File

@ -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)

View File

@ -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<Promise<unknown>> = []
for (const [slug, fileData] of Object.entries<ContentDetails>(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")

View File

@ -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;
}

View File

@ -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<FullSlug, ContentDetails>
// 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<Partial<Options>> = (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<Partial<Options>> = (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<Partial<Options>> = (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

View File

@ -29,9 +29,9 @@ export const Description: QuartzTransformerPlugin<Partial<Options>> = (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
}

View File

@ -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<Partial<Options>> = (userOpts) => {
export const Encrypt: QuartzTransformerPlugin<Partial<Options>> = (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<Partial<Options>> = (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(
`
<div class="encrypted-content" data-encrypted="${encryptedContent}" data-config='${JSON.stringify(opts)}' data-i18n='${JSON.stringify(t)}'>
<div class="encrypted-content" ${attributes}>
<div class="encryption-notice">
<h3>${t.title}</h3>
<p>${t.restricted}</p>
${config.message ? `<p>${config.message}</p>` : ""}
<div class="decrypt-form">
<input type="password" class="decrypt-password" placeholder="${t.enterPassword}" />
<button class="decrypt-button">${t.decrypt}</button>
${file.data.encryptMessage ? `<p class="encrypted-message-footnote">${file.data.encryptMessage}</p>` : ""}
</div>
<div class="decrypt-loading">
<div class="loading-spinner"></div>
<span>${t.decrypting}</span>
</div>
<div class="decrypt-error">
<div class="decrypt-error" data-error="incorrect-password">
${t.incorrectPassword}
</div>
<div class="decrypt-error" data-error="decryption-failed">
${t.decryptionFailed}
</div>
</div>
</div>
`,
@ -233,8 +174,8 @@ export const EncryptPlugin: QuartzTransformerPlugin<Partial<Options>> = (userOpt
declare module "vfile" {
interface DataMap {
encrypted: boolean
encryptMessage?: string
password?: string
encryptionConfig: EncryptionConfig
encryptionResult: EncryptionResult
hash: Hash
}
}

View File

@ -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<Partial<Options>> = (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
}>
}
}

View File

@ -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"

595
quartz/util/encryption.ts Normal file
View File

@ -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<CryptoKey> {
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<Hash> {
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<boolean> {
const { hash: passwordHash } = await hashString(password, passwordHashData.salt)
return passwordHash === passwordHashData.hash
}
export async function encryptContent(
content: string,
password: string,
config: EncryptionConfig,
): Promise<EncryptionResult> {
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<string> {
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<string | undefined> {
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<void> = Promise.resolve()
interface CachedPassword {
password: string
ttl: number
}
// Helper function to execute cache operations atomically
async function executeAtomicCacheOperation<T>(operation: () => T): Promise<T> {
return new Promise<T>((resolve, reject) => {
cacheOperationQueue = cacheOperationQueue
.then(() => {
try {
const result = operation()
resolve(result)
} catch (error) {
reject(error)
}
})
.catch((error) => {
reject(error)
})
})
}
export function getPasswordCache(): Record<string, CachedPassword> {
// 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<string, CachedPassword>) {
// 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<void> {
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<string> = 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<boolean> = 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)
}
})
}
}