diff --git a/README.md b/README.md index e8a5998..5254c14 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/Ar - Outline panel, daily notes with calendar view, tags with autocomplete, drag-and-drop - Live KaTeX math editor (`/math`, `/imath`) with modal preview, double-click to edit - Mermaid diagrams (opt-in render, copy as PNG, save as PNG/SVG) +- Encrypted secret blocks (`/secret`) stored as portable `helix-secret` markdown fences - Insert date/time (`/date`, `/time`, `/now`), color swatches (`/color`), configurable week start - Manual notebook sorting (drag to reorder above, into, or below) - External `.md` viewer mode with import-to-vault flow diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index a75fe57..3268204 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -43,6 +43,7 @@ import type { VersionEntry, AiStreamEvent, NoteTitleEntry } from '$lib/types'; import { listen } from '@tauri-apps/api/event'; import { debounce } from '$lib/utils/debounce'; + import { encryptSecretText, decryptSecretText, readSecretTitle } from '$lib/utils/secrets'; import GraphView from './GraphView.svelte'; import TagSuggestInput from './TagSuggestInput.svelte'; @@ -215,6 +216,7 @@ { label: 'Numbered List', aliases: ['ol', 'ordered', 'number'], icon: '123', action: () => editor?.chain().focus().toggleOrderedList().run() }, { label: 'Task List', aliases: ['checklist', 'checkbox', 'todo', 'check'], icon: '', action: () => editor?.chain().focus().toggleTaskList().run() }, { label: 'Code Block', aliases: ['code', 'codeblock', 'pre', 'snippet'], icon: '', action: () => editor?.chain().focus().toggleCodeBlock().run() }, + { label: 'Secret', aliases: ['secret', 'encrypt', 'password', 'private'], icon: '', action: () => openSecretInsert() }, { label: 'Blockquote', aliases: ['quote', 'blockquote', 'citation'], icon: '', action: () => editor?.chain().focus().toggleBlockquote().run() }, { label: 'Collapsible Section', aliases: ['details', 'accordion', 'collapse', 'toggle', 'summary'], icon: '', action: () => insertDetails() }, { label: 'Table', aliases: ['table', 'grid'], icon: '', action: () => { slashTablePicker = true; slashTableHover = { rows: 0, cols: 0 }; } }, @@ -289,6 +291,77 @@ editor.chain().focus().insertContent({ type: nodeType, attrs: { tex: trimmed } }).run(); } } + + type SecretModalState = { + title: string; + text: string; + passphrase: string; + confirmPassphrase: string; + from: number; + to: number; + error: string; + busy: boolean; + }; + + let secretModal = $state(null); + let secretTitleInput = $state(null); + + function selectedPlainText(): string { + if (!editor) return ''; + const { from, to } = editor.state.selection; + return from === to ? '' : editor.state.doc.textBetween(from, to, '\n\n'); + } + + function openSecretInsert() { + const selection = editor?.state.selection; + secretModal = { + title: 'Encrypted secret', + text: selectedPlainText(), + passphrase: '', + confirmPassphrase: '', + from: selection?.from ?? 0, + to: selection?.to ?? 0, + error: '', + busy: false, + }; + tick().then(() => { + secretTitleInput?.focus(); + secretTitleInput?.select(); + }); + } + + function cancelSecretModal() { + secretModal = null; + } + + async function commitSecretModal() { + if (!editor || !secretModal || secretModal.busy) return; + if (!secretModal.text) { + secretModal.error = 'Secret text is required.'; + return; + } + if (!secretModal.passphrase) { + secretModal.error = 'Passphrase is required.'; + return; + } + if (secretModal.passphrase !== secretModal.confirmPassphrase) { + secretModal.error = 'Passphrases do not match.'; + return; + } + secretModal.busy = true; + secretModal.error = ''; + try { + const payload = await encryptSecretText(secretModal.text, secretModal.passphrase, secretModal.title); + const { from, to } = secretModal; + secretModal = null; + editor.chain().focus().setTextSelection({ from, to }).insertContent({ type: 'secretBlock', attrs: { payload } }).run(); + } catch (e: any) { + if (secretModal) { + secretModal.error = e?.message || 'Could not encrypt secret.'; + secretModal.busy = false; + } + } + } const katexCache = new Map(); function renderKatex(tex: string, displayMode: boolean): string { const key = (displayMode ? 'B:' : 'I:') + tex; @@ -515,6 +588,144 @@ }, }); + function renderSecretNode(dom: HTMLElement, payload: string) { + dom.textContent = ''; + const secretTitle = readSecretTitle(payload); + + const header = document.createElement('div'); + header.className = 'secret-block-header'; + + const title = document.createElement('span'); + title.className = 'secret-block-title'; + title.innerHTML = ''; + title.append(document.createTextNode(secretTitle)); + + const badge = document.createElement('span'); + badge.className = 'secret-block-badge'; + badge.textContent = 'AES-GCM'; + + header.append(title, badge); + + const form = document.createElement('form'); + form.className = 'secret-block-form'; + + const input = document.createElement('input'); + input.type = 'password'; + input.placeholder = 'Passphrase'; + input.autocomplete = 'current-password'; + + const unlock = document.createElement('button'); + unlock.type = 'submit'; + unlock.textContent = 'Unlock'; + + const error = document.createElement('div'); + error.className = 'secret-block-error'; + + form.append(input, unlock); + dom.append(header, form, error); + + form.addEventListener('submit', async (event) => { + event.preventDefault(); + error.textContent = ''; + unlock.disabled = true; + try { + const plaintext = await decryptSecretText(payload, input.value); + renderUnlockedSecretNode(dom, payload, plaintext); + } catch (e: any) { + error.textContent = e?.message || 'Unable to unlock secret.'; + unlock.disabled = false; + } + }); + } + + function renderUnlockedSecretNode(dom: HTMLElement, payload: string, plaintext: string) { + dom.textContent = ''; + const secretTitle = readSecretTitle(payload); + + const header = document.createElement('div'); + header.className = 'secret-block-header'; + + const title = document.createElement('span'); + title.className = 'secret-block-title'; + title.textContent = secretTitle; + + const actions = document.createElement('div'); + actions.className = 'secret-block-actions'; + + const copy = document.createElement('button'); + copy.type = 'button'; + copy.textContent = 'Copy'; + copy.addEventListener('click', async () => { + await navigator.clipboard.writeText(plaintext); + copy.textContent = 'Copied'; + setTimeout(() => { copy.textContent = 'Copy'; }, 1200); + }); + + const lock = document.createElement('button'); + lock.type = 'button'; + lock.textContent = 'Lock'; + lock.addEventListener('click', () => renderSecretNode(dom, payload)); + + actions.append(copy, lock); + header.append(title, actions); + + const body = document.createElement('pre'); + body.className = 'secret-block-plaintext'; + body.textContent = plaintext; + + dom.append(header, body); + } + + function decodeSecretPayload(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return ''; + } + } + + const SecretBlock = TiptapNode.create({ + name: 'secretBlock', + group: 'block', + atom: true, + selectable: true, + addAttributes() { + return { payload: { default: '' } }; + }, + parseHTML() { + return [{ + tag: 'div[data-secret-block]', + getAttrs: (el: HTMLElement) => ({ + payload: decodeSecretPayload(el.getAttribute('data-secret-block') || ''), + }), + }]; + }, + renderHTML({ HTMLAttributes }) { + const payload = HTMLAttributes.payload || ''; + return ['div', { 'data-secret-block': encodeURIComponent(payload), class: 'secret-block' }]; + }, + addNodeView() { + return ({ node }) => { + let payload = node.attrs.payload || ''; + const dom = document.createElement('div'); + dom.className = 'secret-block'; + dom.contentEditable = 'false'; + renderSecretNode(dom, payload); + return { + dom, + update(updatedNode) { + if (updatedNode.type.name !== 'secretBlock') return false; + payload = updatedNode.attrs.payload || ''; + renderSecretNode(dom, payload); + return true; + }, + stopEvent: () => true, + ignoreMutation: () => true, + }; + }; + }, + }); + const MathBlock = TiptapNode.create({ name: 'mathBlock', group: 'block', @@ -2361,6 +2572,10 @@ const name = node.attrs.name || ''; return `
\n`; } + case 'secretBlock': { + const payload = (node.attrs.payload || '').trim(); + return `\`\`\`helix-secret\n${payload}\n\`\`\`\n`; + } case 'mathBlock': { const tex = node.attrs.tex || ''; return `$$\n${tex}\n$$\n`; @@ -2809,8 +3024,40 @@ return md.trim() + '\n'; } + function secretFencesToHtml(md: string): string { + const lines = md.split('\n'); + const out: string[] = []; + for (let i = 0; i < lines.length; i++) { + if (!/^\s*```helix-secret\s*$/.test(lines[i])) { + out.push(lines[i]); + continue; + } + + const payloadLines: string[] = []; + let closedAt = -1; + for (let j = i + 1; j < lines.length; j++) { + if (/^\s*```\s*$/.test(lines[j])) { + closedAt = j; + break; + } + payloadLines.push(lines[j]); + } + + if (closedAt === -1) { + out.push(lines[i], ...payloadLines); + break; + } + + const payload = payloadLines.join('\n'); + out.push(`
`); + i = closedAt; + } + return out.join('\n'); + } + function markdownToHtml(md: string): string { let src = stripTitleH1(md); + src = secretFencesToHtml(src); // Pre-process: convert [[Note Title]] wiki-links to HTML anchors // Supports Obsidian syntax: [[note|alias]], [[note#heading]], [[note^block]] @@ -3068,6 +3315,7 @@ CopyButtonExtension, MermaidRenderer, PdfEmbed, + SecretBlock, MathBlock, MathInline, PageBreak, @@ -4883,6 +5131,10 @@ Code Block + + + + { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); commitSecretModal(); } + if (e.key === 'Escape') { e.preventDefault(); cancelSecretModal(); } + }} + /> + +
+ { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); commitSecretModal(); } + if (e.key === 'Escape') { e.preventDefault(); cancelSecretModal(); } + }} + /> + { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); commitSecretModal(); } + if (e.key === 'Escape') { e.preventDefault(); cancelSecretModal(); } + }} + /> +
+ {#if secretModal.error} +
{secretModal.error}
+ {/if} + + + +{/if} + {#if viewerImportPickerOpen}
(viewerImportPickerOpen = false)}> @@ -6910,6 +7234,101 @@ opacity: 0.7; } + :global(.tiptap-wrapper .tiptap .secret-block) { + border: 1px solid var(--border); + border-radius: 8px; + background: color-mix(in srgb, var(--accent) 7%, var(--bg-secondary)); + margin: 1em 0; + padding: 12px; + user-select: text; + } + + :global(.secret-block-header) { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; + } + + :global(.secret-block-title) { + display: inline-flex; + align-items: center; + gap: 7px; + font-weight: 600; + font-size: 13px; + color: var(--text-primary); + } + + :global(.secret-block-lock) { + flex: 0 0 auto; + } + + :global(.secret-block-badge) { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 11px; + color: var(--text-tertiary); + } + + :global(.secret-block-form), + :global(.secret-block-actions) { + display: flex; + gap: 8px; + } + + :global(.secret-block-form input) { + flex: 1; + min-width: 0; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--bg-primary); + color: var(--text-primary); + padding: 7px 9px; + font: inherit; + } + + :global(.secret-block-form input:focus) { + outline: none; + border-color: var(--accent); + } + + :global(.secret-block-form button), + :global(.secret-block-actions button) { + border: 1px solid var(--border); + border-radius: 5px; + background: var(--bg-primary); + color: var(--text-primary); + padding: 7px 10px; + font: inherit; + cursor: pointer; + } + + :global(.secret-block-form button:hover:not(:disabled)), + :global(.secret-block-actions button:hover) { + border-color: var(--accent); + } + + :global(.secret-block-form button:disabled) { + opacity: 0.6; + cursor: not-allowed; + } + + :global(.secret-block-error) { + color: #d32f2f; + font-size: 12px; + margin-top: 8px; + } + + :global(.secret-block-plaintext) { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 13px; + line-height: 1.5; + color: var(--text-primary); + } + /* Syntax highlighting - light mode */ :global(.tiptap pre code .hljs-keyword), :global(.tiptap pre code .hljs-selector-tag), @@ -7087,6 +7506,59 @@ cursor: not-allowed; } + .secret-modal { + max-width: min(720px, 92vw); + } + + .secret-modal-text { + min-height: 140px; + } + + .secret-modal-title { + border: 1px solid var(--border); + border-radius: 5px; + background: var(--bg-secondary); + color: var(--text-primary); + padding: 9px 10px; + font: inherit; + outline: none; + } + + .secret-modal-title:focus { + border-color: var(--accent); + } + + .secret-modal-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + } + + .secret-modal-fields input { + border: 1px solid var(--border); + border-radius: 5px; + background: var(--bg-secondary); + color: var(--text-primary); + padding: 9px 10px; + font: inherit; + outline: none; + } + + .secret-modal-fields input:focus { + border-color: var(--accent); + } + + .secret-modal-error { + color: #d32f2f; + font-size: 12px; + } + + @media (max-width: 640px) { + .secret-modal-fields { + grid-template-columns: 1fr; + } + } + .viewer-banner { display: flex; align-items: center; diff --git a/src/lib/utils/secrets.ts b/src/lib/utils/secrets.ts new file mode 100644 index 0000000..198aea9 --- /dev/null +++ b/src/lib/utils/secrets.ts @@ -0,0 +1,150 @@ +const SECRET_VERSION = 2; +const SECRET_CIPHER = 'AES-256-GCM'; +const SECRET_KDF = 'PBKDF2-HMAC-SHA256'; +const SECRET_ITERATIONS = 600_000; +const SALT_BYTES = 16; +const NONCE_BYTES = 12; + +interface SecretEnvelope { + v: number; + title?: string; + cipher: string; + kdf: string; + iterations: number; + salt: string; + nonce: string; + ct: string; +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +function base64ToBytes(value: string, field: string): Uint8Array { + const text = value.trim(); + if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) { + throw new Error(`Invalid secret ${field}`); + } + const binary = atob(text); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +function stableBytes(bytes: Uint8Array): Uint8Array { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy; +} + +function authenticatedMetadata(envelope: SecretEnvelope): string { + return JSON.stringify({ + v: envelope.v, + title: envelope.title ?? 'Encrypted secret', + cipher: envelope.cipher, + kdf: envelope.kdf, + iterations: envelope.iterations, + salt: envelope.salt, + nonce: envelope.nonce, + }); +} + +function envelopeAad(envelope: SecretEnvelope): Uint8Array { + return encoder.encode(authenticatedMetadata(envelope)); +} + +function parseEnvelope(payload: string): SecretEnvelope { + let envelope: SecretEnvelope; + try { + envelope = JSON.parse(payload); + } catch { + throw new Error('Invalid secret payload'); + } + if ( + envelope?.v !== SECRET_VERSION || + envelope.cipher !== SECRET_CIPHER || + envelope.kdf !== SECRET_KDF || + !Number.isInteger(envelope.iterations) || + envelope.iterations < 100_000 || + envelope.iterations > 5_000_000 + ) { + throw new Error('Unsupported secret payload'); + } + return envelope; +} + +export function readSecretTitle(payload: string): string { + try { + const parsed = JSON.parse(payload); + return typeof parsed?.title === 'string' && parsed.title.trim() + ? parsed.title.trim() + : 'Encrypted secret'; + } catch { + return 'Encrypted secret'; + } +} + +async function deriveKey(passphrase: string, salt: Uint8Array, iterations: number): Promise { + if (!passphrase) throw new Error('Passphrase is required'); + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(passphrase), + 'PBKDF2', + false, + ['deriveKey'], + ); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', hash: 'SHA-256', salt: stableBytes(salt), iterations }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); +} + +export async function encryptSecretText(plaintext: string, passphrase: string, title = 'Encrypted secret'): Promise { + if (!plaintext) throw new Error('Secret text is required'); + const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES)); + const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES)); + const key = await deriveKey(passphrase, salt, SECRET_ITERATIONS); + const envelope: SecretEnvelope = { + v: SECRET_VERSION, + title: title.trim() || 'Encrypted secret', + cipher: SECRET_CIPHER, + kdf: SECRET_KDF, + iterations: SECRET_ITERATIONS, + salt: bytesToBase64(salt), + nonce: bytesToBase64(nonce), + ct: '', + }; + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: stableBytes(nonce), additionalData: stableBytes(envelopeAad(envelope)) }, + key, + encoder.encode(plaintext), + ); + envelope.ct = bytesToBase64(new Uint8Array(encrypted)); + return JSON.stringify(envelope, null, 2); +} + +export async function decryptSecretText(payload: string, passphrase: string): Promise { + try { + const envelope = parseEnvelope(payload); + const salt = base64ToBytes(envelope.salt, 'salt'); + const nonce = base64ToBytes(envelope.nonce, 'nonce'); + const ciphertext = base64ToBytes(envelope.ct, 'ciphertext'); + const key = await deriveKey(passphrase, salt, envelope.iterations); + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: stableBytes(nonce), additionalData: stableBytes(envelopeAad(envelope)) }, + key, + stableBytes(ciphertext), + ); + return decoder.decode(plaintext); + } catch { + throw new Error('Unable to unlock secret. Check the passphrase or payload.'); + } +}