mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-21 03:44:05 -06:00
Created plugin to publish encrypted pages
This commit is contained in:
parent
efddd798e8
commit
19abdca764
@ -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: [
|
||||
|
||||
361
quartz/components/scripts/encrypt.inline.ts
Normal file
361
quartz/components/scripts/encrypt.inline.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
149
quartz/components/styles/encrypt.scss
Normal file
149
quartz/components/styles/encrypt.scss
Normal 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 */
|
||||
}
|
||||
}
|
||||
@ -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]
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
224
quartz/plugins/transformers/encrypt.ts
Normal file
224
quartz/plugins/transformers/encrypt.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user