Clean up the plugin configuration types and make fields nullable

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

View File

@ -17,132 +17,90 @@ This plugin enables content encryption for sensitive pages in your Quartz site.
```typescript
Plugin.Encrypt({
algorithm: "aes-256-cbc", // Encryption algorithm (key length auto-inferred)
algorithm: "aes-256-cbc", // Encryption algorithm
ttl: 3600 * 24 * 7, // Password cache TTL in seconds (7 days)
message: "This content is encrypted.", // Default message shown
encryptedFolders: {
// Folder-level encryption with simple passwords
// Simple password for a folder
"private/": "folder-password",
"work/confidential/": "work-password",
// Advanced per-folder configuration
// Advanced configuration for a folder
"secure/": {
password: "advanced-password",
algorithm: "aes-256-gcm",
ttl: 3600 * 24 * 30, // 30 days
message: "Authorized access only",
},
},
ttl: 3600 * 24 * 7, // Password cache TTL in seconds (7 days)
})
```
> [!warning]
> It is very important to note that:
> Important security notes:
>
> - All non-markdown files will be left unencrypted in the final build.
> - Marking something as encrypted will only encrypt the page in the final build. The file's content can still be viewed if your repository is public.
> - All non-markdown files remain unencrypted in the final build
> - Encrypted content is still visible in your source repository if it's public
> - Use this for access control, not for storing highly sensitive secrets
### Configuration Options
- `algorithm`: Encryption algorithm to use. Supported values:
- `algorithm`: Encryption algorithm to use
- `"aes-256-cbc"` (default): AES-256 in CBC mode
- `"aes-256-gcm"`: AES-256 in GCM mode (authenticated encryption)
- `"aes-256-ecb"`: AES-256 in ECB mode (not recommended for security)
- Key length is automatically inferred from the algorithm (e.g., 256-bit = 32 bytes)
- `encryptedFolders`: Object mapping folder paths to passwords or configuration objects for folder-level encryption
- `ttl`: Time-to-live for cached passwords in seconds (default: 604800 = 7 days, set to 0 for session-only)
- `message`: Message to be displayed in the decryption page
- `"aes-256-ecb"`: AES-256 in ECB mode (not recommended)
- `ttl`: Time-to-live for cached passwords in seconds (default: 7 days)
- `message`: Default message shown on encrypted pages
- `encryptedFolders`: Configure folder-level encryption
### Advanced Folder Configuration
## How Configuration Works
You can provide detailed configuration for each encrypted folder:
### Configuration Inheritance
Settings cascade down through your folder structure:
```typescript
encryptedFolders: {
"basic/": "simple-password", // Simple string password
"advanced/": {
password: "complex-password",
algorithm: "aes-256-gcm", // Override global algorithm
ttl: 3600 * 24 * 30, // Override global TTL (30 days)
message: "This content is encrypted", // Message to be displayed
"docs/": {
password: "docs-password",
algorithm: "aes-256-gcm"
},
"docs/internal/": {
password: "internal-password"
// Inherits algorithm from parent folder
}
}
```
## Usage
In this example:
### Folder-level Encryption
- `docs/page.md` uses `"docs-password"` with `"aes-256-gcm"`
- `docs/internal/report.md` uses `"internal-password"` but still uses `"aes-256-gcm"` (inherited)
Configure folders to be encrypted by adding them to the `encryptedFolders` option:
### Configuration Priority
```typescript
Plugin.Encrypt({
encryptedFolders: {
"private/": "my-secret-password",
"work/": "work-password",
"personal/diary/": "diary-password",
},
})
```
When multiple configurations apply, the priority is:
All pages within these folders will be encrypted with the specified password. Nested folders inherit passwords from parent folders, with deeper paths taking precedence.
### Page-level Encryption
Use frontmatter to encrypt individual pages or override folder passwords.
```yaml
---
title: "My Secret Page"
password: "page-specific-password"
encryptConfig:
password: "password-also-allowed-here"
message: "Sorry, this one is only for my eyes"
algorithm: "aes-256-gcm" # Optional: override algorithm
ttl: 86400 # Optional: override TTL (1 day)
---
This content will be encrypted and require a password to view.
```
### Frontmatter Fields
#### encryptConfig object fields:
- `password`: (required) The password required to decrypt this page
- `message`: (optional) Custom message to show on the unlock page
- `algorithm`: (optional) Override the encryption algorithm for this page
- `ttl`: (optional) Override password cache TTL for this page
#### Legacy fields (still supported):
- `encrypt`: Set to `true` to enable encryption for this page
- `password`: The password required to decrypt this page
If a page is in an encrypted folder but has its own password configuration, the page-specific settings will be used instead of the folder settings.
### Security Considerations
- Use strong passwords for sensitive content
- Consider using AES-256-GCM mode for authenticated encryption
- ECB mode is provided for compatibility but is not recommended for security-critical applications
- Key lengths are automatically determined by the algorithm (no manual configuration needed)
1. **Page frontmatter** (highest priority)
2. **Deepest matching folder**
3. **Parent folders** (inherited settings)
4. **Global defaults** (lowest priority)
## Security Features
### Password Cache
### Password Caching
The plugin implements intelligent password caching:
- Passwords are stored in browser localStorage
- Automatic expiration based on TTL settings
- Cached passwords are tried automatically when navigating
- Passwords are cached in browser localStorage
- Configurable TTL (time-to-live) for automatic cache expiration
- Passwords are automatically tried when navigating to encrypted pages
### Protection Levels
### Content Protection
- **Full Content Encryption**: The entire HTML content is encrypted, not just hidden
- **SEO Protection**: Search engines and RSS feeds see generic placeholder descriptions
- **Client-side Decryption**: Content is never transmitted in plain text
- **Password Verification**: Fast password hash verification before attempting decryption
- **Content**: Entire page HTML is encrypted
- **Search/RSS**: Only generic descriptions are exposed
- **Navigation**: Encrypted pages appear in navigation but require passwords to view
## API
- Category: Transformer
- Function name: `Plugin.Encrypt()`.
- Source: [`quartz/plugins/transformers/encrypt.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/encrypt.ts).
- Function name: `Plugin.Encrypt()`
- Source: [`quartz/plugins/transformers/encrypt.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/encrypt.ts)

1
index.d.ts vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,78 +2,238 @@ import { QuartzTransformerPlugin } from "../types"
import { Root } from "hast"
import { toHtml } from "hast-util-to-html"
import { fromHtml } from "hast-util-from-html"
import { toString } from "hast-util-to-string"
import { VFile } from "vfile"
import { i18n } from "../../i18n"
import {
EncryptionOptions,
DirectoryConfig,
defaultEncryptionConfig,
SUPPORTED_ALGORITHMS,
SupportedEncryptionAlgorithm,
encryptContent,
getEncryptionConfigForPath,
Hash,
hashString,
EncryptionResult,
CompleteCryptoConfig,
BaseCryptoConfig,
EncryptionConfig,
DirectoryConfig,
} from "../../util/encryption"
// @ts-ignore
import encryptScript from "../../components/scripts/encrypt.inline.ts"
import encryptStyle from "../../components/styles/encrypt.scss"
export interface Options extends EncryptionOptions {}
const defaultOptions: Options = {
...defaultEncryptionConfig,
encryptedFolders: {},
// =============================================================================
// TYPE DEFINITIONS
// =============================================================================
// Plugin configuration
export interface PluginConfig extends CompleteCryptoConfig {
encryptedFolders: Record<string, DirectoryConfig>
}
export const Encrypt: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
const opts = { ...defaultOptions, ...userOpts }
// User-provided options (all optional)
export type PluginOptions = Partial<PluginConfig>
// Validate algorithm at build time
if (
opts.algorithm &&
!SUPPORTED_ALGORITHMS.includes(opts.algorithm as SupportedEncryptionAlgorithm)
) {
// Internal normalized folder configuration
interface NormalizedFolderConfig extends CompleteCryptoConfig {
password: string
path: string
depth: number
}
// =============================================================================
// CONFIGURATION MANAGEMENT
// =============================================================================
const DEFAULT_CONFIG: CompleteCryptoConfig = {
algorithm: "aes-256-cbc",
ttl: 3600 * 24 * 7, // 1 week
message: "This content is encrypted.",
}
/**
* Normalizes a directory configuration into a complete configuration object
*/
function normalizeDirectoryConfig(
dirConfig: DirectoryConfig,
defaults: CompleteCryptoConfig,
): EncryptionConfig {
if (typeof dirConfig === "string") {
return {
...defaults,
password: dirConfig,
}
}
return {
algorithm: dirConfig.algorithm ?? defaults.algorithm,
ttl: dirConfig.ttl ?? defaults.ttl,
message: dirConfig.message ?? defaults.message,
password: dirConfig.password,
}
}
/**
* Creates a sorted list of folder configurations by path depth
* This ensures parent configurations are processed before children
*/
function createFolderConfigHierarchy(
folders: Record<string, DirectoryConfig>,
globalDefaults: CompleteCryptoConfig,
): NormalizedFolderConfig[] {
const configs: NormalizedFolderConfig[] = []
for (const [path, config] of Object.entries(folders)) {
const normalized = normalizeDirectoryConfig(config, globalDefaults)
configs.push({
...normalized,
path: path.endsWith("/") ? path : path + "/",
depth: path.split("/").filter(Boolean).length,
})
}
// Sort by depth (shallow to deep) to ensure proper inheritance
return configs.sort((a, b) => a.depth - b.depth)
}
/**
* Merges configurations following the inheritance chain
* Deeper paths override shallower ones
*/
function mergeConfigurations(
base: CompleteCryptoConfig,
override: Partial<BaseCryptoConfig>,
): CompleteCryptoConfig {
return {
algorithm: override.algorithm ?? base.algorithm,
ttl: override.ttl ?? base.ttl,
message: override.message ?? base.message,
}
}
/**
* Gets the encryption configuration for a specific file path
* Respects the directory hierarchy and inheritance
*/
function getConfigurationForPath(
filePath: string,
folderConfigs: NormalizedFolderConfig[],
globalDefaults: CompleteCryptoConfig,
): EncryptionConfig | undefined {
let currentConfig: CompleteCryptoConfig = globalDefaults
let password: string | undefined
// Apply configurations in order (shallow to deep)
for (const folderConfig of folderConfigs) {
if (filePath.startsWith(folderConfig.path)) {
// Merge the configuration, inheriting from parent
currentConfig = mergeConfigurations(currentConfig, folderConfig)
password = folderConfig.password
}
}
// If no password was found in any matching folder, return undefined
if (!password) {
return undefined
}
return {
...currentConfig,
password,
}
}
/**
* Validates the plugin configuration
*/
function validateConfig(config: PluginConfig): void {
if (!SUPPORTED_ALGORITHMS.includes(config.algorithm)) {
throw new Error(
`[EncryptPlugin] Unsupported encryption algorithm: ${opts.algorithm}. Supported algorithms: ${SUPPORTED_ALGORITHMS.join(", ")}`,
`[EncryptPlugin] Unsupported encryption algorithm: ${config.algorithm}. ` +
`Supported algorithms: ${SUPPORTED_ALGORITHMS.join(", ")}`,
)
}
const getEncryptionConfig = (file: VFile): DirectoryConfig | undefined => {
if (config.ttl <= 0) {
throw new Error(`[EncryptPlugin] TTL must be a positive number`)
}
}
// =============================================================================
// PLUGIN IMPLEMENTATION
// =============================================================================
export const Encrypt: QuartzTransformerPlugin<PluginOptions> = (userOpts) => {
// Merge user options with defaults
const pluginConfig: PluginConfig = {
...DEFAULT_CONFIG,
...userOpts,
encryptedFolders: userOpts?.encryptedFolders ?? {},
}
// Validate configuration
validateConfig(pluginConfig)
// Pre-process folder configurations for efficient lookup
const folderConfigs = createFolderConfigHierarchy(pluginConfig.encryptedFolders, pluginConfig)
/**
* Determines the final encryption configuration for a file
* Priority: frontmatter > deepest matching folder > global defaults
*/
const getEncryptionConfig = (file: VFile): EncryptionConfig | undefined => {
const frontmatter = file.data?.frontmatter
const frontmatterConfig = (frontmatter?.encryptConfig ?? {}) as DirectoryConfig
const relativePath = file.data?.relativePath
const folderConfig = relativePath ? getEncryptionConfigForPath(relativePath, opts) : null
// Check if file should be encrypted via frontmatter
const shouldEncryptViaFrontmatter = frontmatter?.encrypt === true
const frontmatterConfig = frontmatter?.encryptConfig as
| (BaseCryptoConfig & { password?: string })
| undefined
if (!folderConfig && !frontmatterConfig.password) {
return undefined
} else if (!folderConfig && !frontmatter?.encrypt) {
// Get folder-based configuration
const folderConfig = relativePath
? getConfigurationForPath(relativePath, folderConfigs, pluginConfig)
: undefined
// If neither folder config nor frontmatter indicates encryption, skip
if (!folderConfig && !shouldEncryptViaFrontmatter) {
return undefined
}
const config = {
algorithm: frontmatterConfig.algorithm || folderConfig?.algorithm || opts.algorithm,
password: frontmatterConfig.password || folderConfig?.password || "",
message: frontmatterConfig.message || folderConfig?.message || opts.message,
ttl: frontmatterConfig.ttl || folderConfig?.ttl || opts.ttl,
// Build final configuration with proper precedence
let finalConfig: EncryptionConfig | undefined
if (folderConfig && frontmatterConfig) {
// Merge folder and frontmatter configs
finalConfig = {
algorithm: frontmatterConfig.algorithm ?? folderConfig.algorithm,
ttl: frontmatterConfig.ttl ?? folderConfig.ttl,
message: frontmatterConfig.message ?? folderConfig.message,
password: frontmatterConfig.password ?? folderConfig.password,
}
} else if (folderConfig) {
// Use folder config only
finalConfig = folderConfig
} else if (frontmatterConfig?.password) {
// Use frontmatter config with global defaults
finalConfig = {
algorithm: frontmatterConfig.algorithm ?? pluginConfig.algorithm,
ttl: frontmatterConfig.ttl ?? pluginConfig.ttl,
message: frontmatterConfig.message ?? pluginConfig.message,
password: frontmatterConfig.password,
}
}
if (!config.password) {
// Validate final configuration
if (!finalConfig?.password) {
console.warn(`[EncryptPlugin] No password configured for ${relativePath}`)
return undefined
}
return config
return finalConfig
}
return {
name: "Encrypt",
markdownPlugins() {
// If encrypted, prepend lock emoji before the title
return [
() => {
return async (_, file) => {
@ -93,34 +253,33 @@ export const Encrypt: QuartzTransformerPlugin<Partial<Options>> = (userOpts) =>
() => {
return async (tree: Root, file) => {
const config = getEncryptionConfig(file)
if (!file.data.hash || !config) {
if (!config || !file.data.hash) {
return tree
}
const locale = ctx.cfg.configuration.locale
const t = i18n(locale).components.encryption
// Convert html to plaintext and encrypt it
file.data.encryptionResult = await encryptContent(
toString(tree),
config.password,
config,
)
// Encrypt the content and generate verification hash
// Encrypt the content
const encryptionResult = await encryptContent(toHtml(tree), config.password, config)
// Create individual attributes for each field instead of JSON
// Store for later use
file.data.encryptionResult = encryptionResult
// Create attributes for client-side decryption
const attributes = [
`data-config='${JSON.stringify(config)}'`,
`data-config='${JSON.stringify({
algorithm: config.algorithm,
ttl: config.ttl,
message: config.message,
})}'`,
`data-encrypted='${JSON.stringify(encryptionResult)}'`,
`data-hash='${JSON.stringify(file.data.hash)}'`,
`data-slug='${file.data.slug}'`,
`data-decrypted='false'`,
].join(" ")
// Create a new tree with encrypted content placeholder
// Create encrypted content placeholder
const encryptedTree = fromHtml(
`
<div class="encrypted-content" ${attributes}>
@ -149,7 +308,6 @@ export const Encrypt: QuartzTransformerPlugin<Partial<Options>> = (userOpts) =>
// Replace the original tree
tree.children = encryptedTree.children
return tree
}
},
@ -170,12 +328,15 @@ export const Encrypt: QuartzTransformerPlugin<Partial<Options>> = (userOpts) =>
inline: true,
},
],
additionalHead: [],
}
},
}
}
// =============================================================================
// MODULE AUGMENTATION
// =============================================================================
declare module "vfile" {
interface DataMap {
encryptionConfig: EncryptionConfig

View File

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

View File

@ -1,16 +1,17 @@
// =============================================================================
// TYPES AND INTERFACES
// =============================================================================
export const SUPPORTED_ALGORITHMS = ["aes-256-cbc", "aes-256-gcm", "aes-256-ecb"] as const
export type SupportedEncryptionAlgorithm = (typeof SUPPORTED_ALGORITHMS)[number]
// Result of hash operation
export interface Hash {
hash: string
salt: string
}
// Result of encryption operation
export interface EncryptionResult {
encryptedContent: string
encryptionSalt: string
@ -18,30 +19,28 @@ export interface EncryptionResult {
authTag?: string
}
export interface EncryptionConfig {
algorithm: string
// Base crypto configuration without password
export interface BaseCryptoConfig {
algorithm?: SupportedEncryptionAlgorithm
ttl?: number
message?: string
}
// Directory configuration can be partial or just a password string
export type DirectoryConfig = (BaseCryptoConfig & { password: string }) | string
// Complete crypto configuration with all required fields
export interface CompleteCryptoConfig {
algorithm: SupportedEncryptionAlgorithm
ttl: number
message: string
}
export interface DirectoryConfig extends EncryptionConfig {
// Encryption configuration includes password
export interface EncryptionConfig extends CompleteCryptoConfig {
password: string
}
export interface EncryptionOptions extends EncryptionConfig {
encryptedFolders: { [folderPath: string]: string | DirectoryConfig }
}
// =============================================================================
// CONSTANTS AND CONFIGURATION
// =============================================================================
export const defaultEncryptionConfig: EncryptionConfig = {
algorithm: "aes-256-cbc",
ttl: 3600 * 24 * 7,
message: "This content is encrypted.",
}
const ENCRYPTION_CACHE_KEY = "quartz-encrypt-passwords"
// =============================================================================
@ -197,7 +196,7 @@ export async function verifyPasswordHash(
export async function encryptContent(
content: string,
password: string,
config: EncryptionConfig,
config: CompleteCryptoConfig,
): Promise<EncryptionResult> {
checkCryptoSupport()
@ -290,7 +289,7 @@ export async function encryptContent(
export async function decryptContent(
encrypted: EncryptionResult,
config: EncryptionConfig,
config: CompleteCryptoConfig,
password: string,
): Promise<string> {
checkCryptoSupport()
@ -364,72 +363,27 @@ export async function decryptContent(
return arrayBufferToString(decryptedBuffer)
}
// =============================================================================
// CONFIGURATION MANAGEMENT
// =============================================================================
export function normalizeDirectoryConfig(
folderConfig: string | DirectoryConfig,
globalConfig: EncryptionConfig,
): DirectoryConfig {
if (typeof folderConfig === "string") {
return {
...globalConfig,
password: folderConfig,
}
}
return {
algorithm: folderConfig.algorithm || globalConfig.algorithm,
ttl: folderConfig.ttl || globalConfig.ttl,
password: folderConfig.password,
message: folderConfig.message || globalConfig.message,
}
}
export function getEncryptionConfigForPath(
filePath: string,
options: EncryptionOptions,
): DirectoryConfig | undefined {
if (!options.encryptedFolders) {
return undefined
}
let deepestFolder = ""
let deepestConfig: string | DirectoryConfig | undefined
for (const [folder, config] of Object.entries(options.encryptedFolders)) {
if (filePath.startsWith(folder) && deepestFolder.length < folder.length) {
deepestFolder = folder
deepestConfig = config
}
}
if (deepestConfig) {
const globalConfig = {
algorithm: options.algorithm || defaultEncryptionConfig.algorithm,
ttl: options.ttl || defaultEncryptionConfig.ttl,
message: options.message || defaultEncryptionConfig.message,
}
return normalizeDirectoryConfig(deepestConfig, globalConfig)
}
return undefined
}
export async function searchForValidPassword(
filePath: string,
hash: Hash,
config: EncryptionConfig,
config: CompleteCryptoConfig,
blacklist: Set<string> | null = null,
): Promise<string | undefined> {
const passwords = getRelevantPasswords(filePath)
for (const password of passwords) {
if (blacklist && blacklist.has(password)) {
continue
}
if (await verifyPasswordHash(password, hash)) {
addPasswordToCache(password, filePath, config.ttl)
return password
}
if (blacklist) {
blacklist.add(password)
}
}
return undefined
@ -566,27 +520,36 @@ export function getSharedDirectoryDepth(path1: string, path2: string): number {
export async function contentDecryptedEventListener(
filePath: string,
hash: Hash,
config: EncryptionConfig,
config: CompleteCryptoConfig,
callback: (password: string) => void,
once: boolean = true,
) {
const checkForValidPassword: () => Promise<boolean> = async () => {
const password = await searchForValidPassword(filePath, hash, config)
if (password) {
callback(password)
return true
const blacklist = new Set<string>()
async function decryptionSuccessful(password: string) {
addPasswordToCache(password, filePath, config.ttl)
callback(password)
}
async function listener(e: CustomEventMap["decrypt"]) {
const password = e.detail.password
if (blacklist.has(password)) {
return
}
if (await verifyPasswordHash(password, hash)) {
document.removeEventListener("decrypt", listener)
await decryptionSuccessful(password)
return
} else {
blacklist.add(password)
}
return false
}
const result = await checkForValidPassword()
if (!result || !once) {
document.addEventListener("nav", async function listener() {
const result = await checkForValidPassword()
if (result && once) {
document.removeEventListener("nav", listener)
}
})
const password = await searchForValidPassword(filePath, hash, config, blacklist)
if (password) {
callback(password)
}
document.addEventListener("decrypt", listener)
}