Created plugin to publish encrypted pages

This commit is contained in:
Yigit Colakoglu 2025-07-30 19:20:32 +02:00
parent efddd798e8
commit 19abdca764
7 changed files with 757 additions and 2 deletions

View File

@ -70,8 +70,16 @@ const config: QuartzConfig = {
Plugin.GitHubFlavoredMarkdown(),
Plugin.TableOfContents(),
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
Plugin.Description(),
Plugin.Latex({ renderEngine: "katex" }),
Plugin.EncryptPlugin({
algorithm: "aes-256-cbc",
keyLength: 32,
iterations: 100000,
encryptedFolders: {
},
ttl: 3600 * 24 * 7, // A week
}),
Plugin.Description(),
],
filters: [Plugin.RemoveDrafts()],
emitters: [

View File

@ -0,0 +1,361 @@
// Password cache management
const PASSWORD_CACHE_KEY = "quartz-encrypt-passwords"
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)
const pathParts = filePath.split("/")
// Sort cache keys by how many directory levels they share with current file
const sortedPaths = Object.keys(cache).sort((a, b) => {
const aShared = getSharedDirectoryDepth(a, filePath)
const bShared = getSharedDirectoryDepth(b, filePath)
return bShared - aShared // Descending order (most shared first)
})
for (const cachedPath of sortedPaths) {
if (getSharedDirectoryDepth(cachedPath, filePath) > 0) {
passwords.push(cache[cachedPath].password)
}
}
savePasswordCache(cache)
return passwords
}
function getSharedDirectoryDepth(path1: string, path2: string): number {
const parts1 = path1.split("/")
const parts2 = path2.split("/")
let sharedDepth = 0
const minLength = Math.min(parts1.length, parts2.length)
for (let i = 0; i < minLength - 1; i++) {
// -1 to exclude filename
if (parts1[i] === parts2[i]) {
sharedDepth++
} else {
break
}
}
return sharedDepth
}
// Helper: hex string to ArrayBuffer
function hexToArrayBuffer(hex: string): ArrayBuffer {
if (!hex) return new ArrayBuffer(0)
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16)
}
return bytes.buffer
}
// Helper: ArrayBuffer to hex string
function arrayBufferToHex(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
}
// Helper: string to ArrayBuffer
function stringToArrayBuffer(str: string): ArrayBuffer {
const encoder = new TextEncoder()
return encoder.encode(str)
}
// Helper: ArrayBuffer to string
function arrayBufferToString(buffer: ArrayBuffer): string {
const decoder = new TextDecoder()
return decoder.decode(buffer)
}
async function verifyPassword(password: string, parsed: any): Promise<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 loadingDiv = container.querySelector(".decrypt-loading") as HTMLElement
const form = container.querySelector(".decrypt-form") as HTMLElement
if (loadingDiv && form) {
if (show) {
form.style.display = "none"
loadingDiv.style.display = "flex"
} else {
form.style.display = "flex"
loadingDiv.style.display = "none"
}
}
}
async function decryptWithPassword(
container: Element,
password: string,
showError = true,
): Promise<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!)
if (showError) errorDiv.style.display = "none"
try {
const parsed = JSON.parse(atob(encryptedData))
// First verify password hash
const isValidPassword = await verifyPassword(password, parsed)
if (!isValidPassword) {
if (showError) throw new Error("Incorrect password")
return false
}
// Show loading indicator when hash passes and give UI time to update
if (showError) {
showLoading(container, true)
// Allow UI to update before starting heavy computation
await new Promise((resolve) => setTimeout(resolve, 50))
}
try {
// If hash matches, decrypt content
const decryptedContent = await performDecryption(password, parsed, config)
if (decryptedContent) {
// Cache the password
const filePath = window.location.pathname
addPasswordToCache(password, filePath, config.ttl)
// Replace content
const contentWrapper = document.createElement("div")
contentWrapper.className = "decrypted-content-wrapper"
contentWrapper.innerHTML = decryptedContent
container.parentNode!.replaceChild(contentWrapper, container)
return true
}
if (showError) throw new Error("Decryption failed, check logs")
return false
} catch (decryptError) {
if (showError) showLoading(container, false)
if (showError) throw new Error("Decryption failed, check logs")
return false
}
} catch (error) {
if (showError) {
showLoading(container, false)
errorDiv.style.display = "block"
errorDiv.textContent = error instanceof Error ? error.message : "Decryption failed"
const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement
if (passwordInput) {
passwordInput.value = ""
passwordInput.focus()
}
}
return false
}
}
async function tryAutoDecrypt(container: Element): Promise<boolean> {
const filePath = window.location.pathname
const passwords = getRelevantPasswords(filePath)
for (const password of passwords) {
if (await decryptWithPassword(container, password, false)) {
return true
}
}
return false
}
async function manualDecrypt(container: Element) {
const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement
const password = passwordInput.value
if (!password) {
passwordInput.focus()
return
}
await decryptWithPassword(container, password, true)
}
document.addEventListener("nav", async () => {
// Try auto-decryption for all encrypted content
const encryptedElements = document.querySelectorAll(".encrypted-content")
for (const container of encryptedElements) {
await tryAutoDecrypt(container)
}
// Manual decryption handlers
const buttons = document.querySelectorAll(".decrypt-button")
buttons.forEach((button) => {
const handleClick = async function (this: HTMLElement) {
const container = this.closest(".encrypted-content")!
await manualDecrypt(container)
}
button.addEventListener("click", handleClick)
window.addCleanup(() => button.removeEventListener("click", handleClick))
})
// Enter key handler
document.querySelectorAll(".decrypt-password").forEach((input) => {
const handleKeypress = async function (this: HTMLInputElement, e: Event) {
const keyEvent = e as KeyboardEvent
if (keyEvent.key === "Enter") {
const container = this.closest(".encrypted-content")!
await manualDecrypt(container)
}
}
input.addEventListener("keypress", handleKeypress)
window.addCleanup(() => input.removeEventListener("keypress", handleKeypress))
})
})

View File

@ -0,0 +1,149 @@
@use "../../styles/variables.scss" as *;
.encrypted-content {
display: flex;
align-items: center;
justify-content: center;
}
.encryption-notice {
background: rgba(0, 0, 0, 0.35);
border: 1px solid var(--lightgray);
border-radius: 8px;
padding: 2rem 1.5rem;
max-width: 450px;
width: 100%;
text-align: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
margin-top: 2rem;
}
.encryption-icon {
font-size: 2.5rem;
margin-bottom: 0.5rem;
opacity: 0.7;
}
.encryption-notice h3 {
margin: 0 0 0.5rem 0;
color: var(--darkgray);
font-size: 1.3rem;
font-weight: 600;
}
.encryption-notice p {
margin: 0 0 1.5rem 0;
color: var(--gray);
line-height: 1.4;
font-size: 0.95rem;
}
.decrypted-content-wrapper {
/* Reset any container styles that might interfere */
display: block;
margin: 0;
padding: 0;
}
.decrypt-form {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
}
.decrypt-password {
width: 100%;
box-sizing: border-box;
padding: 0.75rem 1rem;
border-radius: 5px;
font-family: var(--bodyFont);
font-size: 1rem;
border: 1px solid var(--gray);
background: rgba(0, 0, 0, 0);
color: var(--dark);
transition: border-color 0.2s ease;
}
.decrypt-password:focus {
outline: none;
border-color: var(--secondary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--secondary) 20%, transparent);
}
.decrypt-button {
background: var(--secondary);
color: var(--light);
border: none;
border-radius: 5px;
padding: 0.75rem 2rem;
font-family: var(--bodyFont);
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
min-width: 120px;
}
.decrypt-button:hover {
background: color-mix(in srgb, var(--secondary) 90%, var(--dark));
transform: translateY(-1px);
box-shadow: 0 4px 12px color-mix(in srgb, var(--secondary) 30%, transparent);
}
.decrypt-button:active {
transform: translateY(0);
}
.decrypt-loading {
display: none;
margin-top: 1rem;
padding: 0.75rem 1rem;
background: color-mix(in srgb, var(--secondary) 10%, var(--light));
border: 1px solid color-mix(in srgb, var(--secondary) 30%, transparent);
border-radius: 5px;
color: var(--secondary);
font-size: 0.9rem;
text-align: center;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid color-mix(in srgb, var(--secondary) 20%, transparent);
border-top: 2px solid var(--secondary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.decrypt-error {
display: none;
margin-top: 1rem;
padding: 0.75rem 1rem;
background: color-mix(in srgb, #ef4444 10%, var(--light));
border: 1px solid color-mix(in srgb, #ef4444 30%, transparent);
border-radius: 5px;
color: #dc2626;
font-size: 0.9rem;
text-align: center;
}
@media (max-width: 600px) {
.encryption-notice {
padding: 1.5rem 1rem;
margin: 1rem;
}
.decrypt-password,
.decrypt-button {
font-size: 16px; /* Prevent zoom on iOS */
}
}

View File

@ -19,6 +19,7 @@ export type ContentDetails = {
richContent?: string
date?: Date
description?: string
encrypted?: boolean
}
interface Options {
@ -58,7 +59,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndexMap, limit?:
<title>${escapeHTML(content.title)}</title>
<link>https://${joinSegments(base, encodeURI(slug))}</link>
<guid>https://${joinSegments(base, encodeURI(slug))}</guid>
<description><![CDATA[ ${content.richContent ?? content.description} ]]></description>
<description><![CDATA[ ${content.encrypted ? content.description : content.richContent ?? content.description} ]]></description>
<pubDate>${content.date?.toUTCString()}</pubDate>
</item>`
@ -115,6 +116,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
: undefined,
date: date,
description: file.data.description ?? "",
encrypted: file.data.encrypted,
})
}
}
@ -143,6 +145,11 @@ 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) {
delete content.content
delete content.richContent
}
delete content.description
delete content.date
return [slug, content]

View File

@ -28,6 +28,11 @@ export const Description: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
return [
() => {
return async (tree: HTMLRoot, file) => {
if (file.data?.encrypted) {
file.data.description = "This file is encrypted. Open it to see the contents."
return;
}
let frontMatterDescription = file.data.frontmatter?.description
let text = escapeHTML(toString(tree))

View File

@ -0,0 +1,224 @@
import { createCipheriv, randomBytes, pbkdf2Sync, createHash } from "crypto"
import { QuartzTransformerPlugin } from "../types"
import { Root } from "hast"
import { toHtml } from "hast-util-to-html"
import { fromHtml } from "hast-util-from-html"
import { VFile } from "vfile"
// @ts-ignore
import encryptScript from "../../components/scripts/encrypt.inline.ts"
import encryptStyle from "../../components/styles/encrypt.scss"
export interface Options {
algorithm?: string
keyLength?: number
iterations?: number
encryptedFolders?: { [folderPath: string]: string } // json object with folder paths as keys and passwords as values
ttl: number
}
const defaultOptions: Options = {
algorithm: "aes-256-cbc",
keyLength: 32,
iterations: 100000,
encryptedFolders: {},
ttl: 3600 * 24 * 7,
}
const SUPPORTED_ALGORITHMS = ["aes-256-cbc", "aes-256-gcm", "aes-256-ecb"] as const
type SupportedAlgorithm = (typeof SUPPORTED_ALGORITHMS)[number]
function deriveKey(password: string, salt: Buffer, keyLength: number, iterations: number): Buffer {
return pbkdf2Sync(password, salt, iterations, keyLength, "sha256")
}
function hashPassword(password: string, salt: Buffer): string {
// Create a fast hash for password verification (separate from key derivation)
const hash = createHash("sha256")
hash.update(password)
hash.update(salt)
const result = hash.digest("hex")
return result
}
function encryptContent(content: string, password: string, options: Options): string {
const { algorithm, keyLength, iterations } = { ...defaultOptions, ...options }
// Validate algorithm
if (!SUPPORTED_ALGORITHMS.includes(algorithm as SupportedAlgorithm)) {
throw new Error(
`Unsupported encryption algorithm: ${algorithm}. Supported: ${SUPPORTED_ALGORITHMS.join(", ")}`,
)
}
const salt = randomBytes(16)
const key = deriveKey(password, salt, keyLength!, iterations!)
let iv: Buffer | undefined
let cipher: any
// Handle different encryption modes
if (algorithm === "aes-256-ecb") {
// ECB doesn't use IV
cipher = createCipheriv(algorithm!, key, null)
} else {
// CBC and GCM use IV
iv = randomBytes(16)
cipher = createCipheriv(algorithm!, key, iv)
}
let encrypted = cipher.update(content, "utf8", "hex")
encrypted += cipher.final("hex")
// Handle auth tag for GCM
let authTag: string | undefined
if (algorithm === "aes-256-gcm") {
authTag = cipher.getAuthTag().toString("hex")
}
// Create password hash for verification
const passwordHash = hashPassword(password, salt)
// Build result object
const result: any = {
salt: salt.toString("hex"),
content: encrypted,
passwordHash,
}
if (iv) {
result.iv = iv.toString("hex")
}
if (authTag) {
result.authTag = authTag
}
return Buffer.from(JSON.stringify(result)).toString("base64")
}
export const EncryptPlugin: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
const opts = { ...defaultOptions, ...userOpts }
// Validate algorithm at build time
if (opts.algorithm && !SUPPORTED_ALGORITHMS.includes(opts.algorithm as SupportedAlgorithm)) {
throw new Error(
`[EncryptPlugin] Unsupported encryption algorithm: ${opts.algorithm}. Supported algorithms: ${SUPPORTED_ALGORITHMS.join(", ")}`,
)
}
const getPassword = (file: VFile): string | undefined => {
const frontmatter = file.data?.frontmatter
if (frontmatter?.encrypt && frontmatter?.password) {
return frontmatter.password as string
}
let deepestFolder = "";
for (const folder of Object.keys(opts.encryptedFolders ?? {})) {
if (file.data?.relativePath?.startsWith(folder) && deepestFolder.length < folder.length) {
deepestFolder = folder;
}
}
if (deepestFolder) {
return String(opts.encryptedFolders[deepestFolder])
}
}
return {
name: "EncryptPlugin",
markdownPlugins() {
// If encypted, prepend lock emoji before the title
return [
() => {
return (_, file) => {
const password = getPassword(file)
if (!password) {
return
}
file.data.encrypted = true
if (file.data?.frontmatter?.title) {
file.data.frontmatter.title = `🔒 ${file.data.frontmatter.title}`
}
}
},
]
},
htmlPlugins() {
return [
() => {
return (tree: Root, file) => {
const password = getPassword(file)
if (!password) {
return tree // No encryption, return original tree
}
// Convert the HTML tree to string
const htmlContent = toHtml(tree)
// Encrypt the content
const encryptedContent = encryptContent(htmlContent, password, opts)
// Create a new tree with encrypted content placeholder
const encryptedTree = fromHtml(
`
<div class="encrypted-content" data-encrypted="${encryptedContent}" data-config='${JSON.stringify(opts)}'>
<div class="encryption-notice">
<h3>🛡 Restricted Content 🛡</h3>
<p>This content is restricted. Enter the password to view:</p>
<div class="decrypt-form">
<input type="password" class="decrypt-password" placeholder="Enter password" />
<button class="decrypt-button">Decrypt</button>
</div>
<div class="decrypt-loading">
<div class="loading-spinner"></div>
<span>Decrypting...</span>
</div>
<div class="decrypt-error">
Incorrect password. Please try again.
</div>
</div>
</div>
`,
{ fragment: true },
)
// Replace the original tree
tree.children = encryptedTree.children
return tree
}
},
]
},
externalResources() {
return {
js: [
{
loadTime: "afterDOMReady",
contentType: "inline",
script: encryptScript,
},
],
css: [
{
content: encryptStyle,
inline: true,
},
],
additionalHead: [],
}
},
}
}
declare module "vfile" {
interface DataMap {
encrypted: boolean
}
}

View File

@ -8,6 +8,7 @@ export { CrawlLinks } from "./links"
export { ObsidianFlavoredMarkdown } from "./ofm"
export { OxHugoFlavouredMarkdown } from "./oxhugofm"
export { SyntaxHighlighting } from "./syntax"
export { EncryptPlugin } from "./encrypt"
export { TableOfContents } from "./toc"
export { HardLineBreaks } from "./linebreaks"
export { RoamFlavoredMarkdown } from "./roam"