mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-19 10:54:06 -06:00
Tried to address the comments
This commit is contained in:
parent
ff83a93588
commit
4e02f7810e
@ -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
8
index.d.ts
vendored
@ -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>
|
||||
|
||||
@ -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
|
||||
}),
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 = ""
|
||||
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)) {
|
||||
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)
|
||||
// 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)
|
||||
// Check if window.addCleanup exists before using it
|
||||
if (window.addCleanup) {
|
||||
window.addCleanup(() => input.removeEventListener("keypress", handleKeypress))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,7 +488,45 @@ 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)) {
|
||||
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,
|
||||
@ -481,12 +537,16 @@ async function fillDocument(data: ContentIndex) {
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
indexPopulated = true
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
@ -4,73 +4,64 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.encryption-notice {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
.encryption-notice {
|
||||
background: color-mix(in srgb, var(--lightgray) 60%, var(--light));
|
||||
border: 1px solid var(--lightgray);
|
||||
border-radius: 8px;
|
||||
border-radius: 5px;
|
||||
padding: 2rem 1.5rem;
|
||||
max-width: 450px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 6px 36px rgba(0, 0, 0, 0.15);
|
||||
margin-top: 2rem;
|
||||
}
|
||||
font-family: var(--bodyFont);
|
||||
|
||||
.encryption-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.encryption-notice h3 {
|
||||
& h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--darkgray);
|
||||
color: var(--dark);
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
font-weight: $semiBoldWeight;
|
||||
font-family: var(--headerFont);
|
||||
}
|
||||
|
||||
.encryption-notice p {
|
||||
color: var(--gray);
|
||||
line-height: 1.4;
|
||||
& p {
|
||||
color: var(--darkgray);
|
||||
line-height: 1.5;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.decrypted-content-wrapper {
|
||||
/* Reset any container styles that might interfere */
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.decrypt-form {
|
||||
.decrypt-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.decrypt-password {
|
||||
.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);
|
||||
border: 1px solid var(--lightgray);
|
||||
background: var(--light);
|
||||
color: var(--dark);
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.decrypt-password:focus {
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--secondary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--secondary) 20%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.decrypt-button {
|
||||
@media all and ($mobile) {
|
||||
font-size: 16px; // Prevent zoom on iOS
|
||||
}
|
||||
}
|
||||
|
||||
.decrypt-button {
|
||||
background: var(--secondary);
|
||||
color: var(--light);
|
||||
border: none;
|
||||
@ -78,23 +69,38 @@
|
||||
padding: 0.75rem 2rem;
|
||||
font-family: var(--bodyFont);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
font-weight: $semiBoldWeight;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.decrypt-button:hover {
|
||||
&: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 {
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.decrypt-loading {
|
||||
@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;
|
||||
@ -107,15 +113,55 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
if (!config.password) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return opts.encryptedFolders!![deepestFolder] as string
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
595
quartz/util/encryption.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user