Files
FixIt/assets/js/lib/fixit-decryptor.ts
Cell bfee4041d6 refactor(encrypt): rename post-encrypt to encrypt
- Rename package from @hugo-fixit/post-encrypt to @hugo-fixit/encrypt
- Rename bin from post-encrypt to fixit-encrypt
- Update all i18n translations (16 files) to use npx @hugo-fixit/encrypt
- Update CI workflow, publish script, code comments, and docs
- Rewrite README with performance benchmarks and other implementations
2026-07-19 13:15:57 +08:00

388 lines
14 KiB
TypeScript

/**
* Encrypted content decryptor for FixIt pages and shortcodes.
*
* Responsibilities:
* - Validate password input and decrypt AES-256-GCM payloads.
* - Support both full-page and shortcode-scoped encrypted blocks.
* - Persist and validate page-level decrypt cache with expiration.
* - Emit FixIt events for content/TOC rendering and re-encrypt flows.
*
* Encrypted payloads are stored in `<template data-password="..." data-cipher="...">` elements.
* Using `<template>` prevents the browser from rendering inner content (mermaid, echarts, etc.).
*
* Payload formats:
* - v2 (current): PBKDF2(SHA-256(password), 100k iterations) → base64(salt).base64(iv).base64(ciphertext+tag)
* - Password verification: data-password stores PBKDF2(SHA-256(password), data-verify-salt) when fixit-encrypted
*/
import { eventBus } from '../core/event-bus'
import { flashTooltip } from '../utils'
interface DecryptorOptions {
duration?: number
}
interface CachedStat {
expiration: number
password: string
/** SHA-256 hash of the password, used for AES key derivation */
sha256: string
}
const PBKDF2_ITERATIONS = 100_000
class FixItDecryptor {
options: Required<DecryptorOptions>
/**
* Create a decryptor instance and register custom elements.
* @param options - Configuration options.
* @param options.duration - Cache duration in seconds for decrypted content (default: 24 hours).
*/
constructor(options: DecryptorOptions = {}) {
this.options = { duration: options.duration || 24 * 60 * 60 }
customElements.get('fixit-encryptor') || customElements.define('fixit-encryptor', class extends HTMLElement {})
}
/** Toggle encrypted/decrypted visibility helper classes in the given scope. */
#toggleClass(container: Element | Document, show: boolean): void {
const fromClass = show ? 'encrypted-hidden' : 'decrypted-shown'
const toClass = show ? 'decrypted-shown' : 'encrypted-hidden'
container.querySelectorAll(`.${fromClass}`).forEach(($element: Element) => {
$element.classList.replace(fromClass, toClass)
})
}
/** Copy TOC HTML to all TOC containers. */
#renderToc(html: string): void {
const $template = document.createElement('template')
$template.innerHTML = html
const $tocCore = $template.content.querySelector('#TableOfContents')
if (!$tocCore)
return
for (const id of ['toc-content-static', 'toc-content-auto', 'toc-content-drawer']) {
const $container = document.getElementById(id)
if (!$container)
continue
$container.textContent = ''
const $clone = $tocCore.cloneNode(true) as HTMLElement
$clone.removeAttribute('id')
$container.appendChild($clone)
}
}
/**
* Decrypt a v2 payload using PBKDF2 key derivation.
* Format: base64(salt).base64(iv).base64(ciphertext+tag)
*/
async #decryptV2(payload: string, passwordHash: string): Promise<string> {
const [saltBase64, ivBase64, encryptedBase64] = payload.split('.', 3)
if (!saltBase64 || !ivBase64 || !encryptedBase64) {
throw new Error('Invalid v2 payload format: expected 3 segments')
}
const salt = Uint8Array.from(atob(saltBase64), c => c.charCodeAt(0))
const iv = Uint8Array.from(atob(ivBase64), c => c.charCodeAt(0))
const encrypted = Uint8Array.from(atob(encryptedBase64), c => c.charCodeAt(0))
const passwordBytes = new TextEncoder().encode(passwordHash)
const baseKey = await crypto.subtle.importKey('raw', passwordBytes, 'PBKDF2', false, ['deriveKey'])
const aesKey = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt'],
)
const plainBuffer = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv, tagLength: 128 },
aesKey,
encrypted,
)
return new TextDecoder().decode(plainBuffer)
}
/**
* Compute SHA-256 hex digest for user input.
* @param input - Raw password input.
* @returns Lowercase hex SHA-256 digest.
*/
async #sha256Hex(input: string): Promise<string> {
const bytes = new TextEncoder().encode(input)
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes)
return Array.from(new Uint8Array(hashBuffer))
.map(byte => byte.toString(16).padStart(2, '0'))
.join('')
}
/**
* Derive a verification hash using PBKDF2 from a SHA-256 password hash.
*/
async #deriveVerifyHash(passwordHash: string, salt: ArrayBuffer): Promise<string> {
const passwordBytes = new TextEncoder().encode(passwordHash)
const baseKey = await crypto.subtle.importKey('raw', passwordBytes, 'PBKDF2', false, ['deriveBits'])
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
baseKey,
256,
)
return Array.from(new Uint8Array(bits))
.map(byte => byte.toString(16).padStart(2, '0'))
.join('')
}
/**
* Decrypt TOC template content and refill TOC containers directly.
* The template itself stays encrypted so re-encrypt can restore the initial state.
* @param passwordHash - SHA-256 hash for AES key derivation.
*/
async #decryptToc(passwordHash: string): Promise<void> {
const $tocTemplate = document.querySelector<HTMLTemplateElement>('template[data-toc]')
if (!$tocTemplate)
return
try {
const cipher = $tocTemplate.dataset.cipher
const payload = $tocTemplate.innerHTML
let html: string
if (!cipher) {
// Dev mode: no post-build encryption, TOC is plaintext
html = payload
}
else if (cipher === 'aes-256-gcm-v2') {
html = await this.#decryptV2(payload, passwordHash)
}
else {
throw new Error(`Unsupported cipher: ${cipher}`)
}
this.#renderToc(html)
eventBus.emit('fixit:toc-decrypted', { html })
}
catch (err) {
console.error('[FixItDecryptor] Failed to restore TOC:', err)
}
}
/**
* Decrypt template payload and write plaintext into the target container.
* @param $template - Encrypted content template element.
* @param $target - Target container that should receive decrypted content.
* @param passwordHash - SHA-256 hash for AES key derivation.
*/
async #decryptContent($template: HTMLTemplateElement, $target: HTMLElement, passwordHash: string): Promise<void> {
try {
const cipher = $template.dataset.cipher
const payload = $template.innerHTML
let html: string
if (!cipher) {
// Dev mode: no post-build encryption, content is plaintext
html = payload
}
else if (cipher === 'aes-256-gcm-v2') {
html = await this.#decryptV2(payload, passwordHash)
}
else {
throw new Error(`Unsupported cipher: ${cipher}`)
}
$target.innerHTML = html
$template.parentElement!.classList.add('decrypted')
const isPage = $target.id === 'content'
this.#toggleClass(isPage ? document : $target, true)
eventBus.emit('fixit:content-decrypted', { target: $target, isPage })
}
catch (err) {
const $encryptor = $template.parentElement!
const $input = $encryptor.querySelector<HTMLInputElement>('.fixit-decryptor-input')
if ($input) {
flashTooltip($input, err instanceof Error ? err.message : 'Decryption failed')
}
return console.error(err)
}
}
/**
* Validate user input against the stored password hash; invoke callback on success.
* Supports both PBKDF2-verified (post-build) and plain SHA-256 (dev mode) passwords.
* @param $encryptor - The `<fixit-encryptor>` element containing the input field.
* @param callback - Invoked with `(template, sha256Hash)` on success.
*/
async #validatePassword($encryptor: Element, callback: ($template: HTMLTemplateElement, passwordHash: string, verifyHash?: string) => Promise<void>): Promise<void> {
const $template = $encryptor.querySelector<HTMLTemplateElement>('template[data-password]')!
const storedHash = $template.dataset.password!
const verifySalt = $template.dataset.verifySalt
const inputEl = $encryptor.querySelector<HTMLInputElement>('.fixit-decryptor-input')!
const input = inputEl.value.trim()
const inputSha256 = await this.#sha256Hex(input)
inputEl.value = ''
inputEl.blur()
if (!input) {
flashTooltip(inputEl, 'Please enter the correct password!')
return console.warn('Please enter the correct password!')
}
let matches: boolean
let verifyHash: string
if (verifySalt) {
// Post-build: stored hash is PBKDF2(SHA-256(password), verifySalt)
const salt = Uint8Array.from(atob(verifySalt), c => c.charCodeAt(0)).buffer as ArrayBuffer
verifyHash = await this.#deriveVerifyHash(inputSha256, salt)
matches = verifyHash === storedHash
}
else {
// Dev mode: stored hash is SHA-256(password)
verifyHash = inputSha256
matches = inputSha256 === storedHash
}
if (!matches) {
flashTooltip(inputEl, `Password error: ${input} not the correct password!`)
return console.warn(`Password error: ${input} not the correct password!`)
}
// Store verifyHash for cache validation, inputSha256 for AES key derivation
await callback($template, inputSha256, verifyHash)
}
/**
* Initialize page-level and/or shortcode-level decryption based on flags.
* @param options - `{ all?, shortcode? }` controlling which modes to activate.
* @param options.all - Enable whole-page decryption.
* @param options.shortcode - Enable shortcode-level decryption.
*/
init({ all, shortcode }: { all?: boolean, shortcode?: boolean }): void {
if (shortcode) {
eventBus.on('fixit:content-decrypted', ({ detail }) => {
this.initShortcodes(detail.target)
})
}
if (all) {
this.initPage()
}
else if (shortcode) {
this.initShortcodes(document.querySelector<HTMLElement>('#content')!)
}
}
/** Initialize whole-page decryption with cache validation and input handlers. */
initPage(): void {
this.validateCache()
const $encryptor = document.querySelector<HTMLElement>('article > fixit-encryptor')!
const $content = document.querySelector<HTMLElement>('#content')!
const decryptorHandler = () => {
void this.#validatePassword($encryptor, async ($template, passwordHash, verifyHash) => {
window.localStorage?.setItem(
`fixit-decryptor/#${location.pathname}`,
JSON.stringify({
expiration: Math.ceil(Date.now() / 1000) + this.options.duration,
password: verifyHash ?? passwordHash,
sha256: passwordHash,
}),
)
await this.#decryptToc(passwordHash)
await this.#decryptContent($template, $content, passwordHash)
}).catch(console.error)
}
$encryptor.querySelector('.fixit-decryptor-input')?.addEventListener('keydown', (e) => {
if ((e as KeyboardEvent).key === 'Enter') {
e.preventDefault()
decryptorHandler()
}
})
$encryptor.querySelector('.fixit-decryptor-btn')?.addEventListener('click', (e) => {
e.preventDefault()
decryptorHandler()
})
// Re-encrypt button only orchestrates state/cache reset; modules handle DOM cleanup.
const $reEncryptBtn = $encryptor.querySelector<HTMLElement>('.fixit-encryptor-btn')
$reEncryptBtn?.addEventListener('click', (e) => {
e.preventDefault()
window.CellTooltip?.getOrCreateInstance($reEncryptBtn).dispose()
for (const id of ['toc-content-static', 'toc-content-auto', 'toc-content-drawer']) {
const $el = document.getElementById(id)
if ($el) {
$el.textContent = ''
}
}
$content.animate(
[
{ opacity: 1, transform: 'scaleY(1)', transformOrigin: 'top' },
{ opacity: 0, transform: 'scaleY(0)', transformOrigin: 'top' },
],
{ duration: 200, easing: 'ease-out' },
).finished.then(() => {
$content.textContent = ''
$content.style.opacity = ''
$content.style.transform = ''
})
$encryptor.classList.remove('decrypted')
this.#toggleClass(document, false)
window.localStorage?.removeItem(`fixit-decryptor/#${location.pathname}`)
eventBus.emit('fixit:re-encrypt')
})
$encryptor.classList.add('initialized')
}
/**
* Initialize decryption for all unprocessed `fixit-encryptor` shortcodes under a parent.
* @param $parent - The parent element to search for shortcodes.
*/
initShortcodes($parent: Element): void {
const $shortcodes = $parent.querySelectorAll<HTMLElement>('fixit-encryptor:not(.initialized)')
$shortcodes.forEach(($shortcode) => {
const decryptorHandler = () => {
const $content = $shortcode.querySelector<HTMLElement>('.decryptor-content')!
void this.#validatePassword($shortcode, async ($template, passwordHash) => {
await this.#decryptContent($template, $content, passwordHash)
}).catch(console.error)
}
$shortcode.querySelector('.fixit-decryptor-input')?.addEventListener('keydown', (e) => {
if ((e as KeyboardEvent).key === 'Enter') {
e.preventDefault()
decryptorHandler()
}
})
$shortcode.querySelector('.fixit-decryptor-btn')?.addEventListener('click', (e) => {
e.preventDefault()
decryptorHandler()
})
$shortcode.classList.add('initialized')
})
}
/** Restore decrypted content from localStorage cache if the password has not expired. */
validateCache(): this {
const $content = document.querySelector<HTMLElement>('#content')!
const $encryptor = document.querySelector<HTMLElement>('article > fixit-encryptor')!
const $template = $encryptor.querySelector<HTMLTemplateElement>('template[data-password]')!
const password = $template.dataset.password
const cachedStat: CachedStat | null = JSON.parse(window.localStorage?.getItem(`fixit-decryptor/#${location.pathname}`) || 'null')
if (!cachedStat || cachedStat.password !== password || cachedStat.expiration < Math.ceil(Date.now() / 1000)) {
if (cachedStat) {
window.localStorage?.removeItem(`fixit-decryptor/#${location.pathname}`)
console.warn('The password has expired, please re-enter!')
}
return this
}
// Use sha256 hash for AES key derivation (not the verification hash)
void this.#decryptToc(cachedStat.sha256)
.then(() => this.#decryptContent($template, $content, cachedStat.sha256))
return this
}
}
window.FixItDecryptor = FixItDecryptor