-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Commit
- Loading branch information
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
--- | ||
title: Password Protected | ||
--- | ||
|
||
Some notes may be sensitive, IE. Internal company documentation, business notes, personal health data 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, or confidential business data, 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. | ||
|
||
## Configuration | ||
|
||
- Disable password protected notes: set the `enablePasswordProtected` field in `quartz.config.ts` to be `false`. | ||
- Style: `quartz/components/styles/passwordProtected.scss` | ||
- Script: `quartz/components/scripts/decrypt.inline.ts` |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types" | ||
import { i18n } from "../../i18n" | ||
|
||
const EncryptedContent: QuartzComponent = ({ iteration, 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>{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={iteration}>{encryptedContent}</pre> | ||
</div> | ||
<article id="content"></article> | ||
</> | ||
) | ||
} | ||
|
||
export default (() => EncryptedContent) satisfies QuartzComponentConstructor |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,167 @@ | ||
import { base64 } from 'rfc4648' | ||
|
||
function find<T>(selector: string): T { | ||
Check failure on line 3 in quartz/components/scripts/decrypt.inline.ts
|
||
const element = document.querySelector(selector) as T | ||
if (element) return element | ||
} | ||
|
||
let salt: Uint8Array, iv: Uint8Array, ciphertext: Uint8Array, iterations: number | ||
|
||
async function decryptHTML() { | ||
const pl = find<HTMLPreElement>('pre[data-i]') | ||
if (!pl) { | ||
return | ||
} | ||
|
||
find<HTMLFormElement>('form').addEventListener('submit', async (event) => { | ||
event.preventDefault() | ||
await decrypt() | ||
}) | ||
const pwd = find<HTMLInputElement>('.pwd') | ||
if (!subtle) { | ||
error('modern') | ||
pwd.disabled = true | ||
} | ||
|
||
show(find<HTMLDivElement>('#lock')) | ||
if (!pl.innerHTML) { | ||
pwd.disabled = true | ||
error('empty') | ||
return | ||
} | ||
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(find<HTMLDivElement>('#load')) | ||
show(find<HTMLFormElement>('form')) | ||
pwd.focus() | ||
} | ||
} | ||
|
||
document.addEventListener('DOMContentLoaded', decryptHTML) | ||
document.addEventListener('nav', decryptHTML) | ||
|
||
const subtle = | ||
window.crypto?.subtle || | ||
(window.crypto as unknown as { webkitSubtle: Crypto['subtle'] }) | ||
?.webkitSubtle | ||
|
||
function show(element: Element) { | ||
element.classList.remove('hidden') | ||
} | ||
|
||
function hide(element: Element) { | ||
element.classList.add('hidden') | ||
} | ||
|
||
function error(code: string) { | ||
const msg = find<HTMLParagraphElement>('#msg') | ||
msg.innerText = msg.getAttribute('data-' + code!) | ||
Check failure on line 71 in quartz/components/scripts/decrypt.inline.ts
|
||
} | ||
|
||
async function sleep(milliseconds: number): Promise<void> { | ||
return new Promise((resolve) => setTimeout(resolve, milliseconds)) | ||
} | ||
|
||
async function decrypt() { | ||
find<HTMLDivElement>('#load').lastElementChild.innerText = 'Decrypting...' | ||
Check failure on line 79 in quartz/components/scripts/decrypt.inline.ts
|
||
show(find<HTMLDivElement>('#load')) | ||
hide(find<HTMLFormElement>('form')) | ||
const pwd = find<HTMLInputElement>('.pwd') | ||
await sleep(60) | ||
|
||
try { | ||
const decrypted = await decryptFile( | ||
{ salt, iv, ciphertext, iterations }, | ||
pwd.value, | ||
) | ||
|
||
const article = find<HTMLArticleElement>('#content') | ||
Check failure on line 91 in quartz/components/scripts/decrypt.inline.ts
|
||
article.innerHTML = decrypted | ||
hide(find<HTMLDivElement>('#lock')) | ||
} catch (e) { | ||
hide(find<HTMLDivElement>('#load')) | ||
show(find<HTMLFormElement>('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' | ||
|
||
try { | ||
sessionStorage[document.body.dataset.slug!] = JSON.stringify(await subtle.exportKey('jwk', key)) | ||
} catch (e) { | ||
console.error(e) | ||
} | ||
|
||
return decoder.decode(data) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
.hidden { | ||
display: none; | ||
} | ||
|
||
.pwd { | ||
box-sizing: border-box; | ||
font-family: var(--bodyFont); | ||
color: var(--dark); | ||
border: 1px solid var(--lightgray); | ||
padding: .5em 1em; | ||
background: var(--light); | ||
border-radius: 7px; | ||
width: 70%; | ||
margin-bottom: 2em; | ||
margin-right: 2em; | ||
} | ||
|
||
.pwd + input { | ||
font-style: normal; | ||
font-weight: 700; | ||
font-size: 15px; | ||
line-height: 1; | ||
color: var(--light); | ||
padding: 7px 15px; | ||
background-color: var(--dark); | ||
margin-top: 1px; | ||
border-radius: 7px; | ||
position: relative; | ||
padding: .6em 1em; | ||
} |