Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow to set password-protected notes #1481

Open
wants to merge 1 commit into
base: v4
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ This part of the configuration concerns anything that can affect the whole site.
- `pageTitleSuffix`: a string added to the end of the page title. This only applies to the browser tab title, not the title shown at the top of the page.
- `enableSPA`: whether to enable [[SPA Routing]] on your site.
- `enablePopovers`: whether to enable [[popover previews]] on your site.
- `passProtected`: what to use [[Password Protected]] on your site.
- `enabled`: whether to enable password protected
- `iteration`: iteration of key derivation, default is `2e6`
- `analytics`: what to use for analytics on your site. Values can be
- `null`: don't use analytics;
- `{ provider: 'google', tagId: '<your-google-tag>' }`: use Google Analytics;
Expand Down
32 changes: 32 additions & 0 deletions docs/features/Password Protected.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
title: Password Protected
---

Some notes may be sensitive, i.e. non-public personal projects, contacts, meeting notes and such. It would be really useful to be able to protect some pages or group of pages so they don't appear to everyone, while still allowing them to be published.

By adding a password to your note's frontmatter, you can create an extra layer of security, ensuring that only authorized individuals can access your content. Whether you're safeguarding personal journals, project plans, this feature provides the peace of mind you need.

## How it works

Simply add a password field to your note's frontmatter and set your desired password. When you try to view the note, you'll be prompted to enter the password. If the password is correct, the note will be unlocked. Once unlocked, you can access the note until you clear your browser cookies.

### Security techniques

- Key Derivation: Utilizes PBKDF2 for generating secure encryption keys.
- Unique Salt for Each Encryption: A unique salt is generated every time the encrypt method is used, enhancing security.
- Encryption/Decryption: Implements AES-GCM for robust data encryption and decryption.
- Encoding/Decoding: Use base64 to convert non-textual encrypted data in HTML

### Disclaimer

- Use it at your own risk
- You need to choose a strong password and share it only to trusted users
- You need to secure your notes and Quartz repository in private mode on Github/Gitlab/Bitbucket... or use your own Git server
- You can use other WAF tools to enhance security, based on URL of notes that Quartz build for you, e.g. Cloudflare WAF, AWS WAF, Google Cloud Armor...

## Configuration

- Enable password protected notes: set the `passwordProtected.enabled` field in `quartz.config.ts` to be `true`.
- Change iteration count of key derivation: set the `passwordProtected.iteration` filed in `quartz.config.ts` to any bigger than 2e6.
- Style: `quartz/components/styles/passwordProtected.scss`
- Script: `quartz/components/scripts/decrypt.inline.ts`
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"remark-smartypants": "^3.0.2",
"rfc4648": "^1.5.3",
"rfdc": "^1.4.1",
"rimraf": "^6.0.1",
"serve-handler": "^6.1.5",
Expand Down
4 changes: 4 additions & 0 deletions quartz.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const config: QuartzConfig = {
baseUrl: "quartz.jzhao.xyz",
ignorePatterns: ["private", "templates", ".obsidian"],
defaultDateType: "created",
passProtected: {
enabled: false,
iteration: 2e6,
},
theme: {
fontOrigin: "googleFonts",
cdnCaching: true,
Expand Down
9 changes: 9 additions & 0 deletions quartz/cfg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ export type Analytics =
projectId?: string
}

export type PassProtected = {
/** Whether to enable password protected page rendering */
enabled: boolean
/** Iteration of derived key to encrypt page */
iteration: number
}

export interface GlobalConfiguration {
pageTitle: string
pageTitleSuffix?: string
Expand All @@ -56,6 +63,8 @@ export interface GlobalConfiguration {
ignorePatterns: string[]
/** Whether to use created, modified, or published as the default type of date */
defaultDateType: ValidDateType
/** Password protected page rendering */
passProtected: PassProtected
/** Base URL to use for CNAME files, sitemaps, and RSS feeds that require an absolute URL.
* Quartz will avoid using this as much as possible and use relative URLs most of the time
*/
Expand Down
2 changes: 1 addition & 1 deletion quartz/cli/handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ export async function handleBuild(argv) {

// remove default exports that we manually inserted
text = text.replace("export default", "")
text = text.replace("export", "")
text = text.replace("export ", "")
dynamotn marked this conversation as resolved.
Show resolved Hide resolved

const sourcefile = path.relative(path.resolve("."), args.path)
const resolveDir = path.dirname(sourcefile)
Expand Down
41 changes: 41 additions & 0 deletions quartz/components/pages/EncryptedContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
import { i18n } from "../../i18n"

const EncryptedContent: QuartzComponent = ({ encryptedContent, cfg }: QuartzComponentProps) => {
return (
<>
<div id="lock">
<div
id="msg"
data-wrong={i18n(cfg.locale).pages.encryptedContent.wrongPassword}
data-modern={i18n(cfg.locale).pages.encryptedContent.modernBrowser}
data-empty={i18n(cfg.locale).pages.encryptedContent.noPayload}
>
{i18n(cfg.locale).pages.encryptedContent.enterPassword}
</div>
<div id="load">
<p class="spinner"></p>
<p id="load-text" data-decrypt={i18n(cfg.locale).pages.encryptedContent.decrypting}>
{i18n(cfg.locale).pages.encryptedContent.loading}
</p>
</div>
<form class="hidden">
<input
type="password"
class="pwd"
name="pwd"
aria-label={i18n(cfg.locale).pages.encryptedContent.password}
autofocus
/>
<input type="submit" value={i18n(cfg.locale).pages.encryptedContent.submit} />
</form>
<pre class="hidden" data-i={cfg.passProtected?.iteration}>
{encryptedContent}
</pre>
</div>
<article id="content"></article>
</>
)
}

export default (() => EncryptedContent) satisfies QuartzComponentConstructor
19 changes: 16 additions & 3 deletions quartz/components/renderPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { render } from "preact-render-to-string"
import { QuartzComponent, QuartzComponentProps } from "./types"
import HeaderConstructor from "./Header"
import BodyConstructor from "./Body"
import EncryptedContent from "./pages/EncryptedContent"
import { JSResourceToScriptElement, StaticResources } from "../util/resources"
import { getEncryptedPayload } from "../util/encrypt"
import { clone, FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
import { visit } from "unist-util-visit"
import { Root, Element, ElementContent } from "hast"
Expand Down Expand Up @@ -53,13 +55,13 @@ export function pageResources(
}
}

export function renderPage(
export async function renderPage(
cfg: GlobalConfiguration,
slug: FullSlug,
componentData: QuartzComponentProps,
components: RenderComponents,
pageResources: StaticResources,
): string {
): Promise<string> {
// make a deep copy of the tree so we don't remove the transclusion references
// for the file cached in contentMap in build.ts
const root = clone(componentData.tree) as Root
Expand Down Expand Up @@ -195,6 +197,7 @@ export function renderPage(
} = components
const Header = HeaderConstructor()
const Body = BodyConstructor()
const Encrypted = EncryptedContent()

const LeftComponent = (
<div class="left sidebar">
Expand All @@ -213,6 +216,16 @@ export function renderPage(
)

const lang = componentData.fileData.frontmatter?.lang ?? cfg.locale?.split("-")[0] ?? "en"

let content = <Content {...componentData} />
if (cfg.passProtected?.enabled && componentData.fileData.frontmatter?.password) {
componentData.encryptedContent = await getEncryptedPayload(
render(content),
JSON.stringify(componentData.fileData.frontmatter.password),
cfg.passProtected?.iteration,
)
content = <Encrypted {...componentData} />
}
const doc = (
<html lang={lang}>
<Head {...componentData} />
Expand All @@ -233,7 +246,7 @@ export function renderPage(
))}
</div>
</div>
<Content {...componentData} />
{content}
<hr />
<div class="page-footer">
{afterBody.map((BodyComponent) => (
Expand Down
168 changes: 168 additions & 0 deletions quartz/components/scripts/decrypt.inline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { base64 } from "rfc4648"

// @ts-ignore:next-line
function find<T>(selector: string): T {
const element = document.querySelector(selector) as T
if (element) return element
}

let salt: Uint8Array, iv: Uint8Array, ciphertext: Uint8Array, iterations: number
const subtle =
window.crypto?.subtle ||
(window.crypto as unknown as { webkitSubtle: Crypto["subtle"] })?.webkitSubtle

let pl: HTMLPreElement,
form: HTMLFormElement,
pwd: HTMLInputElement,
load: HTMLDivElement,
loadText: HTMLElement,
lock: HTMLDivElement,
msg: HTMLParagraphElement,
article: HTMLElement

async function decryptHTML() {
pl = find<HTMLPreElement>("pre[data-i]")
form = find<HTMLFormElement>("form")
pwd = find<HTMLInputElement>(".pwd")
load = find<HTMLDivElement>("#load")
loadText = find<HTMLElement>("#load-text")
lock = find<HTMLDivElement>("#lock")
msg = find<HTMLParagraphElement>("#msg")
article = find<HTMLElement>("#content")

if (!pl || !form || !pwd) {
return
}
pwd.value = ""
if (!subtle) {
pwd.disabled = true
error("modern")
return
}

show(lock)
if (!pl.innerHTML) {
pwd.disabled = true
error("empty")
return
}

form.addEventListener("submit", async (event) => {
event.preventDefault()
await decrypt()
})

iterations = Number(pl.dataset.i)
const bytes = base64.parse(pl.innerHTML)
salt = bytes.slice(0, 32)
iv = bytes.slice(32, 32 + 16)
ciphertext = bytes.slice(32 + 16)

if (location.hash) {
const parts = location.href.split("#")
pwd.value = parts[1]
history.replaceState(null, "", parts[0])
}

if (sessionStorage[document.body.dataset.slug!] || pwd.value) {
await decrypt()
} else {
hide(load)
show(form)
pwd.focus()
}
}

document.addEventListener("nav", decryptHTML)

function show(element: Element) {
element.classList.remove("hidden")
}

function hide(element: Element) {
element.classList.add("hidden")
}

function error(code: string) {
msg.innerText = msg.getAttribute("data-" + code) || ""
}

async function sleep(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}

async function decrypt() {
loadText.innerText = loadText.getAttribute("data-decrypt") || ""
show(load)
hide(form)
await sleep(60)

try {
const decrypted = await decryptFile({ salt, iv, ciphertext, iterations }, pwd.value)

article.innerHTML = decrypted
hide(lock)
} catch (e) {
hide(load)
show(form)

if (sessionStorage[document.body.dataset.slug!]) {
sessionStorage.removeItem(document.body.dataset.slug!)
} else {
error("wrong")
}

pwd.value = ""
pwd.focus()
}
}

async function deriveKey(
salt: Uint8Array,
password: string,
iterations: number,
): Promise<CryptoKey> {
const encoder = new TextEncoder()
const baseKey = await subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, [
"deriveKey",
])
return await subtle.deriveKey(
{ name: "PBKDF2", salt, iterations, hash: "SHA-256" },
baseKey,
{ name: "AES-GCM", length: 256 },
true,
["decrypt"],
)
}

async function importKey(key: JsonWebKey) {
return subtle.importKey("jwk", key, "AES-GCM", true, ["decrypt"])
}

async function decryptFile(
{
salt,
iv,
ciphertext,
iterations,
}: {
salt: Uint8Array
iv: Uint8Array
ciphertext: Uint8Array
iterations: number
},
password: string,
) {
const decoder = new TextDecoder()

const key = sessionStorage[document.body.dataset.slug!]
? await importKey(JSON.parse(sessionStorage[document.body.dataset.slug!]))
: await deriveKey(salt, password, iterations)

const data = new Uint8Array(await subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext))
if (!data) throw "Malformed data"

sessionStorage[document.body.dataset.slug!] = JSON.stringify(await subtle.exportKey("jwk", key))

return decoder.decode(data)
}
Loading